# 采集协议单 · 缴费默认定价 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:** 按「样本数 × 缴费模式」在新建/首次选模式时填充默认合同金额与分期模板;支持双样本 `djfq`(趸交+分期);趸交允许多条缴费明细;样本数/样本价变化不再自动改合同金额。 **Architecture:** 前端新增 `protocol-payment-presets.ts` 集中定价表与 `applyPaymentPreset()`;`PayMode` 扩展为 `dj | fq | djfq`,分期逻辑通过 `isInstallmentPayMode()` 统一处理 `fq` 与 `djfq`;后端校验放宽趸交多 `dfj`、合计校验,`djfq` 走分期分支;方案 B 用 `paymentDefaultsApplied` 标记避免重复填默认。 **Tech Stack:** Vue 3 · TypeScript · Element Plus · FastAPI · Python unittest · `tsx`(前端纯函数冒烟) **Design spec:** `docs/superpowers/specs/2026-07-06-protocol-payment-defaults-design.md` --- ## File Map | 文件 | 职责 | |------|------| | `web-admin/src/utils/protocol-payment-presets.ts` | **新建** 定价表、`getActiveSampleCount`、`applyPaymentPreset`、`isInstallmentPayMode` | | `web-admin/scripts/test-protocol-payment-presets.ts` | **新建** 定价表冒烟脚本(`tsx` 运行) | | `web-admin/src/utils/protocol-pay-mode-switch.ts` | `PayMode` 含 `djfq`;趸交多明细;切换时用 preset 重建 | | `web-admin/src/utils/protocol-payment-installment.ts` | `alignFq*` / `syncFq*` 识别 `djfq` | | `web-admin/src/utils/status-tag-registry.ts` | `djfq` 标签 | | `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` | 模式 UI、首次默认、去掉样本价联动、保存校验 | | `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | `djfq` 只读展示 | | `web-admin/src/components/business/protocol-payment-detail-readonly/index.vue` | 分期字段展示含 `djfq` | | `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue` | 摘要 `djfq` 按分期展示 | | `web-admin/src/utils/protocol-standard-import-parse.ts` | (P1)识别「趸交+分期」 | | `backend/app/services/protocol_pay_mode_switch.py` | 趸交多明细合计校验;`djfq`→分期分支 | | `backend/app/crud/collection_protocol.py` | `align_fq_*` 等 `djfq` 与 `fq` 同等 | | `backend/app/services/qc_work_order_fee_summary.py` | `djfq` 标签与分期摘要 | | `backend/tests/test_protocol_pay_mode_switch.py` | 更新/新增校验单测 | | `backend/tests/test_protocol_payment_presets.py` | **新建**(可选)后端不依赖 preset,本任务以前端脚本为主 | --- ## 依赖顺序 ```text Task 1 (presets 纯函数) → Task 2 (后端校验) → Task 3 (pay-mode-switch + installment 工具) → Task 4 (标签/只读组件) → Task 5 (ProtocolFormDialog 主改动) → Task 6 (导入 P1 + 收尾 QA) ``` --- ### Task 1: 定价表纯函数与冒烟测试 **Files:** - Create: `web-admin/src/utils/protocol-payment-presets.ts` - Create: `web-admin/scripts/test-protocol-payment-presets.ts` - [ ] **Step 1: 创建 `protocol-payment-presets.ts`** ```typescript // web-admin/src/utils/protocol-payment-presets.ts export type PayMode = 'dj' | 'fq' | 'djfq' export type PaymentPreset = { contractAmount: number installmentCount?: number installmentAmounts?: number[] } const PRESETS: Record<1 | 2, Partial>> = { 1: { dj: { contractAmount: 22880 }, fq: { contractAmount: 25880, installmentCount: 6, installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880] } }, 2: { dj: { contractAmount: 37880 }, djfq: { contractAmount: 41000, installmentCount: 5, installmentAmounts: [22880, 6480, 3880, 3880, 3880] }, fq: { contractAmount: 44000, installmentCount: 5, installmentAmounts: [12960, 7760, 7760, 7760, 7760] } } } export function getActiveSampleCount( samples: Array<{ sampleStatus?: string }> ): 1 | 2 { const n = samples.filter((s) => String(s.sampleStatus || '') !== 'yzf').length return (n >= 2 ? 2 : 1) as 1 | 2 } export function resolvePaymentPreset( sampleCount: 1 | 2, payMode: PayMode ): PaymentPreset | null { return PRESETS[sampleCount]?.[payMode] ?? null } export function isInstallmentPayMode(payMode: string): boolean { return payMode === 'fq' || payMode === 'djfq' } export function availablePayModes(sampleCount: 1 | 2): PayMode[] { return sampleCount === 2 ? ['dj', 'djfq', 'fq'] : ['dj', 'fq'] } /** 校验双样本才能用 djfq */ export function isPayModeValidForSampleCount( payMode: PayMode, sampleCount: 1 | 2 ): boolean { return availablePayModes(sampleCount).includes(payMode) } ``` - [ ] **Step 2: 创建冒烟脚本** ```typescript // web-admin/scripts/test-protocol-payment-presets.ts import assert from 'node:assert/strict' import { resolvePaymentPreset, getActiveSampleCount, isInstallmentPayMode, isPayModeValidForSampleCount } from '../src/utils/protocol-payment-presets' const fq1 = resolvePaymentPreset(1, 'fq')! assert.equal(fq1.contractAmount, 25880) assert.deepEqual(fq1.installmentAmounts, [6480, 3880, 3880, 3880, 3880, 3880]) assert.equal( fq1.installmentAmounts!.reduce((s, v) => s + v, 0), fq1.contractAmount ) const djfq2 = resolvePaymentPreset(2, 'djfq')! assert.equal(djfq2.contractAmount, 41000) assert.equal(djfq2.installmentCount, 5) assert.equal(getActiveSampleCount([{ sampleStatus: 'ycj' }]), 1) assert.equal(getActiveSampleCount([{ sampleStatus: 'ycj' }, { sampleStatus: 'yzf' }]), 1) assert.equal( getActiveSampleCount([{ sampleStatus: 'ycj' }, { sampleStatus: 'ycj' }]), 2 ) assert.equal(isInstallmentPayMode('djfq'), true) assert.equal(isInstallmentPayMode('dj'), false) assert.equal(isPayModeValidForSampleCount('djfq', 1), false) assert.equal(isPayModeValidForSampleCount('djfq', 2), true) console.log('protocol-payment-presets: OK') ``` - [ ] **Step 3: 运行冒烟** ```bash cd web-admin && pnpm exec tsx scripts/test-protocol-payment-presets.ts ``` Expected: 输出 `protocol-payment-presets: OK`,exit 0。 --- ### Task 2: 后端保存校验(趸交多明细 + djfq) **Files:** - Modify: `backend/app/services/protocol_pay_mode_switch.py` - Modify: `backend/tests/test_protocol_pay_mode_switch.py` - [ ] **Step 1: 修改 `validate_payment_details_for_save` 趸交分支** 将 `dj` 分支中「仅 1 条 dfj」改为「全部明细应缴合计 = 合同金额」: ```python # backend/app/services/protocol_pay_mode_switch.py — pay_mode == "dj" 分支替换为: if pay_mode == "dj": total_due = sum( _dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount")) for r in rows ) if contract and abs(total_due - contract) > 0.015: raise ValueError( f"趸交各明细应缴金额之和应为 {contract:.2f},当前为 {total_due:.2f}" ) # 保留:存在 yjf 时,dfj 行 due 之和应等于 remaining(可选细化为与 total 一致即可) ``` 删除: ```python if len(dfj_rows) > 1: raise ValueError("趸交模式仅允许一条待缴费明细") ``` 以及单条 `dfj` 的 `expected` 校验(已由 `total_due` 覆盖)。 - [ ] **Step 2: `djfq` 走分期分支** ```python installment_modes = ("fq", "djfq") # ... elif pay_mode in installment_modes: # 原 fq 逻辑不变 ``` - [ ] **Step 3: 更新单测** `test_dj_rejects_multiple_dfj` 改名为 `test_dj_allows_multiple_dfj_when_sum_matches`: ```python def test_dj_allows_multiple_dfj_when_sum_matches(self): validate_payment_details_for_save( "dj", 10000, None, [ {"payStatus": "dfj", "dueAmount": 2000}, {"payStatus": "dfj", "dueAmount": 8000}, ], ) def test_dj_rejects_multiple_dfj_wrong_sum(self): with self.assertRaises(ValueError) as ctx: validate_payment_details_for_save( "dj", 10000, None, [ {"payStatus": "dfj", "dueAmount": 2000}, {"payStatus": "dfj", "dueAmount": 7000}, ], ) self.assertIn("之和应为", str(ctx.exception)) ``` 新增: ```python def test_djfq_same_as_fq(self): validate_payment_details_for_save( "djfq", 41000, 5, [ {"payStatus": "dfj", "installmentNo": 1, "dueAmount": 22880}, {"payStatus": "dfj", "installmentNo": 2, "dueAmount": 6480}, {"payStatus": "dfj", "installmentNo": 3, "dueAmount": 3880}, {"payStatus": "dfj", "installmentNo": 4, "dueAmount": 3880}, {"payStatus": "dfj", "installmentNo": 5, "dueAmount": 3880}, ], installment_ratios=[22880, 6480, 3880, 3880, 3880], ) ``` - [ ] **Step 4: 运行后端单测** ```bash cd backend && source venv/bin/activate && python -m pytest tests/test_protocol_pay_mode_switch.py -v ``` Expected: 全部 PASS。 - [ ] **Step 5: 修改 `collection_protocol.py` 分期对齐** 约 L2814,将: ```python if pay.pay_mode != "fq" or not pay.installment_count: ``` 改为: ```python if pay.pay_mode not in ("fq", "djfq") or not pay.installment_count: ``` 全文检索 `pay_mode != "fq"` / `pay_mode == "fq"` 在同文件内一并调整。 --- ### Task 3: 前端工具层(pay-mode-switch + installment) **Files:** - Modify: `web-admin/src/utils/protocol-pay-mode-switch.ts` - Modify: `web-admin/src/utils/protocol-payment-installment.ts` - [ ] **Step 1: 扩展 `PayMode` 并导入 preset** ```typescript // protocol-pay-mode-switch.ts 顶部 import { type PayMode, isInstallmentPayMode, resolvePaymentPreset, getActiveSampleCount } from '@/utils/protocol-payment-presets' export type { PayMode } ``` - [ ] **Step 2: `canAddPaymentDetail` 趸交始终可添加** ```typescript export function canAddPaymentDetail(payMode: string, _details: DetailLike[]): boolean { if (isInstallmentPayMode(payMode)) return true return true // dj:允许多条明细 } ``` - [ ] **Step 3: `evaluatePayModeSwitch` / `applyPayModeSwitch` 支持 `djfq`** - `dj` → `djfq`:与 `dj` → `fq` 相同(无已缴才允许) - `djfq` → `dj`:与 `fq` → `dj` - `djfq` ↔ `fq`:按分期规则处理 - `applyPayModeSwitch` 重建分期明细时,从 `resolvePaymentPreset(sampleCount, toMode)` 取 `installmentAmounts`,无 preset 时回退 `defaultInstallmentAmounts` - [ ] **Step 4: `protocol-payment-installment.ts`** 将所有 `payment.payMode !== 'fq'` / `=== 'fq'` 改为使用 `isInstallmentPayMode(payment.payMode as string)`(从 presets 导入)。 - [ ] **Step 5: 类型检查** ```bash cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -40 ``` Expected: 无新增 error(允许既有 warning)。 --- ### Task 4: 标签与只读展示 **Files:** - Modify: `web-admin/src/utils/status-tag-registry.ts` - Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` - Modify: `web-admin/src/components/business/protocol-payment-detail-readonly/index.vue` - Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue` - Modify: `backend/app/services/qc_work_order_fee_summary.py` - [ ] **Step 1: `status-tag-registry.ts` 增加** ```typescript djfq: { label: '趸交+分期', tagType: 'warning' } // 或 primary,与 fq 区分即可 ``` - [ ] **Step 2: 只读组件分期条件** `payment?.payMode === 'fq'` → `isInstallmentPayMode(payment?.payMode || '')`(ViewDialog、readonly、QcWorkOrderProtocolPaymentPanel)。 - [ ] **Step 3: 后端 `_PAY_MODE_LABELS`** ```python _PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"} ``` `build_protocol_payment_summary` 内 `pay_mode == "fq"` 改为 `pay_mode in ("fq", "djfq")`。 --- ### Task 5: ProtocolFormDialog 主改动 **Files:** - Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` - [ ] **Step 1: 缴费模式单选 UI** ```vue 趸交 趸交+分期 分期 ``` `activeSampleCount` = `computed(() => getActiveSampleCount(formData.samples))`。 - [ ] **Step 2: 分期区块条件** `formData.payMode === 'fq'` → `isInstallmentPayMode(formData.payMode)`(模板内所有分期表单项、分期金额行)。 - [ ] **Step 3: 新增状态 `paymentDefaultsApplied`** ```typescript const paymentDefaultsApplied = ref>({ dj: false, fq: false, djfq: false }) function applyPaymentPresetForMode(payMode: PayMode) { const preset = resolvePaymentPreset(getActiveSampleCount(formData.samples), payMode) if (!preset) return formData.contractAmount = preset.contractAmount if (isInstallmentPayMode(payMode) && preset.installmentCount) { formData.installmentCount = preset.installmentCount formData.installmentIntervalMonths = formData.installmentIntervalMonths ?? 6 formData.installmentStartDate = formData.installmentStartDate || defaultInstallmentStartDateYmd() formData.installmentAdvanceDays = formData.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS formData.installmentRatios = [...(preset.installmentAmounts || [])] formData.paymentDetails = generateInitialFqDetails( getFqParamsFromForm(), preset.contractAmount ) initPaymentDetailExpandFlags(formData.paymentDetails) markPayDetailLocks(formData.paymentDetails) syncLastConfirmedFqParams() } else { formData.paymentDetails = [ createPaymentDetailRow({ dueAmount: preset.contractAmount, installmentDueDate: formData.lumpSumDeadline || '', expanded: true }) ] } paymentDefaultsApplied.value[payMode] = true } ``` - [ ] **Step 4: 改造 `onPayModeChange`** 在既有 `evaluatePayModeSwitch` 之前/之后: ```typescript async function onPayModeChange(newMode: PayMode) { const oldMode = lastConfirmedPayMode.value // ... 既有 evaluate + apply 逻辑 ... if (!paymentDefaultsApplied.value[newMode]) { applyPaymentPresetForMode(newMode) } lastConfirmedPayMode.value = newMode } ``` 新建弹窗 `resetForm` / `openCreate` 时重置 `paymentDefaultsApplied` 全 `false`;编辑加载详情后全部标 `true`(不覆盖库内数据)。 - [ ] **Step 5: 去掉样本价 → 合同额自动联动** `onSamplePriceChange` 改为空实现或仅刷新弱提示: ```typescript function onSamplePriceChange() { // 方案 B:不调用 syncContractAmount() } ``` 删除或停用 `syncContractAmount()` 内对 `formData.contractAmount` 的写入;保留函数供「恢复默认」P1 使用时可再启用。 可选弱提示(缴费 Tab): ```typescript const samplePriceContractHint = computed(() => { const sampleTotal = formData.samples .filter((s) => s.sampleStatus !== 'yzf') .reduce((sum, s) => sum + (Number(s.price) || 0), 0) if (formData.contractAmount == null) return '' if (Math.abs(sampleTotal - Number(formData.contractAmount)) > 0.01) { return `样本价格合计 ${fmtMoney(sampleTotal)} 与合同金额不一致,请核对` } return '' }) ``` - [ ] **Step 6: 保存前校验 `djfq` 样本数** 在 `handleSubmit` 缴费校验段: ```typescript if ( formData.payMode === 'djfq' && getActiveSampleCount(formData.samples) !== 2 ) { await ElMessageBox.alert('「趸交+分期」仅适用于 2 个样本,请切换缴费模式', '无法保存', { type: 'warning' }) return } if (!isPayModeValidForSampleCount(formData.payMode as PayMode, getActiveSampleCount(formData.samples))) { await ElMessageBox.alert('当前缴费模式与样本数量不匹配', '无法保存', { type: 'warning' }) return } ``` - [ ] **Step 7: 标准导入后标记默认已应用** `applyPaymentFromImport` 末尾: ```typescript paymentDefaultsApplied.value = { dj: true, fq: true, djfq: true } ``` - [ ] **Step 8: 编辑加载 `lastConfirmedPayMode`** ```typescript lastConfirmedPayMode.value = (['fq', 'djfq'].includes(formData.payMode) ? formData.payMode : 'dj') as PayMode ``` - [ ] **Step 9: 手动验证(开发环境)** 1. 新建 → 添加 1 样本 → 首次选「分期」→ 合同 25880、6 期金额正确 2. 再添加第 2 样本 → 合同/明细**不变** 3. 新建 2 样本 → 选「趸交+分期」→ 41000、5 期 4. 趸交 → 添加 2 条明细 2000+20880 → 保存成功 5. 2 样本 `djfq` 删 1 样本 → 保存拦截 --- ### Task 6: 标准导入(P1)与收尾 **Files:** - Modify: `web-admin/src/utils/protocol-standard-import-parse.ts` - Run: `./scripts/update-graph.sh frontend` - [ ] **Step 1: 导入识别 `djfq`** `parsePayMode` 中: ```typescript if (/趸交\s*[++]\s*分期|趸交.*分期/.test(v)) return { payMode: 'djfq' } ``` `ProtocolStandardImportParsed.payMode` 类型改为 `'dj' | 'fq' | 'djfq'`。 - [ ] **Step 2: 更新知识图谱** ```bash ./scripts/update-graph.sh frontend ``` - [ ] **Step 3: Lint** ```bash cd web-admin && pnpm lint cd backend && python -m pytest tests/test_protocol_pay_mode_switch.py -v ``` - [ ] **Step 4: 更新 design spec 状态** `docs/superpowers/specs/2026-07-06-protocol-payment-defaults-design.md` 表头 `状态` → `已实现`。 --- ## Spec 覆盖自检 | Spec § | Task | |--------|------| | §3 默认定价表 | Task 1 | | §4 方案 B 填充时机 | Task 5 Step 3–4、7 | | §5 趸交多明细 | Task 2、3、5 | | §5.2 djfq 分期 | Task 2–5 | | §6 模式切换修订 | Task 3、5 | | §7 pay_mode=djfq | Task 2、4 | | §8 UI | Task 4、5 | | §8.3 样本价解耦 | Task 5 Step 5 | | §9–10 前后端摘要 | Task 2–5 | | §11 测试要点 | 各 Task Step 验证 | | §4.3 恢复默认 P1 | **范围外**(未列入 Task) | --- ## 人工回归清单(提测) - [ ] 新建 1 样本首次 `fq`:25880 / 6 期模板 - [ ] 新建 2 样本首次 `djfq`:41000 / 5 期 - [ ] 样本数 1→2 后金额不变 - [ ] 趸交 2+ 条 `dfj` 保存成功 - [ ] 分期合计 ≠ 合同额:前后端拦截 - [ ] 已缴锁定 + 模式切换:仍符合 2026-06-04 - [ ] 查看弹窗 / 质检费用摘要:`djfq` 标签与期次表 - [ ] 标准导入多笔趸交转账保存成功 --- ## 执行方式 **Plan 已保存至** `docs/superpowers/plans/2026-07-06-protocol-payment-defaults.md`。 **两种执行方式:** 1. **Subagent-Driven(推荐)** — 每 Task 派发独立子代理,Task 间人工/代理复核 2. **Inline Execution** — 本会话按 Task 1→6 连续实现,每 Task 末 checkpoint 告知选用哪种方式即可开始实现。