For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在满月待办中支持多期合并开票申请(主表+子表),财务在缴退费财务确认新增「开票申请」Tab 上传发票附件并完成处理。
Architecture: 新增 biz_protocol_invoice_request 主表与 biz_protocol_invoice_request_item 子表;CRUD 落在 collection_protocol.py(与缴退费确认同模块);API 挂在现有 collection_protocol 路由;前端扩展 FullmoonTodoTab.vue 统一弹窗 + 新建 InvoiceRequestTab.vue 对齐 RefundConfirmTab.vue。
Tech Stack: MySQL 5.7 · FastAPI + SQLAlchemy · Vue 3 + Element Plus · pnpm
Design Doc: docs/superpowers/specs/2026-06-17-protocol-invoice-request-design.md
Reference Pattern Files:
backend/app/crud/collection_protocol.py(list_receipt_confirm_details L1509+)backend/app/api/collection_protocol.py(receipt-confirm L565+)web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vueweb-admin/src/views/cell-storage/payment-confirm/RefundConfirmTab.vueweb-admin/src/components/business/protocol-finance-confirm-fields/| File | Responsibility |
|---|---|
docs/sql/protocol_invoice_request.sql |
主表 + 子表 DDL |
backend/tests/test_protocol_invoice_request.py |
开票申请 CRUD 单元测试 |
web-admin/src/views/cell-storage/payment-confirm/InvoiceRequestTab.vue |
财务开票 Tab |
| File | Change |
|---|---|
backend/app/models/models.py |
+2 Models |
backend/app/crud/collection_protocol.py |
+开票申请 CRUD 函数 |
backend/app/api/collection_protocol.py |
+6 端点 + Pydantic Body |
web-admin/src/api/collection-protocol.ts |
+6 fetch* |
web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue |
+开票申请统一弹窗 |
web-admin/src/views/cell-storage/payment-confirm/index.vue |
+第三 Tab + 角标 |
Files:
Create: docs/sql/protocol_invoice_request.sql
[ ] Step 1: 写入 DDL(与设计文档 §5 一致)
-- docs/sql/protocol_invoice_request.sql
DROP TABLE IF EXISTS `biz_protocol_invoice_request_item`;
DROP TABLE IF EXISTS `biz_protocol_invoice_request`;
CREATE TABLE `biz_protocol_invoice_request` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`collection_protocol_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采集协议单ID',
`invoice_amount` decimal(18,2) NOT NULL COMMENT '开票合计金额',
`invoice_title` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '发票抬头',
`tax_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '税号',
`company_address` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '单位地址',
`company_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '电话',
`bank_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '开户银行',
`bank_account` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '银行账号',
`apply_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '申请备注',
`invoice_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '财务发票附件逗号分隔URL',
`finance_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '财务备注',
`status` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'dkp' COMMENT 'dkp待开票 ykp已开票',
`finance_handle_time` datetime DEFAULT NULL COMMENT '财务处理时间',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_inv_req_protocol` (`collection_protocol_id`,`del_flag`),
KEY `idx_inv_req_status` (`tenant_no`,`status`,`del_flag`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单开票申请主表';
CREATE TABLE `biz_protocol_invoice_request_item` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`invoice_request_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开票申请主表ID',
`payment_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '缴费主表ID',
`payment_detail_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '缴费明细ID',
`installment_no` int(11) DEFAULT NULL COMMENT '期数快照',
`line_amount` decimal(18,2) NOT NULL COMMENT '该行开票金额',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_inv_item_req` (`invoice_request_id`,`del_flag`),
KEY `idx_inv_item_detail` (`payment_detail_id`,`del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单开票申请明细';
mysql -u root -p zkxw < docs/sql/protocol_invoice_request.sql
Expected: 两张表创建成功,无 ERROR。
Files:
Modify: backend/app/models/models.py(在 BizProtocolPaymentDetail 之后插入)
[ ] Step 1: 新增两个 Model 类
class BizProtocolInvoiceRequest(Base):
"""采集协议单开票申请主表 biz_protocol_invoice_request"""
__tablename__ = "biz_protocol_invoice_request"
id = Column(String(64), primary_key=True, comment="主键ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
invoice_amount = Column(Numeric(18, 2), nullable=False, comment="开票合计金额")
invoice_title = Column(String(200), nullable=True, comment="发票抬头")
tax_no = Column(String(64), nullable=True, comment="税号")
company_address = Column(String(512), nullable=True, comment="单位地址")
company_phone = Column(String(64), nullable=True, comment="电话")
bank_name = Column(String(200), nullable=True, comment="开户银行")
bank_account = Column(String(64), nullable=True, comment="银行账号")
apply_remark = Column(String(512), nullable=True, comment="申请备注")
invoice_attachments = Column(Text, nullable=True, comment="财务发票附件")
finance_remark = Column(String(512), nullable=True, comment="财务备注")
status = Column(String(8), nullable=False, default="dkp", comment="dkp/ykp")
finance_handle_time = Column(DateTime, nullable=True, comment="财务处理时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolInvoiceRequestItem(Base):
"""采集协议单开票申请明细 biz_protocol_invoice_request_item"""
__tablename__ = "biz_protocol_invoice_request_item"
id = Column(String(64), primary_key=True, comment="主键ID")
invoice_request_id = Column(String(64), nullable=False, comment="开票申请主表ID")
payment_id = Column(String(64), nullable=False, comment="缴费主表ID")
payment_detail_id = Column(String(64), nullable=False, comment="缴费明细ID")
installment_no = Column(Integer, nullable=True, comment="期数快照")
line_amount = Column(Numeric(18, 2), nullable=False, comment="该行开票金额")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
cd backend && source venv/bin/activate && python -c "from app.models.models import BizProtocolInvoiceRequest, BizProtocolInvoiceRequestItem; print('ok')"
Expected: ok
Files:
backend/app/crud/collection_protocol.pybackend/tests/test_protocol_invoice_request.py在文件末尾(upsert_fullmoon_todo 之后)新增以下函数。
# backend/tests/test_protocol_invoice_request.py
import unittest
from decimal import Decimal
from unittest.mock import MagicMock, patch
class TestInstallmentSummary(unittest.TestCase):
def test_build_installment_summary_single(self):
from app.crud.collection_protocol import build_installment_summary
self.assertEqual(build_installment_summary([1]), "第1期")
def test_build_installment_summary_multi(self):
from app.crud.collection_protocol import build_installment_summary
self.assertEqual(build_installment_summary([1, 2, 3]), "第1、2、3期")
def test_build_installment_summary_lump_sum(self):
from app.crud.collection_protocol import build_installment_summary
self.assertEqual(build_installment_summary([None]), "趸交")
cd backend && source venv/bin/activate && pytest tests/test_protocol_invoice_request.py::TestInstallmentSummary -v
Expected: FAIL ImportError or cannot import build_installment_summary
def build_installment_summary(installment_nos: List[Optional[int]]) -> str:
nums = sorted({n for n in installment_nos if n is not None})
if not nums:
return "趸交"
if len(nums) == 1:
return f"第{nums[0]}期"
return "第" + "、".join(str(n) for n in nums) + "期"
def _invoice_request_item_to_dict(item: BizProtocolInvoiceRequestItem) -> Dict[str, Any]:
return {
"id": item.id,
"invoiceRequestId": item.invoice_request_id,
"paymentId": item.payment_id,
"paymentDetailId": item.payment_detail_id,
"installmentNo": item.installment_no,
"lineAmount": float(item.line_amount) if item.line_amount is not None else None,
"orderNum": item.order_num,
"remark": item.remark or "",
"createTime": item.create_time.strftime("%Y-%m-%d %H:%M:%S") if item.create_time else "",
}
def _invoice_request_to_dict(
req: BizProtocolInvoiceRequest,
*,
items: Optional[List[Dict[str, Any]]] = None,
installment_summary: Optional[str] = None,
protocol_ctx: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
out = {
"id": req.id,
"collectionProtocolId": req.collection_protocol_id,
"invoiceAmount": float(req.invoice_amount) if req.invoice_amount is not None else None,
"invoiceTitle": req.invoice_title or "",
"taxNo": req.tax_no or "",
"companyAddress": req.company_address or "",
"companyPhone": req.company_phone or "",
"bankName": req.bank_name or "",
"bankAccount": req.bank_account or "",
"applyRemark": req.apply_remark or "",
"invoiceAttachments": req.invoice_attachments or "",
"financeRemark": req.finance_remark or "",
"status": req.status,
"financeHandleTime": req.finance_handle_time.strftime("%Y-%m-%d %H:%M:%S") if req.finance_handle_time else "",
"tenantNo": req.tenant_no,
"orderNum": req.order_num,
"remark": req.remark or "",
"createTime": req.create_time.strftime("%Y-%m-%d %H:%M:%S") if req.create_time else "",
"updateTime": req.update_time.strftime("%Y-%m-%d %H:%M:%S") if req.update_time else "",
}
if items is not None:
out["items"] = items
out["itemCount"] = len(items)
if installment_summary is not None:
out["installmentSummary"] = installment_summary
if protocol_ctx:
out.update(protocol_ctx)
return out
def _active_invoiced_payment_detail_ids(db: Session, tenant_no: str) -> set:
rows = (
db.query(BizProtocolInvoiceRequestItem.payment_detail_id)
.join(
BizProtocolInvoiceRequest,
BizProtocolInvoiceRequest.id == BizProtocolInvoiceRequestItem.invoice_request_id,
)
.filter(
BizProtocolInvoiceRequest.tenant_no == tenant_no,
BizProtocolInvoiceRequest.del_flag == 0,
BizProtocolInvoiceRequest.status.in_(("dkp", "ykp")),
BizProtocolInvoiceRequestItem.del_flag == 0,
)
.all()
)
return {r[0] for r in rows}
需在文件顶部 import 区增加:
from app.models.models import (
...
BizProtocolInvoiceRequest,
BizProtocolInvoiceRequestItem,
)
pytest tests/test_protocol_invoice_request.py::TestInstallmentSummary -v
Expected: 3 passed
class TestCreateInvoiceRequestValidation(unittest.TestCase):
@patch("app.crud.collection_protocol.generate_uuid", return_value="req-1")
def test_rejects_duplicate_payment_detail_in_active_request(self, _mock_uuid):
from app.crud.collection_protocol import create_invoice_request, ValueError as CrudValueError
mock_db = MagicMock()
# 简化:mock 查询返回已占用 detail_id
with patch("app.crud.collection_protocol._active_invoiced_payment_detail_ids", return_value={"pd-1"}):
with self.assertRaises(ValueError):
create_invoice_request(
mock_db,
collection_protocol_id="proto-1",
payment_detail_ids=["pd-1"],
tenant_no="t1",
user_info={"user_id": "u1", "dept_id": ""},
)
list_invoice_eligible_payment_details逻辑要点:
collection_protocol_id 下所有 BizProtocolPayment → BizProtocolPaymentDetailpay_status == "yjf"_active_invoiced_payment_detail_ids 中的 id_rows_to_payment_detail_dicts 或轻量 dict(含 installmentNo, transferAmount, transferTime, transferType, installmentDueDate)def list_invoice_eligible_payment_details(
db: Session,
*,
collection_protocol_id: str,
tenant_no: str,
) -> List[Dict[str, Any]]:
if not tenant_no or not collection_protocol_id:
return []
invoiced_ids = _active_invoiced_payment_detail_ids(db, tenant_no)
payment = (
db.query(BizProtocolPayment)
.filter(
BizProtocolPayment.collection_protocol_id == collection_protocol_id,
BizProtocolPayment.tenant_no == tenant_no,
BizProtocolPayment.del_flag == 0,
)
.first()
)
if not payment:
return []
details = (
db.query(BizProtocolPaymentDetail)
.filter(
BizProtocolPaymentDetail.payment_id == payment.id,
BizProtocolPaymentDetail.tenant_no == tenant_no,
BizProtocolPaymentDetail.del_flag == 0,
BizProtocolPaymentDetail.pay_status == "yjf",
)
.order_by(BizProtocolPaymentDetail.order_num.asc(), BizProtocolPaymentDetail.create_time.desc())
.all()
)
out = []
for d in details:
if d.id in invoiced_ids:
continue
out.append({
"id": d.id,
"paymentId": payment.id,
"installmentNo": d.installment_no,
"dueAmount": float(d.due_amount) if d.due_amount is not None else None,
"transferAmount": float(d.transfer_amount) if d.transfer_amount is not None else None,
"transferTime": d.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if d.transfer_time else "",
"transferType": d.transfer_type or "",
"installmentDueDate": d.installment_due_date or "",
})
return out
create_invoice_request(同事务 1 主表 + N 子表)def create_invoice_request(
db: Session,
*,
collection_protocol_id: str,
payment_detail_ids: List[str],
tenant_no: str,
user_info: Optional[dict] = None,
invoice_title: Optional[str] = None,
tax_no: Optional[str] = None,
company_address: Optional[str] = None,
company_phone: Optional[str] = None,
bank_name: Optional[str] = None,
bank_account: Optional[str] = None,
apply_remark: Optional[str] = None,
) -> Dict[str, Any]:
ids = list(dict.fromkeys([str(i).strip() for i in (payment_detail_ids or []) if str(i).strip()]))
if not ids:
raise ValueError("请至少选择一条缴费明细")
invoiced_ids = _active_invoiced_payment_detail_ids(db, tenant_no)
overlap = set(ids) & invoiced_ids
if overlap:
raise ValueError("所选缴费明细中存在已申请开票的记录")
payment = (
db.query(BizProtocolPayment)
.filter(
BizProtocolPayment.collection_protocol_id == collection_protocol_id,
BizProtocolPayment.tenant_no == tenant_no,
BizProtocolPayment.del_flag == 0,
)
.first()
)
if not payment:
raise ValueError("未找到缴费信息")
details = (
db.query(BizProtocolPaymentDetail)
.filter(
BizProtocolPaymentDetail.payment_id == payment.id,
BizProtocolPaymentDetail.id.in_(ids),
BizProtocolPaymentDetail.tenant_no == tenant_no,
BizProtocolPaymentDetail.del_flag == 0,
BizProtocolPaymentDetail.pay_status == "yjf",
)
.all()
)
if len(details) != len(ids):
raise ValueError("部分缴费明细不可开票")
total = Decimal("0")
detail_map = {d.id: d for d in details}
ordered_details = [detail_map[i] for i in ids]
uid = (user_info or {}).get("user_id") or ""
dept_id = (user_info or {}).get("dept_id") or ""
req = BizProtocolInvoiceRequest(
id=generate_uuid(),
collection_protocol_id=collection_protocol_id,
invoice_amount=Decimal("0"),
invoice_title=(invoice_title or "").strip() or None,
tax_no=(tax_no or "").strip() or None,
company_address=(company_address or "").strip() or None,
company_phone=(company_phone or "").strip() or None,
bank_name=(bank_name or "").strip() or None,
bank_account=(bank_account or "").strip() or None,
apply_remark=(apply_remark or "").strip() or None,
status="dkp",
tenant_no=tenant_no,
create_by=uid,
create_dept_id=dept_id,
update_by=uid,
)
db.add(req)
db.flush()
item_dicts = []
for idx, d in enumerate(ordered_details, start=1):
line_amt = Decimal(str(d.transfer_amount or 0))
total += line_amt
item = BizProtocolInvoiceRequestItem(
id=generate_uuid(),
invoice_request_id=req.id,
payment_id=payment.id,
payment_detail_id=d.id,
installment_no=d.installment_no,
line_amount=line_amt,
tenant_no=tenant_no,
order_num=idx,
create_by=uid,
create_dept_id=dept_id,
update_by=uid,
)
db.add(item)
item_dicts.append(_invoice_request_item_to_dict(item))
req.invoice_amount = total
db.commit()
db.refresh(req)
summary = build_installment_summary([d.installment_no for d in ordered_details])
return _invoice_request_to_dict(req, items=item_dicts, installment_summary=summary)
def list_invoice_requests_by_protocol(
db: Session,
*,
collection_protocol_id: str,
tenant_no: str,
) -> List[Dict[str, Any]]:
rows = (
db.query(BizProtocolInvoiceRequest)
.filter(
BizProtocolInvoiceRequest.collection_protocol_id == collection_protocol_id,
BizProtocolInvoiceRequest.tenant_no == tenant_no,
BizProtocolInvoiceRequest.del_flag == 0,
)
.order_by(BizProtocolInvoiceRequest.create_time.desc())
.all()
)
return [_pack_invoice_request_with_items(db, r) for r in rows]
def _pack_invoice_request_with_items(db: Session, req: BizProtocolInvoiceRequest) -> Dict[str, Any]:
items_orm = (
db.query(BizProtocolInvoiceRequestItem)
.filter(
BizProtocolInvoiceRequestItem.invoice_request_id == req.id,
BizProtocolInvoiceRequestItem.del_flag == 0,
)
.order_by(BizProtocolInvoiceRequestItem.order_num.asc())
.all()
)
item_dicts = [_invoice_request_item_to_dict(i) for i in items_orm]
summary = build_installment_summary([i.installment_no for i in items_orm])
return _invoice_request_to_dict(req, items=item_dicts, installment_summary=summary)
def list_protocol_invoice_requests(
db: Session,
*,
status: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
tenant_no: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
# 参照 list_receipt_confirm_details:join 协议单 + party_customer 做 keyword
# 主查询 BizProtocolInvoiceRequest.status == status
# 排序 create_time DESC(待开票)/ finance_handle_time DESC(已开票)
# 每条 _pack_invoice_request_with_items + 附加 motherName/protocolNo 等
...
def count_pending_protocol_invoice_requests(db: Session, *, tenant_no: str, user_info=None) -> int:
...
def complete_invoice_request(
db: Session,
*,
request_id: str,
tenant_no: str,
invoice_attachments: str,
finance_remark: Optional[str] = None,
user_info: Optional[dict] = None,
) -> Dict[str, Any]:
if not (invoice_attachments or "").strip():
raise ValueError("请上传发票附件")
req = (
db.query(BizProtocolInvoiceRequest)
.filter(
BizProtocolInvoiceRequest.id == request_id,
BizProtocolInvoiceRequest.tenant_no == tenant_no,
BizProtocolInvoiceRequest.del_flag == 0,
)
.first()
)
if not req:
raise ValueError("开票申请不存在")
if req.status != "dkp":
raise ValueError("当前状态不可完成开票")
uid = (user_info or {}).get("user_id") or ""
req.status = "ykp"
req.invoice_attachments = invoice_attachments.strip()
req.finance_remark = (finance_remark or "").strip() or None
req.finance_handle_time = datetime.now()
req.update_by = uid
db.commit()
db.refresh(req)
return _pack_invoice_request_with_items(db, req)
list_protocol_invoice_requests 的 keyword 筛选复用 _apply_payment_detail_list_filters 的 party_customer 子查询模式,或复制 RefundConfirmTab 对应 CRUD 中的 join 写法(BizProtocolPartyCustomer.mother_name 等 ilike)。
pytest tests/test_protocol_invoice_request.py -v
Expected: all passed
Files:
Modify: backend/app/api/collection_protocol.py
[ ] Step 1: 新增 Pydantic Body(文件内,与现有 Body 风格一致)
class InvoiceRequestCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
payment_detail_ids: List[str] = Field(..., alias="paymentDetailIds", min_length=1)
invoice_title: Optional[str] = Field(None, alias="invoiceTitle")
tax_no: Optional[str] = Field(None, alias="taxNo")
company_address: Optional[str] = Field(None, alias="companyAddress")
company_phone: Optional[str] = Field(None, alias="companyPhone")
bank_name: Optional[str] = Field(None, alias="bankName")
bank_account: Optional[str] = Field(None, alias="bankAccount")
apply_remark: Optional[str] = Field(None, alias="applyRemark")
class InvoiceRequestCompleteBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
invoice_attachments: str = Field(..., alias="invoiceAttachments")
finance_remark: Optional[str] = Field(None, alias="financeRemark")
@router.get("/collection-protocols/{protocol_id}/invoice-eligible-payments")
async def get_invoice_eligible_payments(protocol_id: str, db: Session = Depends(get_db), user=Depends(require_admin_or_super_admin)):
tenant_no = _get_tenant_no(db, user)
items = cp_svc.list_invoice_eligible_payment_details(db, collection_protocol_id=protocol_id, tenant_no=tenant_no)
return success_response({"list": items, "total": len(items)})
@router.get("/collection-protocols/{protocol_id}/invoice-requests")
async def get_protocol_invoice_requests(protocol_id: str, db: Session = Depends(get_db), user=Depends(require_admin_or_super_admin)):
tenant_no = _get_tenant_no(db, user)
items = cp_svc.list_invoice_requests_by_protocol(db, collection_protocol_id=protocol_id, tenant_no=tenant_no)
return success_response({"list": items, "total": len(items)})
@router.post("/collection-protocols/{protocol_id}/invoice-requests")
async def post_protocol_invoice_request(protocol_id: str, body: InvoiceRequestCreateBody, db: Session = Depends(get_db), user=Depends(require_admin_or_super_admin)):
tenant_no = _get_tenant_no(db, user)
try:
row = cp_svc.create_invoice_request(
db,
collection_protocol_id=protocol_id,
payment_detail_ids=body.payment_detail_ids,
tenant_no=tenant_no,
user_info=user,
invoice_title=body.invoice_title,
tax_no=body.tax_no,
company_address=body.company_address,
company_phone=body.company_phone,
bank_name=body.bank_name,
bank_account=body.bank_account,
apply_remark=body.apply_remark,
)
except ValueError as e:
return error_response(str(e))
return success_response(row, message="提交成功")
@router.get("/protocol-invoice-requests")
async def list_protocol_invoice_requests_api(
status: str = Query(..., description="dkp|ykp"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = Query(None),
db: Session = Depends(get_db),
user=Depends(require_admin_or_super_admin),
):
tenant_no = _get_tenant_no(db, user)
items, total = cp_svc.list_protocol_invoice_requests(
db, status=status, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
)
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize})
@router.get("/protocol-invoice-requests/pending-count")
async def protocol_invoice_pending_count(db: Session = Depends(get_db), user=Depends(require_admin_or_super_admin)):
tenant_no = _get_tenant_no(db, user)
count = cp_svc.count_pending_protocol_invoice_requests(db, tenant_no=tenant_no, user_info=user)
return success_response({"count": count})
@router.put("/protocol-invoice-requests/{request_id}/complete")
async def complete_protocol_invoice_request(
request_id: str,
body: InvoiceRequestCompleteBody,
db: Session = Depends(get_db),
user=Depends(require_admin_or_super_admin),
):
tenant_no = _get_tenant_no(db, user)
try:
row = cp_svc.complete_invoice_request(
db,
request_id=request_id,
tenant_no=tenant_no,
invoice_attachments=body.invoice_attachments,
finance_remark=body.finance_remark,
user_info=user,
)
except ValueError as e:
return error_response(str(e))
return success_response(row, message="已完成开票")
cd backend && source venv/bin/activate && pytest tests/test_protocol_invoice_request.py tests/test_collection_protocol_conventions.py -q
Expected: all passed
Files:
Modify: web-admin/src/api/collection-protocol.ts
[ ] Step 1: 追加 6 个函数
export function fetchInvoiceEligiblePayments(protocolId: string) {
return request.get<{ list: Record<string, unknown>[]; total: number }>({
url: `/web/admin/collection-protocols/${protocolId}/invoice-eligible-payments`
})
}
export function fetchProtocolInvoiceRequests(protocolId: string) {
return request.get<{ list: Record<string, unknown>[]; total: number }>({
url: `/web/admin/collection-protocols/${protocolId}/invoice-requests`
})
}
export function fetchCreateProtocolInvoiceRequest(
protocolId: string,
data: {
paymentDetailIds: string[]
invoiceTitle?: string
taxNo?: string
companyAddress?: string
companyPhone?: string
bankName?: string
bankAccount?: string
applyRemark?: string
}
) {
return request.post({
url: `/web/admin/collection-protocols/${protocolId}/invoice-requests`,
data,
showSuccessMessage: true
})
}
export function fetchProtocolInvoiceRequestList(params: {
status: 'dkp' | 'ykp'
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-invoice-requests',
params
})
}
export function fetchProtocolInvoicePendingCount() {
return request.get<{ count: number }>({
url: '/web/admin/protocol-invoice-requests/pending-count'
})
}
export function fetchCompleteProtocolInvoiceRequest(
requestId: string,
data: { invoiceAttachments: string; financeRemark?: string }
) {
return request.put({
url: `/web/admin/protocol-invoice-requests/${requestId}/complete`,
data,
showSuccessMessage: true
})
}
Files:
Modify: web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue
[ ] Step 1: 操作列增加「开票申请」按钮
在「办理保险」后增加:
<el-button class="btn-op-item" @click="openInvoiceDialog(row)">
<el-icon><Tickets /></el-icon>开票申请
</el-button>
import { Tickets } from '@element-plus/icons-vue';操作列 min-width 调至约 320。
const invoiceDialogVisible = ref(false)
const invoiceProtocolId = ref('')
const invoiceSummaryRow = ref<any>(null)
const invoiceHistory = ref<any[]>([])
const invoiceEligible = ref<any[]>([])
const invoiceSelectedIds = ref<string[]>([])
const invoiceForm = reactive({
invoiceTitle: '',
taxNo: '',
companyAddress: '',
companyPhone: '',
bankName: '',
bankAccount: '',
applyRemark: ''
})
const invoiceSubmitLoading = ref(false)
const invoiceSelectedTotal = computed(() =>
invoiceEligible.value
.filter((r) => invoiceSelectedIds.value.includes(String(r.id)))
.reduce((sum, r) => sum + Number(r.transferAmount || 0), 0)
)
async function loadInvoiceDialogData() {
const pid = invoiceProtocolId.value
if (!pid) return
const [histRes, eligRes] = await Promise.all([
fetchProtocolInvoiceRequests(pid),
fetchInvoiceEligiblePayments(pid)
])
invoiceHistory.value = (histRes as any)?.list || []
invoiceEligible.value = (eligRes as any)?.list || []
invoiceSelectedIds.value = []
}
function openInvoiceDialog(row: any) {
invoiceProtocolId.value = String(row.protocolId || row.id || '')
invoiceSummaryRow.value = row
invoiceDialogVisible.value = true
loadInvoiceDialogData()
}
<el-dialog
v-model="invoiceDialogVisible"
title="开票申请"
width="880px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<!-- 摘要 -->
<div class="invoice-dialog-summary">
<span>{{ invoiceSummaryRow?.motherName || '—' }}</span>
<span>协议号 {{ invoiceSummaryRow?.protocolNo || '—' }}</span>
</div>
<!-- 申请记录 -->
<h4 class="detail-section-title">申请记录</h4>
<el-table :data="invoiceHistory" stripe class="list-table" empty-text="暂无开票申请">
<el-table-column type="expand">
<template #default="{ row }">
<el-table :data="row.items || []" size="small">
<el-table-column prop="installmentNo" label="期数" min-width="80">
<template #default="{ row: item }">{{ item.installmentNo ?? '趸交' }}</template>
</el-table-column>
<el-table-column prop="lineAmount" label="金额(元)" min-width="100" />
</el-table>
</template>
</el-table-column>
<el-table-column prop="installmentSummary" label="包含期数" min-width="120" />
<el-table-column prop="invoiceAmount" label="合计金额(元)" min-width="120" />
<el-table-column prop="status" label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'ykp' ? 'success' : 'warning'" size="small">
{{ row.status === 'ykp' ? '已开票' : '待开票' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="申请时间" min-width="160" />
<el-table-column prop="financeHandleTime" label="财务处理时间" min-width="160" />
<!-- ykp 行附件:解析 invoiceAttachments 逗号分隔,下载按钮 -->
</el-table>
<!-- 新增申请 -->
<h4 class="detail-section-title">新增申请</h4>
<template v-if="invoiceEligible.length">
<div class="invoice-selected-summary">
已选 {{ invoiceSelectedIds.length }} 期 · 合计 ¥{{ formatMoneyDisplay(invoiceSelectedTotal) }}
</div>
<el-table
:data="invoiceEligible"
@selection-change="(rows) => (invoiceSelectedIds = rows.map((r) => String(r.id)))"
>
<el-table-column type="selection" min-width="48" />
<el-table-column prop="installmentNo" label="期数" min-width="80">
<template #default="{ row }">{{ row.installmentNo ?? '趸交' }}</template>
</el-table-column>
<el-table-column prop="transferAmount" label="实缴金额(元)" min-width="120" />
<el-table-column prop="transferTime" label="转账时间" min-width="160" />
</el-table>
<el-form label-position="top" class="form-grid-2 biz-dialog-form">
<!-- 6 个选填开票字段 + applyRemark form-col-span-2 -->
</el-form>
</template>
<div v-else class="finance-empty">暂无可开票缴费记录</div>
<template #footer>
<el-button class="btn-ghost" @click="invoiceDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="invoiceSubmitLoading"
:disabled="!invoiceSelectedIds.length"
@click="submitInvoiceRequest"
>
<el-icon><Check /></el-icon>提交申请
</el-button>
</template>
</el-dialog>
async function submitInvoiceRequest() {
if (!invoiceSelectedIds.value.length) return
invoiceSubmitLoading.value = true
try {
await fetchCreateProtocolInvoiceRequest(invoiceProtocolId.value, {
paymentDetailIds: invoiceSelectedIds.value,
invoiceTitle: invoiceForm.invoiceTitle || undefined,
taxNo: invoiceForm.taxNo || undefined,
companyAddress: invoiceForm.companyAddress || undefined,
companyPhone: invoiceForm.companyPhone || undefined,
bankName: invoiceForm.bankName || undefined,
bankAccount: invoiceForm.bankAccount || undefined,
applyRemark: invoiceForm.applyRemark || undefined
})
Object.assign(invoiceForm, {
invoiceTitle: '', taxNo: '', companyAddress: '', companyPhone: '',
bankName: '', bankAccount: '', applyRemark: ''
})
await loadInvoiceDialogData()
} finally {
invoiceSubmitLoading.value = false
}
}
引入 formatMoneyDisplay from @/utils/money-format。
Files:
web-admin/src/views/cell-storage/payment-confirm/InvoiceRequestTab.vueModify: web-admin/src/views/cell-storage/payment-confirm/index.vue
[ ] Step 1: 创建 InvoiceRequestTab.vue
从 RefundConfirmTab.vue 复制骨架,替换为:
| 区块 | API | status |
|---|---|---|
| 待开票卡片 | fetchProtocolInvoiceRequestList({ status: 'dkp' }) |
dkp |
| 已开票表格 | fetchProtocolInvoiceRequestList({ status: 'ykp' }) |
ykp |
卡片展示:motherName、installmentSummary、invoiceAmount(合计)、createTime;按钮「处理开票」。
处理弹窗:
items(期数、lineAmount)ProtocolFinanceConfirmFields:upload-folder="protocol-invoice-finance"fetchCompleteProtocolInvoiceRequest(id, { invoiceAttachments, financeRemark })已开票「查看」弹窗:只读 + 附件下载(downloadAttachmentFile)。
defineEmits(['action-done']);完成/提交后 emit('action-done')。
payment-confirm/index.vue<button
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': mainTab === 'invoice' }"
@click="mainTab = 'invoice'"
>
开票申请
<span v-if="invoicePendingBadge > 0" class="panel-pill-tabs__badge">
{{ formatTabBadgeCount(invoicePendingBadge) }}
</span>
</button>
import InvoiceRequestTab from './InvoiceRequestTab.vue'const invoicePendingBadge = ref(0)loadTabPendingCounts 内增加:const invRes = await fetchProtocolInvoicePendingCount()
invoicePendingBadge.value = Number((invRes as any)?.count ?? 0)
模板底部:<InvoiceRequestTab v-if="mainTab === 'invoice'" @action-done="loadTabPendingCounts" />
onMounted / route.query.tab:支持 tab=invoice 直达(与现有 payment/refund 一致)。
cd backend && source venv/bin/activate && python scripts/check_field_consistency.py
Expected: 无开票相关字段缺失告警(若脚本未覆盖新实体可跳过并记录)。
cd web-admin && pnpm lint
Expected: 无新增 error
./scripts/update-graph.sh all
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 满月待办 → 开票申请 → 多选 2 期提交 | 1 条 dkp,合计金额正确 |
| 2 | 弹窗申请记录展开 | 2 行子表明细 |
| 3 | 再次打开,已选期不可勾选 | 仅剩余期可选 |
| 4 | 财务 Tab 待开票 → 处理 → 上传附件 | 状态变 ykp |
| 5 | 业务弹窗查看 | 可见附件 |
| 6 | 尝试重复含已开票期 | 后端拒绝 |
将 docs/superpowers/specs/2026-06-17-protocol-invoice-request-design.md 首行 状态 改为 已实现。
| 设计章节 | 对应 Task |
|---|---|
| §4.1 可开票条件 | Task 3 list_invoice_eligible_payment_details |
| §4.2 合计金额 | Task 3 create_invoice_request |
| §4.3 多期合并一张票 | Task 3 主表+子表 |
| §4.4 选填开票信息 | Task 6 表单 |
| §4.5 财务附件 | Task 7 处理弹窗 |
| §6 API 六端点 | Task 4 |
| §7.2 统一弹窗 | Task 6 |
| §7.3 财务 Tab | Task 7 |
| §9 测试要点 | Task 8 Step 4 |
无 TBD / 占位步骤。
Plan complete and saved to docs/superpowers/plans/2026-06-17-protocol-invoice-request.md.
两种执行选项:
请选择执行方式;若无偏好,默认 Inline Execution 从 Task 1 开始。