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:
zlwuandCursor committed 2026-10-05 12:53:47 +08:00
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),避免两套表单漂移。