Files
zkxw/docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md
T
2026-06-11 15:42:58 +08:00

228 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采集协议单 · 缴费模式切换设计
**日期**: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` 生成实现任务清单。