2026-06-04-protocol-pay-mode-switch-design.md 8.2 KB

采集协议单 · 缴费模式切换设计

日期: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)
分期部分已缴后改趸交,未缴期次 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 表单数据结构(过渡期)

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:

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 生成实现任务清单。