# 采集协议单 · 缴费模式切换设计 **日期**:2026-06-04 **范围**:`web-admin` 采集协议单新增/编辑弹窗(`ProtocolFormDialog.vue`)及后端缴费主表/明细保存 **状态**:已实现(P0) --- ## 1. 背景 采集协议单缴费主表 `pay_mode` 有两种取值: | 值 | 含义 | 明细约定 | |----|------|----------| | `dj` | 趸交(一次性) | **1 条**有效明细 | | `fq` | 分期 | **N 条**明细,N = `installment_count` | 用户在**编辑已保存协议**时可能切换缴费模式。当前实现仅做字段联动(金额同步、日期重算),**不区分明细缴费状态**,会导致: - 分期改趸交后仍保留多条明细且每条金额 = 合同全额; - 已缴明细被重算应缴日/期次; - 趸交已缴后仍可改分期,账实不符。 本设计定义切换规则与实现边界。 --- ## 2. 决策记录 | 议题 | 决定 | |------|------| | 小数月间隔换算 | 30 天/月(已实现,见 `protocol-payment-installment.ts`) | | 第 2 期起提前天数 | 可配置,默认 **30** 天(见 [2026-06-10-protocol-installment-config-design.md](./2026-06-10-protocol-installment-config-design.md)) | | 分期部分已缴后改趸交,未缴期次 | **A:作废**(软删/不再展示,不进分期预警) | | 已缴/确认中明细 | **保留**,只读,不参与重算 | | 趸交已缴后改分期 | **禁止** | | 趸交确认中改分期 | **禁止**(提示先完成或撤回财务确认) | --- ## 3. 明细状态与「锁定」定义 | pay_status | 含义 | 切换时 | |------------|------|--------| | `dfj` | 待缴费 | 可作废、可重建 | | `jfqrz` | 缴费确认中 | **锁定**(视为已占用,禁止模式切换) | | `yjf` | 已缴费 | **锁定**(保留,只读) | | `tfqrz` | 退费确认中 | **锁定**(禁止切换) | | `ytf` | 已退费 | **锁定**(禁止切换) | **剩余应缴金额**: ``` remaining = contractAmount − Σ(dueAmount WHERE pay_status IN ('yjf')) ``` `jfqrz` 阶段切换一律阻断;若业务后续允许「确认中算占用」,再单独开变更。 --- ## 4. 场景规则 ### 4.1 趸交 → 分期(dj → fq) | 前置条件 | 处理 | |----------|------| | 无明细或全部 `dfj` | **允许**。作废全部现有明细;按分期参数生成 N 条新 `dfj` 明细;主表 `pay_mode=fq`。 | | 存在 `jfqrz` / `yjf` / `tfqrz` / `ytf` | **禁止**。提示原因。 | 切换后:第 i 期 `installmentNo=i`,应缴日按 `calcFqInstallmentDueDate`,应缴金额按合同均分(末期待尾差)。 ### 4.2 分期 → 趸交(fq → dj),一期未缴 | 前置条件 | 处理 | |----------|------| | 全部 `dfj` | **允许**。作废全部 N 条分期明细;**新建 1 条**趸交明细:`dueAmount=contractAmount`,`installmentNo=null`,应缴日=`lumpSumDeadline`。 | | 存在任何非 `dfj` | 走 4.3 | ### 4.3 分期 → 趸交(fq → dj),部分已缴 | 前置条件 | 处理 | |----------|------| | 有 `yjf`,无 `jfqrz`/`tfqrz`/`ytf` | **允许**。 | | 存在 `jfqrz`/`tfqrz`/`ytf` | **禁止**。 | **允许时步骤**: 1. **保留**所有 `yjf` 明细(只读展示,保留原 `installmentNo`、转账信息)。 2. **作废**所有 `dfj` 分期明细(不进入表单列表;保存时 `del_flag=1`)。 3. **新建 1 条**趸交明细:`dueAmount=remaining`,`payStatus=dfj`,`installmentNo=null`,应缴日=`lumpSumDeadline`。 4. 主表 `pay_mode=dj`;分期参数字段可保留只读快照(便于查看历史计划)。 若 `remaining ≤ 0`:禁止切换,提示「已缴清,无需改趸交」。 ### 4.4 趸交 → 分期,趸交已缴 **禁止**。提示「趸交已结清,无法改为分期」。 --- ## 5. 交互设计 ### 5.1 切换前校验 用户点击「分期 / 趸交」radio 时: 1. 若目标模式与当前相同 → 无操作。 2. 调用 `evaluatePayModeSwitch(currentMode, targetMode, details, contractAmount)`。 3. 返回 `blocked` → `ElMessageBox.alert` 说明原因,**回滚 radio**。 4. 返回 `allowed` → `ElMessageBox.confirm` 展示影响摘要,确认后执行变换。 **确认文案示例(fq→dj,部分已缴)**: > 将作废 4 期未缴计划,保留 2 期已缴记录,并新增 1 条趸交明细(剩余 ¥X,XXX.XX)。是否继续? ### 5.2 编辑态保护 - 锁定明细:禁用删除、应缴金额、缴费状态、期次、应缴日编辑。 - 趸交模式下隐藏「添加明细」(或禁用,保证仅 1 条有效未缴行 + 已缴历史行)。 - 切到分期时**不**强制 `resetCountInterval`;优先保留主表已保存的期数/间隔/开始时间。 ### 5.3 表单数据结构(过渡期) ```typescript interface PaymentDetailRow { // ...existing fields _payModeLocked?: boolean // yjf 等锁定行 _voided?: boolean // 前端待作废,保存时不提交或提交 void 标记 } ``` 保存 payload:仅提交**未作废**明细 + 锁定保留行;作废行由后端按 id 软删(P1)或前端不提交、后端 diff(P2)。 --- ## 6. 后端改造(分阶段) ### P0(与前端同步上线) 在 `save_payment_details` / 协议 bundle 保存前增加校验: - `pay_mode=dj`:有效明细中「未锁定 + 未作废」的**待缴**行 ≤ 1;`Σ dueAmount(dfj 行)` = `remaining`(无已缴时 = 合同金额)。 - `pay_mode=fq`:有效 `dfj` 行数 = `installment_count`(无已缴切换场景);或允许「已缴 k 期 + 剩余 N−k 期 dfj」(dj→fq 不适用,仅 fq 内改参数时需另议)。 - 禁止客户端把 `yjf` 行 `pay_status` 改回 `dfj`。 ### P1 - 作废明细:`del_flag=1`,分期预警自然排除。 - 保存时带 `detail.id` upsert,避免每次编辑换 UUID。 ### P2 - 专用 API:`POST .../payment/change-mode` - 操作日志:记录模式变更、作废条数、剩余金额 --- ## 7. 核心算法(前端纯函数) 建议新增 `web-admin/src/utils/protocol-pay-mode-switch.ts`: ```typescript type SwitchResult = | { ok: true; details: DetailLike[]; summary: string } | { ok: false; reason: string } function evaluatePayModeSwitch(...): SwitchResult | 'need_confirm' function applyPayModeSwitch(...): SwitchResult ``` **禁止**在 `onPayModeChange` 中直接 `forEach` 重写全部明细;改为 `applyPayModeSwitch` 返回新数组。 `tryGenerateInstallmentDetails` / `syncPaymentDueAmounts`: - 跳过 `_payModeLocked` 或 `payStatus !== 'dfj'` 的行; - 仅对未锁定 `dfj` 行重算金额/日期。 --- ## 8. 测试用例 | # | 场景 | 期望 | |---|------|------| | T1 | dj dfj×1 → fq 6期 | 1 条作废,6 条新 dfj,金额均分 | | T2 | fq 6×dfj → dj | 6 条作废,1 条 dj dfj 全额 | | T3 | fq 2×yjf + 4×dfj → dj | 2 条 yjf 保留,4 条作废,1 条 dj remaining | | T4 | dj yjf×1 → fq | blocked | | T5 | fq 含 jfqrz → dj | blocked | | T6 | fq 全部 yjf → dj | blocked(remaining=0) | | T7 | 切换取消 | radio 回滚,明细不变 | --- ## 9. 不在本次范围 - 退费后再改模式的专流程 UI - 小程序端缴费模式切换 --- ## 10. 实现顺序建议 1. `protocol-pay-mode-switch.ts` + 单元测试(Node/vitest 或 backend pytest 镜像逻辑) 2. 改造 `onPayModeChange` + 确认弹窗 3. 改造 `tryGenerateInstallmentDetails` / `syncPaymentDueAmounts` 跳过锁定行 4. 后端 P0 校验 5. 联调四个场景 + 分期预警回归 --- ## 11. 分期参数变更(非模式切换) 编辑已保存的**分期**协议时,用户可能修改「分期期数 / 每期间隔 / 分期开始时间」,规则如下: | 情况 | 处理 | |------|------| | 全部 `dfj` | 确认后按新参数**整表重建** N 条明细 | | 部分 `yjf` | 保留已缴行;**作废**全部 `dfj`;按 **剩余金额** 均分生成 `(新期数 − 已缴期数)` 条未缴明细;应缴日按已缴期数续算 index | | 期数小于已缴最大期次 | **禁止** | | 含 `jfqrz`/`tfqrz`/`ytf` | **禁止** | | 仅改间隔/开始时间(期数不变) | 确认后保留 yjf,重建未缴行(金额+日期) | 实现:`web-admin/src/utils/protocol-fq-params-change.ts`,表单 `@change` → `handleFqParamsChange()`。 --- **审阅**:请确认本 spec 无遗漏后,进入 `writing-plans` 生成实现任务清单。