日期:2026-06-04
范围:web-admin 采集协议单新增/编辑弹窗(ProtocolFormDialog.vue)及后端缴费主表/明细保存
状态:已实现(P0)
采集协议单缴费主表 pay_mode 有两种取值:
| 值 | 含义 | 明细约定 |
|---|---|---|
dj |
趸交(一次性) | 1 条有效明细 |
fq |
分期 | N 条明细,N = installment_count |
用户在编辑已保存协议时可能切换缴费模式。当前实现仅做字段联动(金额同步、日期重算),不区分明细缴费状态,会导致:
本设计定义切换规则与实现边界。
| 议题 | 决定 |
|---|---|
| 小数月间隔换算 | 30 天/月(已实现,见 protocol-payment-installment.ts) |
| 第 2 期起提前天数 | 可配置,默认 30 天(见 2026-06-10-protocol-installment-config-design.md) |
| 分期部分已缴后改趸交,未缴期次 | A:作废(软删/不再展示,不进分期预警) |
| 已缴/确认中明细 | 保留,只读,不参与重算 |
| 趸交已缴后改分期 | 禁止 |
| 趸交确认中改分期 | 禁止(提示先完成或撤回财务确认) |
| pay_status | 含义 | 切换时 |
|---|---|---|
dfj |
待缴费 | 可作废、可重建 |
jfqrz |
缴费确认中 | 锁定(视为已占用,禁止模式切换) |
yjf |
已缴费 | 锁定(保留,只读) |
tfqrz |
退费确认中 | 锁定(禁止切换) |
ytf |
已退费 | 锁定(禁止切换) |
剩余应缴金额:
remaining = contractAmount − Σ(dueAmount WHERE pay_status IN ('yjf'))
jfqrz 阶段切换一律阻断;若业务后续允许「确认中算占用」,再单独开变更。
| 前置条件 | 处理 |
|---|---|
无明细或全部 dfj |
允许。作废全部现有明细;按分期参数生成 N 条新 dfj 明细;主表 pay_mode=fq。 |
存在 jfqrz / yjf / tfqrz / ytf |
禁止。提示原因。 |
切换后:第 i 期 installmentNo=i,应缴日按 calcFqInstallmentDueDate,应缴金额按合同均分(末期待尾差)。
| 前置条件 | 处理 |
|---|---|
全部 dfj |
允许。作废全部 N 条分期明细;新建 1 条趸交明细:dueAmount=contractAmount,installmentNo=null,应缴日=lumpSumDeadline。 |
存在任何非 dfj |
走 4.3 |
| 前置条件 | 处理 |
|---|---|
有 yjf,无 jfqrz/tfqrz/ytf |
允许。 |
存在 jfqrz/tfqrz/ytf |
禁止。 |
允许时步骤:
yjf 明细(只读展示,保留原 installmentNo、转账信息)。dfj 分期明细(不进入表单列表;保存时 del_flag=1)。dueAmount=remaining,payStatus=dfj,installmentNo=null,应缴日=lumpSumDeadline。pay_mode=dj;分期参数字段可保留只读快照(便于查看历史计划)。若 remaining ≤ 0:禁止切换,提示「已缴清,无需改趸交」。
禁止。提示「趸交已结清,无法改为分期」。
用户点击「分期 / 趸交」radio 时:
evaluatePayModeSwitch(currentMode, targetMode, details, contractAmount)。blocked → ElMessageBox.alert 说明原因,回滚 radio。allowed → ElMessageBox.confirm 展示影响摘要,确认后执行变换。确认文案示例(fq→dj,部分已缴):
将作废 4 期未缴计划,保留 2 期已缴记录,并新增 1 条趸交明细(剩余 ¥X,XXX.XX)。是否继续?
resetCountInterval;优先保留主表已保存的期数/间隔/开始时间。interface PaymentDetailRow {
// ...existing fields
_payModeLocked?: boolean // yjf 等锁定行
_voided?: boolean // 前端待作废,保存时不提交或提交 void 标记
}
保存 payload:仅提交未作废明细 + 锁定保留行;作废行由后端按 id 软删(P1)或前端不提交、后端 diff(P2)。
在 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。del_flag=1,分期预警自然排除。detail.id upsert,避免每次编辑换 UUID。POST .../payment/change-mode建议新增 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 行重算金额/日期。| # | 场景 | 期望 |
|---|---|---|
| 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 回滚,明细不变 |
protocol-pay-mode-switch.ts + 单元测试(Node/vitest 或 backend pytest 镜像逻辑)onPayModeChange + 确认弹窗tryGenerateInstallmentDetails / syncPaymentDueAmounts 跳过锁定行编辑已保存的分期协议时,用户可能修改「分期期数 / 每期间隔 / 分期开始时间」,规则如下:
| 情况 | 处理 |
|---|---|
全部 dfj |
确认后按新参数整表重建 N 条明细 |
部分 yjf |
保留已缴行;作废全部 dfj;按 剩余金额 均分生成 (新期数 − 已缴期数) 条未缴明细;应缴日按已缴期数续算 index |
| 期数小于已缴最大期次 | 禁止 |
含 jfqrz/tfqrz/ytf |
禁止 |
| 仅改间隔/开始时间(期数不变) | 确认后保留 yjf,重建未缴行(金额+日期) |
实现:web-admin/src/utils/protocol-fq-params-change.ts,表单 @change → handleFqParamsChange()。
审阅:请确认本 spec 无遗漏后,进入 writing-plans 生成实现任务清单。