Files
zkxw/docs/superpowers/plans/2026-06-17-protocol-invoice-request.md
T
2026-06-17 18:16:23 +08:00

1128 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采集协议单开票申请 Implementation Plan
> **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:**
- 缴退费财务 CRUD/API:`backend/app/crud/collection_protocol.py`(`list_receipt_confirm_details` L1509+)
- 缴退费 API:`backend/app/api/collection_protocol.py`(`receipt-confirm` L565+)
- 满月待办前端:`web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue`
- 财务退费 Tab:`web-admin/src/views/cell-storage/payment-confirm/RefundConfirmTab.vue`
- 财务附件:`web-admin/src/components/business/protocol-finance-confirm-fields/`
---
## File Structure Map
### New Files
| 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 |
### Modified Files
| 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 + 角标 |
---
## Task 1: 数据库 DDL
**Files:**
- Create: `docs/sql/protocol_invoice_request.sql`
- [ ] **Step 1: 写入 DDL(与设计文档 §5 一致)**
```sql
-- 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='采集协议单开票申请明细';
```
- [ ] **Step 2: 本地库执行迁移(开发者环境)**
```bash
mysql -u root -p zkxw < docs/sql/protocol_invoice_request.sql
```
Expected: 两张表创建成功,无 ERROR。
---
## Task 2: SQLAlchemy Models
**Files:**
- Modify: `backend/app/models/models.py`(在 `BizProtocolPaymentDetail` 之后插入)
- [ ] **Step 1: 新增两个 Model 类**
```python
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="删除标记")
```
- [ ] **Step 2: 验证 import**
```bash
cd backend && source venv/bin/activate && python -c "from app.models.models import BizProtocolInvoiceRequest, BizProtocolInvoiceRequestItem; print('ok')"
```
Expected: `ok`
---
## Task 3: CRUD 层(含单元测试)
**Files:**
- Modify: `backend/app/crud/collection_protocol.py`
- Create: `backend/tests/test_protocol_invoice_request.py`
在文件末尾(`upsert_fullmoon_todo` 之后)新增以下函数。
### 3.1 辅助函数
- [ ] **Step 1: 写失败测试 — installmentSummary**
```python
# 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]), "趸交")
```
- [ ] **Step 2: 运行测试确认 FAIL**
```bash
cd backend && source venv/bin/activate && pytest tests/test_protocol_invoice_request.py::TestInstallmentSummary -v
```
Expected: FAIL `ImportError` or `cannot import build_installment_summary`
- [ ] **Step 3: 实现辅助函数**
```python
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 区增加:
```python
from app.models.models import (
...
BizProtocolInvoiceRequest,
BizProtocolInvoiceRequestItem,
)
```
- [ ] **Step 4: 运行 TestInstallmentSummary 通过**
```bash
pytest tests/test_protocol_invoice_request.py::TestInstallmentSummary -v
```
Expected: 3 passed
### 3.2 可开票明细 & 创建申请
- [ ] **Step 5: 写失败测试 — 重复 payment_detail_id 拒绝**
```python
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": ""},
)
```
- [ ] **Step 6: 实现 `list_invoice_eligible_payment_details`**
逻辑要点:
1. 查协议单 `collection_protocol_id` 下所有 `BizProtocolPayment` → `BizProtocolPaymentDetail`
2. 过滤 `pay_status == "yjf"`
3. 排除 `_active_invoiced_payment_detail_ids` 中的 id
4. 复用 `_rows_to_payment_detail_dicts` 或轻量 dict(含 `installmentNo`, `transferAmount`, `transferTime`, `transferType`, `installmentDueDate`)
```python
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
```
- [ ] **Step 7: 实现 `create_invoice_request`(同事务 1 主表 + N 子表)**
```python
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)
```
- [ ] **Step 8: 实现列表/完成/计数函数**
```python
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`)。
- [ ] **Step 9: 运行全部开票测试**
```bash
pytest tests/test_protocol_invoice_request.py -v
```
Expected: all passed
---
## Task 4: API 路由
**Files:**
- Modify: `backend/app/api/collection_protocol.py`
- [ ] **Step 1: 新增 Pydantic Body(文件内,与现有 Body 风格一致)**
```python
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")
```
- [ ] **Step 2: 注册 6 个端点**
```python
@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="已完成开票")
```
- [ ] **Step 3: 冒烟(需本地 DB 已迁移且后端运行)**
```bash
cd backend && source venv/bin/activate && pytest tests/test_protocol_invoice_request.py tests/test_collection_protocol_conventions.py -q
```
Expected: all passed
---
## Task 5: 前端 API
**Files:**
- Modify: `web-admin/src/api/collection-protocol.ts`
- [ ] **Step 1: 追加 6 个函数**
```typescript
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
})
}
```
---
## Task 6: 满月待办 — 开票申请统一弹窗
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue`
- [ ] **Step 1: 操作列增加「开票申请」按钮**
在「办理保险」后增加:
```vue
<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`。
- [ ] **Step 2: 新增弹窗 state 与加载函数**
```typescript
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()
}
```
- [ ] **Step 3: 弹窗模板(三块结构)**
```vue
<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>
```
- [ ] **Step 4: 提交函数**
```typescript
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`。
---
## Task 7: 财务开票 Tab
**Files:**
- Create: `web-admin/src/views/cell-storage/payment-confirm/InvoiceRequestTab.vue`
- Modify: `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')`。
- [ ] **Step 2: 修改 `payment-confirm/index.vue`**
1. Tab 按钮在退费后增加:
```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>
```
2. `import InvoiceRequestTab from './InvoiceRequestTab.vue'`
3. `const invoicePendingBadge = ref(0)`
4. `loadTabPendingCounts` 内增加:
```typescript
const invRes = await fetchProtocolInvoicePendingCount()
invoicePendingBadge.value = Number((invRes as any)?.count ?? 0)
```
5. 模板底部:`<InvoiceRequestTab v-if="mainTab === 'invoice'" @action-done="loadTabPendingCounts" />`
6. `onMounted` / `route.query.tab`:支持 `tab=invoice` 直达(与现有 `payment`/`refund` 一致)。
---
## Task 8: 联调与收尾
- [ ] **Step 1: 字段一致性检查**
```bash
cd backend && source venv/bin/activate && python scripts/check_field_consistency.py
```
Expected: 无开票相关字段缺失告警(若脚本未覆盖新实体可跳过并记录)。
- [ ] **Step 2: 前端 lint**
```bash
cd web-admin && pnpm lint
```
Expected: 无新增 error
- [ ] **Step 3: 更新知识图谱**
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 4: 手动验收清单(设计文档 §9)**
| 步骤 | 操作 | 预期 |
|------|------|------|
| 1 | 满月待办 → 开票申请 → 多选 2 期提交 | 1 条 dkp,合计金额正确 |
| 2 | 弹窗申请记录展开 | 2 行子表明细 |
| 3 | 再次打开,已选期不可勾选 | 仅剩余期可选 |
| 4 | 财务 Tab 待开票 → 处理 → 上传附件 | 状态变 ykp |
| 5 | 业务弹窗查看 | 可见附件 |
| 6 | 尝试重复含已开票期 | 后端拒绝 |
- [ ] **Step 5: 更新设计文档状态**
将 `docs/superpowers/specs/2026-06-17-protocol-invoice-request-design.md` 首行 `状态` 改为 `已实现`。
---
## Spec Coverage Self-Review
| 设计章节 | 对应 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`.
**两种执行选项:**
1. **Subagent-Driven(推荐)** — 按 Task 1→8 逐任务派发子代理,任务间人工/助手复核
2. **Inline Execution** — 本会话内按 Task 顺序直接实现,每完成 2~3 个 Task 汇报一次
请选择执行方式;若无偏好,默认 **Inline Execution** 从 Task 1 开始。