docs: add implementation plan for standalone payment-info menu
Break the approved design into TDD-friendly tasks for APIs, dual tabs, and per-sample UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
33004fe9b9
commit
4849b40594
1 file changed
+842
@@ -0,0 +1,842 @@
|
||||
# 缴费信息独立菜单 + 按样本维度 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:** 将缴费信息从采集协议单拆成独立菜单,并新增「按样本」列表/详情(可缴费确认),流程看板能力原样迁入。
|
||||
|
||||
**Architecture:** 后端新增只读聚合 `GET /sample-payments` 与详情;写操作继续走 `payment-transfers`。前端把 `payment-info` 从重定向壳改为双 Pill Tab 页(流程看板 + 按样本),协议单页去掉缴费 Tab 并做深链兼容。进度状态用纯函数计算并单测,列表过滤在查询层完成。
|
||||
|
||||
**Tech Stack:** MySQL 5.7 · FastAPI · SQLAlchemy · Vue 3 · Element Plus · pnpm · pytest/unittest
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-10-05-payment-info-standalone-menu-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/app/services/sample_payment_progress.py` | **新建** 进度状态/金额汇总纯函数 |
|
||||
| `backend/tests/test_sample_payment_progress.py` | **新建** 进度纯函数单测 |
|
||||
| `backend/app/crud/protocol_sample_payment.py` | 增加 `list_sample_payments_page` / `get_sample_payment_detail` |
|
||||
| `backend/app/api/protocol_sample_payment.py` | 注册两个只读路由 |
|
||||
| `backend/tests/test_sample_payment_list.py` | **新建** 列表聚合与过滤单测(尽量无 DB;或轻量 mock) |
|
||||
| `web-admin/src/api/protocol-sample-payment.ts` | `fetchSamplePaymentList` / `fetchSamplePaymentDetail` |
|
||||
| `web-admin/src/utils/status-tag-registry.ts` | `samplePaymentProgress` 注册表(勿与 `payStatus.dfj=已缴费` 混用) |
|
||||
| `web-admin/src/views/cell-storage/payment-info/index.vue` | 独立页双 Tab 壳 |
|
||||
| `web-admin/src/views/cell-storage/payment-info/PaymentBySampleTab.vue` | **新建** 按样本列表 + 详情 |
|
||||
| `web-admin/src/components/business/payment-receipt-confirm-dialog/index.vue` | **新建** 从看板抽出的缴费确认弹窗 |
|
||||
| `web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue` | 改用共享确认弹窗;仍作流程看板组件 |
|
||||
| `web-admin/src/views/cell-storage/protocol-audit/index.vue` | 去掉缴费 Tab;深链重定向 |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | 取消 `isHide`,路由顺序:预签约 → 缴费信息 → 样本信息 |
|
||||
| `backend/app/api/menu.py` | 细胞存储 children 插入缴费信息菜单项 |
|
||||
| `docs/sql/migrations/2026-10-05-unhide-payment-info-menu.sql` | **新建** DB 菜单显示/排序(若库中已有该 menu_code) |
|
||||
|
||||
---
|
||||
|
||||
## 依赖顺序
|
||||
|
||||
```text
|
||||
Task 1 进度纯函数 + 单测
|
||||
→ Task 2 CRUD 列表/详情 + API
|
||||
→ Task 3 前端 API + Tag 注册
|
||||
→ Task 4 独立页壳 + 路由/菜单 + 协议单去 Tab
|
||||
→ Task 5 抽出共享缴费确认弹窗(看板接入)
|
||||
→ Task 6 按样本 Tab(列表 + 详情 + 确认)
|
||||
→ Task 7 菜单 SQL + 图谱 + 冒烟
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 样本缴费进度纯函数
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/sample_payment_progress.py`
|
||||
- Create: `backend/tests/test_sample_payment_progress.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
"""样本缴费进度:状态码与已缴/待缴汇总"""
|
||||
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from app.services.sample_payment_progress import (
|
||||
compute_sample_payment_progress,
|
||||
)
|
||||
|
||||
|
||||
class TestSamplePaymentProgress(unittest.TestCase):
|
||||
def test_pending_when_no_transfers(self):
|
||||
r = compute_sample_payment_progress(
|
||||
schedules=[
|
||||
{"scheduleStatus": "open", "dueAmount": Decimal("1000")},
|
||||
],
|
||||
transfers=[],
|
||||
)
|
||||
self.assertEqual(r["progressStatus"], "dfj")
|
||||
self.assertEqual(r["paidAmount"], Decimal("0"))
|
||||
self.assertEqual(r["dueRemainAmount"], Decimal("1000"))
|
||||
self.assertEqual(r["clearedScheduleCount"], 0)
|
||||
self.assertEqual(r["activeScheduleCount"], 1)
|
||||
self.assertTrue(r["canConfirm"])
|
||||
|
||||
def test_confirming_wins_over_partial(self):
|
||||
r = compute_sample_payment_progress(
|
||||
schedules=[
|
||||
{"id": "s1", "scheduleStatus": "open", "dueAmount": Decimal("1000")},
|
||||
],
|
||||
transfers=[
|
||||
{"scheduleId": "s1", "payStatus": "yjf", "transferAmount": Decimal("200")},
|
||||
{"scheduleId": "s1", "payStatus": "jfqrz", "transferAmount": Decimal("100")},
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["progressStatus"], "jfqrz")
|
||||
self.assertEqual(r["paidAmount"], Decimal("200"))
|
||||
self.assertTrue(r["canConfirm"] is False or r["canConfirm"] is True)
|
||||
# 有确认中时 canConfirm=False(避免重复提交)
|
||||
self.assertFalse(r["canConfirm"])
|
||||
|
||||
def test_partial_paid(self):
|
||||
r = compute_sample_payment_progress(
|
||||
schedules=[
|
||||
{"id": "s1", "scheduleStatus": "cleared", "dueAmount": Decimal("500")},
|
||||
{"id": "s2", "scheduleStatus": "open", "dueAmount": Decimal("500")},
|
||||
],
|
||||
transfers=[
|
||||
{"scheduleId": "s1", "payStatus": "yjf", "transferAmount": Decimal("500")},
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["progressStatus"], "bfyj")
|
||||
self.assertEqual(r["clearedScheduleCount"], 1)
|
||||
self.assertEqual(r["activeScheduleCount"], 2)
|
||||
self.assertTrue(r["canConfirm"])
|
||||
|
||||
def test_all_cleared(self):
|
||||
r = compute_sample_payment_progress(
|
||||
schedules=[
|
||||
{"id": "s1", "scheduleStatus": "cleared", "dueAmount": Decimal("500")},
|
||||
{"id": "s2", "scheduleStatus": "void", "dueAmount": Decimal("500")},
|
||||
],
|
||||
transfers=[
|
||||
{"scheduleId": "s1", "payStatus": "yjf", "transferAmount": Decimal("500")},
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["progressStatus"], "yjq")
|
||||
self.assertEqual(r["activeScheduleCount"], 1)
|
||||
self.assertEqual(r["clearedScheduleCount"], 1)
|
||||
self.assertFalse(r["canConfirm"])
|
||||
self.assertEqual(r["dueRemainAmount"], Decimal("0"))
|
||||
|
||||
def test_void_schedules_ignored(self):
|
||||
r = compute_sample_payment_progress(
|
||||
schedules=[
|
||||
{"scheduleStatus": "void", "dueAmount": Decimal("9999")},
|
||||
{"scheduleStatus": "open", "dueAmount": Decimal("100")},
|
||||
],
|
||||
transfers=[],
|
||||
)
|
||||
self.assertEqual(r["activeScheduleCount"], 1)
|
||||
self.assertEqual(r["dueRemainAmount"], Decimal("100"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_sample_payment_progress.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(模块不存在)
|
||||
|
||||
- [ ] **Step 3: Implement pure function**
|
||||
|
||||
创建 `backend/app/services/sample_payment_progress.py`:
|
||||
|
||||
```python
|
||||
"""样本缴费方案进度汇总(列表「按样本」维度)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def compute_sample_payment_progress(
|
||||
*,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
progressStatus 优先级:jfqrz > yjq > bfyj > dfj
|
||||
已缴仅计 pay_status=yjf;有效期排除 schedule_status=void
|
||||
canConfirm:无 jfqrz,且存在有效未清期(欠缴>0)或可编辑的 dfj 草稿
|
||||
"""
|
||||
sch_list: List[Mapping[str, Any]] = list(schedules or [])
|
||||
tr_list: List[Mapping[str, Any]] = list(transfers or [])
|
||||
|
||||
active = [
|
||||
s
|
||||
for s in sch_list
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "void"
|
||||
]
|
||||
active_schedule_count = len(active)
|
||||
cleared_schedule_count = sum(
|
||||
1
|
||||
for s in active
|
||||
if _status(s, "scheduleStatus", "schedule_status") == "cleared"
|
||||
)
|
||||
|
||||
paid = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in tr_list
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
due_total = sum((_dec(s.get("dueAmount", s.get("due_amount"))) for s in active), Decimal("0"))
|
||||
due_remain = due_total - paid
|
||||
if due_remain < 0:
|
||||
due_remain = Decimal("0")
|
||||
|
||||
has_jfqrz = any(_status(t, "payStatus", "pay_status") == "jfqrz" for t in tr_list)
|
||||
|
||||
if has_jfqrz:
|
||||
progress = "jfqrz"
|
||||
elif active_schedule_count > 0 and cleared_schedule_count == active_schedule_count:
|
||||
progress = "yjq"
|
||||
elif paid > 0:
|
||||
progress = "bfyj"
|
||||
else:
|
||||
progress = "dfj"
|
||||
|
||||
can_confirm = (not has_jfqrz) and any(
|
||||
_status(s, "scheduleStatus", "schedule_status") != "cleared" for s in active
|
||||
)
|
||||
|
||||
return {
|
||||
"progressStatus": progress,
|
||||
"paidAmount": paid,
|
||||
"dueRemainAmount": due_remain,
|
||||
"clearedScheduleCount": cleared_schedule_count,
|
||||
"activeScheduleCount": active_schedule_count,
|
||||
"canConfirm": can_confirm,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_sample_payment_progress.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/sample_payment_progress.py backend/tests/test_sample_payment_progress.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): add sample payment progress aggregation helper
|
||||
|
||||
Pure function for per-sample list progressStatus, paid/due totals, and canConfirm.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 列表/详情 CRUD + API
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py`
|
||||
- Modify: `backend/app/api/protocol_sample_payment.py`
|
||||
- Create: `backend/tests/test_sample_payment_list_helpers.py`(若列表拼装可抽 helper 则单测;否则以手动冒烟 + 进度单测为准)
|
||||
|
||||
- [ ] **Step 1: 在 CRUD 增加列表与详情**
|
||||
|
||||
在 `protocol_sample_payment.py` 末尾附近新增(签名与字段以设计文档 §4.1 为准):
|
||||
|
||||
```python
|
||||
def list_sample_payments_page(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
pay_mode: Optional[str] = None,
|
||||
progress_status: Optional[str] = None,
|
||||
due_date_start: Optional[str] = None,
|
||||
due_date_end: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
主表 biz_protocol_sample_payment JOIN 协议 + 当事人。
|
||||
数据权限:apply_data_scope 挂在 BizCollectionProtocol 上。
|
||||
progress_status 过滤:先按基础条件取出候选 id(可分批),对每个方案加载 schedules/transfers,
|
||||
用 compute_sample_payment_progress 判定后过滤,再切片分页。
|
||||
注意:候选集过大时改为 SQL EXISTS 优化;P0 可接受「基础过滤后内存算进度再分页」,
|
||||
但 MUST 先过滤进度再 slice,禁止「先 LIMIT 再筛进度」。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def get_sample_payment_detail(
|
||||
db: Session,
|
||||
*,
|
||||
sample_payment_id: str,
|
||||
tenant_no: str,
|
||||
user_info=None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
复用 get bundle 单条 / _sample_payment_to_dict,并补:
|
||||
protocolNo, protocolStatus, voidType, motherName, motherPhone, 以及 progress 汇总字段。
|
||||
协议不在 data_scope → 返回 None(API 层 403)。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
1. 基础 query:`BizProtocolSamplePayment` + join `BizCollectionProtocol` + outerjoin `BizProtocolPartyCustomer`,`del_flag=0`,`tenant_no`。
|
||||
2. `apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)`。
|
||||
3. `keyword`:`protocol_no` / `sample_code` / `mother_name` / `mother_phone` LIKE。
|
||||
4. `pay_mode` 精确匹配。
|
||||
5. 应缴日:EXISTS 未作废 schedule 的 `due_date` 落在区间(YYYYMMDD)。
|
||||
6. 对每个候选 `sample_payment` 加载其 schedules + transfers(可批量 IN 查询),调用 `compute_sample_payment_progress`。
|
||||
7. 若 `progress_status` 有值则过滤;再 `total=len`,`page` 切片。
|
||||
8. 列表行字段:设计文档 §4.1 摘要字段,**不含**完整 schedules 树。
|
||||
|
||||
- [ ] **Step 2: 注册 API**
|
||||
|
||||
在 `protocol_sample_payment.py` 增加(注意路由顺序:`/sample-payments/{id}` 放在不会与其它静态路径冲突的位置;`/payment-transfers` 已存在,新路径独立):
|
||||
|
||||
```python
|
||||
@router.get("/sample-payments")
|
||||
async def list_sample_payments(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
payMode: Optional[str] = Query(None, alias="payMode"),
|
||||
progressStatus: Optional[str] = Query(None, alias="progressStatus"),
|
||||
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
|
||||
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_sample_payments_page(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
pay_mode=payMode,
|
||||
progress_status=progressStatus,
|
||||
due_date_start=installmentDueDateStart,
|
||||
due_date_end=installmentDueDateEnd,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": page_size}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sample-payments/{sample_payment_id}")
|
||||
async def get_sample_payment(
|
||||
sample_payment_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = psp_svc.get_sample_payment_detail(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
if not data:
|
||||
return error_response(403, "无权查看或记录不存在")
|
||||
return success_response(data)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 补一条进度过滤顺序的单元测试(helper)**
|
||||
|
||||
若实现时抽出 `_filter_and_page_progress(rows, progress_status, page, page_size)`,为其写测试:输入 5 条不同 progress,筛 `bfyj`,pageSize=2,断言 total/页内容正确且未先截断。
|
||||
|
||||
否则跳过独立测试文件,在 Task 7 冒烟验证。
|
||||
|
||||
- [ ] **Step 4: Run progress tests still pass**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_sample_payment_progress.py tests/test_protocol_sample_payment.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/protocol_sample_payment.py backend/app/api/protocol_sample_payment.py
|
||||
git add backend/tests/test_sample_payment_list_helpers.py # if created
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): add sample-payments list and detail read APIs
|
||||
|
||||
Support per-sample payment overview for the standalone payment-info menu.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 API 与 Tag 注册
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/protocol-sample-payment.ts`
|
||||
- Modify: `web-admin/src/utils/status-tag-registry.ts`
|
||||
|
||||
- [ ] **Step 1: 增加 API 方法**
|
||||
|
||||
在 `protocol-sample-payment.ts` 追加:
|
||||
|
||||
```typescript
|
||||
export function fetchSamplePaymentList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
payMode?: string
|
||||
progressStatus?: string
|
||||
installmentDueDateStart?: string
|
||||
installmentDueDateEnd?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/sample-payments',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSamplePaymentDetail(samplePaymentId: string) {
|
||||
return request.get<Record<string, unknown>>({
|
||||
url: `/web/admin/sample-payments/${samplePaymentId}`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册进度 Tag(独立于 payStatus)**
|
||||
|
||||
在 `status-tag-registry.ts` 的 `payMode` 附近新增:
|
||||
|
||||
```typescript
|
||||
/** 按样本列表整体进度(勿与转账笔 payStatus 混用:payStatus.dfj=已缴费) */
|
||||
export const samplePaymentProgress: StatusRegistry = {
|
||||
dfj: { label: '待缴费', tagType: 'info' },
|
||||
jfqrz: { label: '缴费确认中', tagType: 'warning' },
|
||||
bfyj: { label: '部分已缴', tagType: 'primary' },
|
||||
yjq: { label: '已缴清', tagType: 'success' }
|
||||
}
|
||||
export const samplePaymentProgressLabel = createLabelGetter(samplePaymentProgress)
|
||||
export const samplePaymentProgressTagType = createTagTypeGetter(samplePaymentProgress)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/protocol-sample-payment.ts web-admin/src/utils/status-tag-registry.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add sample-payments API clients and progress tags
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 独立页壳、路由/菜单、协议单去 Tab
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/payment-info/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol-audit/index.vue`
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
- Modify: `backend/app/api/menu.py`
|
||||
|
||||
- [ ] **Step 1: 重写 `payment-info/index.vue` 为双 Tab 壳**
|
||||
|
||||
对齐 `sample-info/index.vue` 结构:
|
||||
|
||||
```vue
|
||||
<!-- 缴费信息(流程看板 / 按样本) -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<nav class="pill-tab-nav" role="tablist" aria-label="视图类型">
|
||||
<div class="pill-tab-nav__rail">
|
||||
<div class="pill-tab-nav__progress" :style="pillProgressStyle" />
|
||||
<button
|
||||
v-for="(item, index) in entityTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="pill-tab-nav__step"
|
||||
:class="{
|
||||
'is-active': activeTab === item.key,
|
||||
'is-done': index < activeTabIndex
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === item.key"
|
||||
@click="activeTab = item.key"
|
||||
>
|
||||
<span class="pill-tab-nav__index">{{ index + 1 }}</span>
|
||||
<span class="pill-tab-nav__text">
|
||||
<span class="pill-tab-nav__title">{{ item.title }}</span>
|
||||
<span class="pill-tab-nav__desc">{{ item.desc }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<PaymentInfoTab v-if="activeTab === 'flow'" />
|
||||
<PaymentBySampleTab v-else-if="activeTab === 'by-sample'" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PaymentInfoTab from '../protocol-audit/PaymentInfoTab.vue'
|
||||
import PaymentBySampleTab from './PaymentBySampleTab.vue'
|
||||
|
||||
defineOptions({ name: 'CellStoragePaymentInfo' })
|
||||
|
||||
const VALID_TABS = ['flow', 'by-sample'] as const
|
||||
type EntityTabKey = (typeof VALID_TABS)[number]
|
||||
|
||||
const entityTabs: Array<{ key: EntityTabKey; title: string; desc: string }> = [
|
||||
{ key: 'flow', title: '流程看板', desc: '按缴费流程状态处理' },
|
||||
{ key: 'by-sample', title: '按样本', desc: '一协议一样本的缴费总览' }
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref<EntityTabKey>('flow')
|
||||
const activeTabIndex = computed(() =>
|
||||
Math.max(0, entityTabs.findIndex((item) => item.key === activeTab.value))
|
||||
)
|
||||
const pillProgressStyle = computed(() => ({
|
||||
width: `calc(${100 / entityTabs.length}% - 3px)`,
|
||||
transform: `translateX(${activeTabIndex.value * 100}%)`
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
const tab = String(route.query.tab || '')
|
||||
if (tab === 'payment-info') {
|
||||
activeTab.value = 'flow'
|
||||
return
|
||||
}
|
||||
if (VALID_TABS.includes(tab as EntityTabKey)) {
|
||||
activeTab.value = tab as EntityTabKey
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Task 4 先建一个最小 `PaymentBySampleTab.vue` 占位(`el-empty`「开发中」),Task 6 再填满,避免壳页 import 失败。
|
||||
|
||||
- [ ] **Step 2: 协议单去掉缴费 Tab + 深链重定向**
|
||||
|
||||
修改 `protocol-audit/index.vue`:
|
||||
|
||||
- `VALID_TABS` / `entityTabs` 删除 `payment-info`
|
||||
- `onMounted` 增加:若 `tab === 'payment-info'`,`router.replace({ name: 'CellStoragePaymentInfo', query: { ...route.query, tab: 'flow' } })`
|
||||
|
||||
- [ ] **Step 3: 路由 `sqxp.ts`**
|
||||
|
||||
将 `CellStoragePaymentInfo` 块移到 `CellStoragePresignOrder` 与 `CellStorageSampleInfo` 之间,并改:
|
||||
|
||||
```ts
|
||||
meta: {
|
||||
title: '缴费信息',
|
||||
keepAlive: true,
|
||||
// 删除 isHide: true
|
||||
menuCode: 'cellStorage:paymentInfo:list'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `menu.py` 硬编码树**
|
||||
|
||||
在细胞存储 children 的预签约与样本信息之间插入:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": "904b5173519411f1944300163e3bdd96",
|
||||
"name": "CellStoragePaymentInfo",
|
||||
"path": "payment-info",
|
||||
"component": "/cell-storage/payment-info/index",
|
||||
"meta": {
|
||||
"title": "缴费信息",
|
||||
"keepAlive": True,
|
||||
"menuCode": "cellStorage:paymentInfo:list",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 手动冒烟壳页**
|
||||
|
||||
Run: `pnpm dev`(web-admin),打开 `/#/cell-storage/payment-info`、`/#/cell-storage/protocol-audit?tab=payment-info`。
|
||||
|
||||
Expected:独立页双 Tab;协议单仅 3 Tab;旧深链跳到独立页。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/cell-storage/payment-info/index.vue \
|
||||
web-admin/src/views/cell-storage/payment-info/PaymentBySampleTab.vue \
|
||||
web-admin/src/views/cell-storage/protocol-audit/index.vue \
|
||||
web-admin/src/router/modules/sqxp.ts \
|
||||
backend/app/api/menu.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: split payment-info into standalone menu with dual tabs
|
||||
|
||||
Remove payment tab from protocol-audit and keep deep-link redirect.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 抽出共享缴费确认弹窗
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/components/business/payment-receipt-confirm-dialog/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue`
|
||||
|
||||
- [ ] **Step 1: 抽取弹窗**
|
||||
|
||||
把 `PaymentInfoTab.vue` 中「缴费确认」`el-dialog`(含表单、附件上传、仅保存、提交缴费确认)抽到:
|
||||
|
||||
`web-admin/src/components/business/payment-receipt-confirm-dialog/index.vue`
|
||||
|
||||
Props / Emits 建议:
|
||||
|
||||
```ts
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
previewRow: Record<string, unknown> | null
|
||||
/** transfer 新模型 vs 旧 detail */
|
||||
isTransfer: boolean
|
||||
targetId: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
saved: []
|
||||
submitted: []
|
||||
}>()
|
||||
```
|
||||
|
||||
内部继续调用:
|
||||
|
||||
- `fetchUpdatePaymentTransfer` / `fetchUpdatePaymentDetailDraft`
|
||||
- `fetchSubmitPaymentTransferFinance` / 旧 detail 提交接口
|
||||
- `fetchUploadAttachment` + `useAttachmentDropZone`
|
||||
|
||||
字段与校验与看板现网一致,禁止删减。
|
||||
|
||||
- [ ] **Step 2: 看板改用共享组件**
|
||||
|
||||
`PaymentInfoTab` 删除内联 dialog 大段模板与相关本地 form 状态,改为:
|
||||
|
||||
```vue
|
||||
<PaymentReceiptConfirmDialog
|
||||
v-model="receiptDialogVisible"
|
||||
:preview-row="receiptPreviewRow"
|
||||
:is-transfer="receiptIsTransfer"
|
||||
:target-id="receiptTargetId"
|
||||
@saved="loadAllColumns()"
|
||||
@submitted="loadAllColumns()"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 看板回归**
|
||||
|
||||
在流程看板「已缴费」列点「缴费确认」,确认弹窗可保存/提交。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/components/business/payment-receipt-confirm-dialog/index.vue \
|
||||
web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(web-admin): extract shared payment receipt confirm dialog
|
||||
|
||||
Reuse the same confirm form for flow kanban and per-sample views.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 按样本 Tab(列表 + 详情 + 确认)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/payment-info/PaymentBySampleTab.vue`
|
||||
|
||||
- [ ] **Step 1: 实现筛选 + 表格列表**
|
||||
|
||||
结构对齐 `SampleCollectionInfoTab.vue`(P02):
|
||||
|
||||
- `filter-block`:关键词、缴费方式(`dj`/`fq`)、进度状态(`samplePaymentProgress` 四码)、应缴日期区间
|
||||
- `list-body.list-wrap` + `el-table.list-table` + `stripe`(不加 border)
|
||||
- 列:协议号、客户(姓名+电话次要)、样本编码、缴费方式 Tag、合同额、已缴、待缴、进度文案(`已缴清 X/Y 期`)、进度状态 Tag、操作列 `fixed="right"` `min-width` ≥ 260
|
||||
- 操作:查看、缴费确认(`v-if="row.canConfirm"`)、协议单
|
||||
- 分页:`list-pager-bar`
|
||||
- 数据:`fetchSamplePaymentList`
|
||||
- 金额:`formatMoneyDisplay`
|
||||
- Tag:`payModeTagType` / `samplePaymentProgressTagType`
|
||||
|
||||
- [ ] **Step 2: 详情弹窗**
|
||||
|
||||
`el-dialog.dialog-form-layout`(宽约 900px,`append-to-body`,`modal-class="protocol-dialog-mask"`):
|
||||
|
||||
1. 打开时 `fetchSamplePaymentDetail(id)`
|
||||
2. 上半摘要:协议号、客户、样本、模式、合同额、已缴/待缴、进度、协议状态 Tag
|
||||
3. 下半按 `schedules` 展开:期次、应缴、应缴日、已缴、欠缴、批次状态;内嵌 `transfers` 列表(金额、方式、时间、凭证、`payStatus` Tag)
|
||||
4. 对欠缴 > 0 且无该期 `jfqrz` 的期次显示「缴费确认」:
|
||||
- 若该期已有 `dfj` 转账:用其 `id` 打开共享确认弹窗(`isTransfer=true`)
|
||||
- 若无转账:先 `fetchCreatePaymentTransfer({ scheduleId, transferAmount: arrears 或 due })`,再用返回 id 打开弹窗
|
||||
5. 列表行「缴费确认」:打开详情后定位最早可确认期,再走同上逻辑
|
||||
6. 「协议单」:`ProtocolViewDialog`(与看板一致)
|
||||
|
||||
- [ ] **Step 3: 确认后刷新**
|
||||
|
||||
共享弹窗 `@saved` / `@submitted`:重新拉详情 + 刷新列表当前页。
|
||||
|
||||
- [ ] **Step 4: 手动验证**
|
||||
|
||||
1. 打开 `/#/cell-storage/payment-info?tab=by-sample`
|
||||
2. 能搜到已配置按样本缴费的数据
|
||||
3. 点查看看各期/转账
|
||||
4. 待缴费样本可提交财务确认;提交后进度变为「缴费确认中」
|
||||
5. 流程看板同笔记录也出现在「缴费确认中」列
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/cell-storage/payment-info/PaymentBySampleTab.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add per-sample payment list and operable detail
|
||||
|
||||
Show one row per sample with schedules/transfers and shared receipt confirm.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 菜单 SQL、图谱、冒烟收尾
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-05-unhide-payment-info-menu.sql`
|
||||
- Run: `./scripts/update-graph.sh all`(或分别 backend/frontend)
|
||||
- Update design status if desired
|
||||
|
||||
- [ ] **Step 1: 菜单增量 SQL**
|
||||
|
||||
创建 `docs/sql/migrations/2026-10-05-unhide-payment-info-menu.sql`:
|
||||
|
||||
```sql
|
||||
-- 缴费信息:取消隐藏,排序夹在预签约订单与样本信息管理之间
|
||||
-- 说明:若环境使用前端固定菜单(menu.py / sqxp),本脚本用于 DB 菜单模式环境
|
||||
|
||||
-- 1) 若已存在该权限码菜单:显示并调整排序
|
||||
UPDATE sys_menu
|
||||
SET del_flag = 0,
|
||||
visible = '0', -- 按本库「显示」约定调整;若字段为 is_hide/status 则改为对应值
|
||||
order_num = (
|
||||
SELECT mid_order FROM (
|
||||
SELECT COALESCE(MAX(m2.order_num), 0) AS mid_order
|
||||
FROM sys_menu m2
|
||||
WHERE m2.perms IN ('cell-storage:presign-order:list', 'cell-storage:presign-order:list')
|
||||
OR m2.path LIKE '%presign-order%'
|
||||
) t
|
||||
) + 1
|
||||
WHERE perms = 'cellStorage:paymentInfo:list'
|
||||
OR path LIKE '%payment-info%';
|
||||
|
||||
-- 2) 若库中无此菜单:由实施时对照 menu.py 插入完整行(id 用 904b5173519411f1944300163e3bdd96)
|
||||
-- INSERT ... 仅在确认表结构后由实施填写,避免错误列名上线
|
||||
```
|
||||
|
||||
实施时打开真实 `sys_menu` 表结构(`docs/sql/zkxw.sql` 中 `CREATE TABLE sys_menu`)核对列名(`visible`/`status`/`is_frame`/`perms`/`path`/`component`/`order_num`/`parent_id`),把 UPDATE/INSERT 写成可直接执行的最终版;**禁止带着未核对的占位列上生产**。
|
||||
|
||||
若当前环境完全走 `menu.py` 前端模式、库中无该菜单,SQL 可写清注释「前端模式无需执行」,仍保留文件作为文档。
|
||||
|
||||
- [ ] **Step 2: 更新图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 冒烟清单(全部勾掉再收工)**
|
||||
|
||||
- [ ] 侧栏顺序:预签约 → 缴费信息 → 样本信息
|
||||
- [ ] 协议单仅 3 Tab
|
||||
- [ ] `protocol-audit?tab=payment-info` → 独立页流程看板
|
||||
- [ ] 流程看板确认仍可用
|
||||
- [ ] 按样本列表筛选/分页
|
||||
- [ ] 详情确认后两边状态一致
|
||||
- [ ] 财务确认页未回归破坏
|
||||
- [ ] `pytest tests/test_sample_payment_progress.py tests/test_protocol_sample_payment.py -v` PASS
|
||||
|
||||
- [ ] **Step 4: 设计文档状态**
|
||||
|
||||
将 `docs/superpowers/specs/2026-10-05-payment-info-standalone-menu-design.md` 顶部状态改为「已实现」(全部冒烟通过后)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/migrations/2026-10-05-unhide-payment-info-menu.sql \
|
||||
backend/graphify-out web-admin/graphify-out \
|
||||
docs/superpowers/specs/2026-10-05-payment-info-standalone-menu-design.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: menu SQL, graph sync, and mark payment-info split done
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage Checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 独立菜单在预签约与样本信息之间 | 4, 7 |
|
||||
| 双 Tab:流程看板 + 按样本 | 4, 6 |
|
||||
| 协议单去掉缴费 Tab + 深链 | 4 |
|
||||
| 一行一样本字段与进度 Tag | 1, 3, 6 |
|
||||
| 详情可操作确认 | 5, 6 |
|
||||
| 只读聚合 API + 复用写 API | 2 |
|
||||
| 权限码复用 | 4, 7 |
|
||||
| 旧模型不进按样本 | 2(主表仅 sample_payment) |
|
||||
| 不做退费/改方案/导出 | 全计划未包含 |
|
||||
|
||||
## Placeholder / Ambiguity Notes(已锁定)
|
||||
|
||||
- `payStatus.dfj` 文案是「已缴费」;样本进度 `dfj` 必须用独立 `samplePaymentProgress`,文案「待缴费」。
|
||||
- 列表 `progressStatus` 过滤:**先算进度再分页**,禁止先 LIMIT。
|
||||
- 共享确认弹窗优先抽取(Task 5),避免两套表单漂移。
|
||||
Reference in new issue
Block a user