2026-10-05-payment-info-standalone-menu.md 29 KB

缴费信息独立菜单 + 按样本维度 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)

依赖顺序

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

"""样本缴费进度:状态码与已缴/待缴汇总"""

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:

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:

"""样本缴费方案进度汇总(列表「按样本」维度)"""

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
cd backend && source venv/bin/activate && pytest tests/test_sample_payment_progress.py -v

Expected: PASS

  • Step 5: Commit
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 为准):

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 已存在,新路径独立):

@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
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
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 追加:

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 附近新增:

/** 按样本列表整体进度(勿与转账笔 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
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 结构:

<!-- 缴费信息(流程看板 / 按样本) -->
<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 之间,并改:

meta: {
  title: '缴费信息',
  keepAlive: true,
  // 删除 isHide: true
  menuCode: 'cellStorage:paymentInfo:list'
}
  • Step 4: menu.py 硬编码树

在细胞存储 children 的预签约与样本信息之间插入:

{
    "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
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 建议:

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 状态,改为:

<PaymentReceiptConfirmDialog
  v-model="receiptDialogVisible"
  :preview-row="receiptPreviewRow"
  :is-transfer="receiptIsTransfer"
  :target-id="receiptTargetId"
  @saved="loadAllColumns()"
  @submitted="loadAllColumns()"
/>
  • Step 3: 看板回归

在流程看板「已缴费」列点「缴费确认」,确认弹窗可保存/提交。

  • Step 4: Commit
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
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

  • [x] Step 1: 菜单增量 SQL

创建 docs/sql/migrations/2026-10-05-unhide-payment-info-menu.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: 更新图谱
./scripts/update-graph.sh all
  • [x] Step 3: 冒烟清单(全部勾掉再收工)

  • [x] 侧栏顺序:预签约 → 缴费信息 → 样本信息

  • [x] 协议单仅 3 Tab

  • [x] protocol-audit?tab=payment-info → 独立页流程看板

  • [x] 流程看板确认仍可用

  • [x] 按样本列表筛选/分页

  • [x] 详情确认后两边状态一致

  • [x] 财务确认页未回归破坏

  • [x] pytest tests/test_sample_payment_progress.py tests/test_protocol_sample_payment.py -v PASS

  • [x] Step 4: 设计文档状态

将 docs/superpowers/specs/2026-10-05-payment-info-standalone-menu-design.md 顶部状态改为「已实现」(全部冒烟通过后)。

  • Step 5: Commit
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),避免两套表单漂移。