Files
zkxw/docs/superpowers/plans/2026-06-10-protocol-installment-config.md
T
2026-06-11 15:42:58 +08:00

686 lines
22 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.
# 分期缴费可配置化 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:** 采集协议单分期模式下,支持可配置「应缴费提前天数(默认 30)」与「每期缴费比例(默认 50/10/10/10/10/10,合计 100%,末位自动补齐)」,并持久化到后端。
**Architecture:** 主表 `biz_protocol_payment` 新增 `installment_advance_days`、`installment_ratios`;前后端共用计算纯函数(提前天数参数化、比例分配金额);扩展既有 `FqParams` / `handleFqParamsChange` 联动链,不改动趸交与明细状态机。
**Tech Stack:** MySQL 5.7 · FastAPI/SQLAlchemy · Vue 3/Element Plus · TypeScript
**Design spec:** `docs/superpowers/specs/2026-06-10-protocol-installment-config-design.md`
---
## File Map
| 文件 | 职责 |
|------|------|
| `docs/sql/migrations/2026-06-10-protocol-installment-config.sql` | DDL 迁移 |
| `docs/sql/zkxw.sql` | 全量 schema 同步 |
| `backend/app/models/models.py` | `BizProtocolPayment` 新列 |
| `backend/app/services/protocol_payment_installment.py` | 应缴日计算(advance_days 参数) |
| `backend/app/services/protocol_pay_mode_switch.py` | 保存校验(比例和=100) |
| `backend/app/crud/collection_protocol.py` | `_payment_to_dict`、upsert、align 用比例 |
| `backend/tests/test_protocol_payment_installment.py` | 应缴日单测 |
| `backend/tests/test_protocol_installment_ratios.py` | 比例金额单测(新建) |
| `web-admin/src/utils/protocol-payment-installment.ts` | 比例/提前天数纯函数 |
| `web-admin/src/utils/protocol-fq-params-change.ts` | 扩展 FqParams |
| `web-admin/src/utils/protocol-pay-mode-switch.ts` | 切换分期时用新参数 |
| `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` | 表单 UI + 联动 |
| `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | 只读展示 |
| `docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md` | 更新「提前 10 天」决策 |
---
### Task 1: 数据库迁移与 Model
**Files:**
- Create: `docs/sql/migrations/2026-06-10-protocol-installment-config.sql`
- Modify: `docs/sql/zkxw.sql`(`biz_protocol_payment` 表定义)
- Modify: `backend/app/models/models.py`(`BizProtocolPayment` 类)
- [ ] **Step 1: 编写迁移 SQL**
```sql
-- docs/sql/migrations/2026-06-10-protocol-installment-config.sql
ALTER TABLE `biz_protocol_payment`
ADD COLUMN `installment_advance_days` INT NULL DEFAULT 30 COMMENT '第2期起应缴费提前天数' AFTER `installment_start_date`,
ADD COLUMN `installment_ratios` VARCHAR(256) NULL COMMENT '各期缴费比例JSON数组' AFTER `installment_advance_days`;
```
- [ ] **Step 2: 同步 zkxw.sql 中 `biz_protocol_payment` CREATE TABLE**
在 `installment_start_date` 后插入上述两列。
- [ ] **Step 3: 更新 Model**
`backend/app/models/models.py` → `BizProtocolPayment`:
```python
installment_advance_days = Column(Integer, nullable=True, default=30, comment="第2期起应缴费提前天数")
installment_ratios = Column(String(256), nullable=True, comment="各期缴费比例JSON数组")
```
- [ ] **Step 4: 本地执行迁移(开发环境)**
```bash
# 按项目惯例连接 MySQL 后执行迁移文件
mysql -u ... -p ... zkxw < docs/sql/migrations/2026-06-10-protocol-installment-config.sql
```
Expected: `ALTER TABLE` 成功,无报错。
---
### Task 2: 后端应缴日计算参数化 + 单测
**Files:**
- Modify: `backend/app/services/protocol_payment_installment.py`
- Modify: `backend/tests/test_protocol_payment_installment.py`
- [ ] **Step 1: 修改 `calc_fq_installment_due_date` 签名**
```python
# 删除 FQ_ADVANCE_DAYS = 10
def calc_fq_installment_due_date(
start_date: str,
index: int,
interval_months: Number,
advance_days: int = 30,
) -> str:
"""第 1 期=开始时间;第 2 期起在间隔递增基础上提前 advance_days 天。"""
total_months = Decimal(str(index)) * Decimal(str(interval_months))
base = add_interval_months_yyyymmdd(start_date, total_months)
if index >= 1:
return add_days_yyyymmdd(base, -int(advance_days or 0))
return base
```
- [ ] **Step 2: 更新现有单测期望值(10→30)**
`test_fractional_interval_0_1_advance_10_days` 重命名为 `test_fractional_interval_0_1_advance_30_days`:
- 原:`20260604` index=1 interval=0.1 → `20260528`(提前 10 天)
- 新:默认 advance_days=30 → `20260518`(+3 天 = 20260607,再 −30 天)
`test_integer_interval_six_months`:
- 原:`20261124`(−10)
- 新:`20261104`(−30)
`test_fractional_interval_0_3` index=2:
- 原:`20260612`(−10)
- 新:`20260522`(−30)
- [ ] **Step 3: 新增 advance_days 可配置用例**
```python
def test_custom_advance_days_15(self):
self.assertEqual(
calc_fq_installment_due_date("20260604", 1, 6, advance_days=15),
"20261119", # 20261204 - 15
)
```
- [ ] **Step 4: 运行测试**
```bash
cd backend && source venv/bin/activate && python -m unittest tests.test_protocol_payment_installment -v
```
Expected: 全部 PASS。
---
### Task 3: 后端比例金额计算 + 单测
**Files:**
- Modify: `backend/app/services/protocol_payment_installment.py`
- Create: `backend/tests/test_protocol_installment_ratios.py`
- [ ] **Step 1: 新增纯函数**
```python
import json
from typing import List, Optional, Sequence
def parse_installment_ratios(raw: Optional[str]) -> List[float]:
if not raw:
return []
try:
data = json.loads(raw)
return [float(x) for x in data] if isinstance(data, list) else []
except (json.JSONDecodeError, TypeError, ValueError):
return []
def calc_installment_due_amounts_by_ratios(
contract_amount: Optional[Decimal],
ratios: Sequence[Number],
) -> List[Decimal]:
"""全额分配:前 n-1 期 floor 到分,末位补尾差。"""
if contract_amount is None or not ratios:
return []
count = len(ratios)
total_cents = int(round(float(contract_amount) * 100))
amounts: List[Decimal] = []
sum_cents = 0
for i in range(count):
if i == count - 1:
cents = total_cents - sum_cents
else:
cents = int(total_cents * float(ratios[i]) / 100.0)
sum_cents += cents
amounts.append(Decimal(cents) / Decimal(100))
return amounts
def calc_unpaid_amounts_by_ratios(
remaining: Decimal,
ratios: Sequence[Number],
unpaid_indices: Sequence[int],
) -> List[Decimal]:
"""部分已缴:按未缴期次比例归一化分配 remaining。"""
if remaining <= 0 or not unpaid_indices:
return []
subset = [float(ratios[i]) for i in unpaid_indices]
ratio_sum = sum(subset)
if ratio_sum <= 0:
return []
total_cents = int(round(float(remaining) * 100))
amounts: List[Decimal] = []
sum_cents = 0
for j, idx in enumerate(unpaid_indices):
if j == len(unpaid_indices) - 1:
cents = total_cents - sum_cents
else:
cents = int(total_cents * subset[j] / ratio_sum)
sum_cents += cents
amounts.append(Decimal(cents) / Decimal(100))
return amounts
```
- [ ] **Step 2: 编写单测**
```python
# backend/tests/test_protocol_installment_ratios.py
import unittest
from decimal import Decimal
from app.services.protocol_payment_installment import (
calc_installment_due_amounts_by_ratios,
calc_unpaid_amounts_by_ratios,
)
class TestInstallmentRatios(unittest.TestCase):
def test_default_50_10_x6(self):
ratios = [50, 10, 10, 10, 10, 10]
amounts = calc_installment_due_amounts_by_ratios(Decimal("10000"), ratios)
self.assertEqual([float(a) for a in amounts], [5000, 1000, 1000, 1000, 1000, 1000])
def test_partial_paid_normalize(self):
remaining = Decimal("5000")
ratios = [50, 10, 10, 10, 10, 10]
unpaid_indices = [1, 2, 3, 4, 5] # 第2-6期
amounts = calc_unpaid_amounts_by_ratios(remaining, ratios, unpaid_indices)
self.assertEqual(len(amounts), 5)
self.assertAlmostEqual(sum(float(a) for a in amounts), 5000.0, places=2)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 3: 运行测试**
```bash
cd backend && python -m unittest tests.test_protocol_installment_ratios -v
```
Expected: PASS。
---
### Task 4: 后端 CRUD、校验与 align 逻辑
**Files:**
- Modify: `backend/app/crud/collection_protocol.py`
- Modify: `backend/app/services/protocol_pay_mode_switch.py`
- [ ] **Step 1: `_payment_to_dict` 增加字段**
```python
import json
def _payment_to_dict(p: BizProtocolPayment) -> Dict[str, Any]:
ratios_raw = (p.installment_ratios or "").strip()
ratios_list: List[float] = []
if ratios_raw:
try:
ratios_list = [float(x) for x in json.loads(ratios_raw)]
except (json.JSONDecodeError, TypeError, ValueError):
ratios_list = []
return {
# ...existing...
"installmentAdvanceDays": p.installment_advance_days if p.installment_advance_days is not None else 30,
"installmentRatios": ratios_list,
}
```
- [ ] **Step 2: `upsert_payment` 支持新字段**
```python
_INT_FIELDS = ["installment_count", "installment_advance_days"]
def _apply(target: BizProtocolPayment) -> None:
# ...existing...
if "installment_ratios" in fields:
val = fields["installment_ratios"]
if val is None:
target.installment_ratios = None
elif isinstance(val, str):
target.installment_ratios = val.strip() or None
else:
target.installment_ratios = json.dumps(list(val), ensure_ascii=False)
```
在 `_normalize_fields` 调用链中确保 camelCase 映射:
```python
# 在 _normalize_fields 或 API 层已有 camel→snake;若无,补充:
# installmentAdvanceDays → installment_advance_days
# installmentRatios → installment_ratios (list → json.dumps 在 upsert 前处理)
```
检查 `backend/app/crud/collection_protocol.py` 中 `_normalize_fields` 是否自动 snake_case;若前端传 camelCase,需在 bundle 保存前转换(参照现有 `installmentCount` 处理方式)。
- [ ] **Step 3: `align_fq_payment_details_payload` 使用比例与 advance_days**
```python
from app.services.protocol_payment_installment import (
calc_fq_installment_due_date,
calc_installment_due_amounts_by_ratios,
parse_installment_ratios,
)
# 替换 amounts = _calc_installment_due_amounts(...)
ratios = parse_installment_ratios(pay.installment_ratios)
if ratios and len(ratios) == count:
amounts = calc_installment_due_amounts_by_ratios(pay.contract_amount, ratios)
else:
amounts = _calc_installment_due_amounts(pay.contract_amount, count)
advance_days = int(pay.installment_advance_days or 30)
# calc_fq_installment_due_date(start, i, interval_val, advance_days)
```
- [ ] **Step 4: `validate_payment_details_for_save` 增加比例校验**
`backend/app/services/protocol_pay_mode_switch.py`:
```python
def validate_payment_details_for_save(
pay_mode: str,
contract_amount,
installment_count: Optional[int],
details: list,
installment_ratios: Optional[list] = None,
installment_advance_days: Optional[int] = None,
) -> None:
# ...existing fq block...
if installment_advance_days is not None and int(installment_advance_days) < 0:
raise ValueError("应缴费提前天数不能为负数")
if installment_ratios is not None:
ratios = [float(x) for x in installment_ratios]
count = int(installment_count or 0)
if len(ratios) != count:
raise ValueError("缴费比例数量须与分期期数一致")
if abs(sum(ratios) - 100.0) > 0.01:
raise ValueError("缴费比例之和须等于 100%")
```
更新 `save_payment_details` 调用处传入 `pay.installment_ratios`(parse 后)与 `pay.installment_advance_days`。
- [ ] **Step 5: 字段一致性检查**
```bash
cd backend && python scripts/check_field_consistency.py
```
Expected: 无新增不一致(若脚本报错,按 `docs/dev-field-consistency.md` 补全 schema)。
---
### Task 5: 前端计算纯函数
**Files:**
- Modify: `web-admin/src/utils/protocol-payment-installment.ts`
- [ ] **Step 1: 常量与默认比例**
```typescript
export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
export const DEFAULT_INSTALLMENT_RATIOS_6 = [50, 10, 10, 10, 10, 10]
export function defaultInstallmentRatios(count: number): number[] {
if (count <= 0) return []
if (count === 6) return [...DEFAULT_INSTALLMENT_RATIOS_6]
const base = Math.floor(100 / count)
const arr = Array(count - 1).fill(base)
arr.push(100 - base * (count - 1))
return arr
}
```
- [ ] **Step 2: `resizeInstallmentRatios` + `finalizeInstallmentRatios`**
```typescript
export function resizeInstallmentRatios(oldRatios: number[], newCount: number): number[] {
if (newCount <= 0) return []
if (newCount > oldRatios.length) {
return [...oldRatios, ...Array(newCount - oldRatios.length).fill(0)]
}
if (newCount < oldRatios.length) {
return oldRatios.slice(0, newCount)
}
return [...oldRatios]
}
/** 末位 = 100 - sum(前 n-1);返回新数组 */
export function finalizeInstallmentRatios(ratios: number[]): number[] {
if (!ratios.length) return []
if (ratios.length === 1) return [100]
const head = ratios.slice(0, -1)
const last = Math.round((100 - head.reduce((s, v) => s + Number(v || 0), 0)) * 100) / 100
return [...head, last]
}
export function isInstallmentRatiosValid(ratios: number[]): boolean {
if (!ratios.length) return false
const head = ratios.slice(0, -1)
const headSum = head.reduce((s, v) => s + Number(v || 0), 0)
return headSum <= 100 + 0.001 && Math.abs(ratios.reduce((s, v) => s + Number(v || 0), 0) - 100) < 0.01
}
```
- [ ] **Step 3: 修改 `calcFqInstallmentDueDate` 增加 `advanceDays` 参数(默认 30)**
```typescript
export function calcFqInstallmentDueDate(
startDate: string,
index: number,
intervalMonths: number,
advanceDays: number = DEFAULT_INSTALLMENT_ADVANCE_DAYS
): string {
const base = addIntervalMonthsYmd(startDate, index * intervalMonths)
if (index >= 1) {
return addDaysYmd(base, -advanceDays)
}
return base
}
```
- [ ] **Step 4: 新增 `calcInstallmentDueAmountsByRatios` 与 `calcUnpaidAmountsByRatios`**
镜像 Task 3 后端逻辑(§4.2 / §4.3)。
- [ ] **Step 5: 更新 `alignFqPaymentDetailsForDisplay` / `syncFqPaymentDetailRowCount`**
`payment` 类型扩展 `installmentAdvanceDays`、`installmentRatios`;金额用比例函数;日期传 `advanceDays`。
---
### Task 6: 扩展分期参数变更与模式切换
**Files:**
- Modify: `web-admin/src/utils/protocol-fq-params-change.ts`
- Modify: `web-admin/src/utils/protocol-pay-mode-switch.ts`
- [ ] **Step 1: 扩展 `FqParams`**
```typescript
export interface FqParams {
installmentCount: number | null
installmentIntervalMonths: number | null
installmentStartDate: string
installmentAdvanceDays: number | null
installmentRatios: number[]
}
```
更新 `fqParamsSnapshot`、`isSameFqParams`(比较 advanceDays 与 ratios 数组)。
- [ ] **Step 2: `generateInitialFqDetails` / `applyFqParamsChange`**
```typescript
const advanceDays = Number(params.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
const ratios = finalizeInstallmentRatios(params.installmentRatios || [])
const amounts = calcInstallmentDueAmountsByRatios(contractAmount, ratios)
// due date:
calcFqInstallmentDueDate(startDate, i, interval, advanceDays)
```
部分已缴分支:用 `calcUnpaidAmountsByRatios(remaining, ratios, unpaidIndices)`。
- [ ] **Step 3: `protocol-pay-mode-switch.ts` dj→fq 分支**
传入 `installmentAdvanceDays`、`installmentRatios`;生成明细时用新函数。
---
### Task 7: ProtocolFormDialog UI 与联动
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`
- [ ] **Step 1: formData 增加字段**
```typescript
installmentAdvanceDays: 30 as number | null,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6] as number[],
```
`resetForm` / 加载编辑数据时:
- 新字段从 API 读取
- 若 `installmentRatios` 为空,调用 `defaultInstallmentRatios(installmentCount)`
- [ ] **Step 2: 模板 — 分期区块新增控件**
在 `installmentStartDate` 表单项后:
```vue
<el-form-item label="应缴费提前(天)">
<el-input
v-model="formData.installmentAdvanceDays"
type="number"
:min="0"
:max="365"
step="1"
style="width: 100%"
@change="handleFqParamsChange"
/>
</el-form-item>
<el-form-item label="缴费比例(%)" class="form-col-span-2">
<div class="installment-ratios-row">
<div v-for="(ratio, rIdx) in formData.installmentRatios" :key="rIdx" class="installment-ratio-item">
<span class="installment-ratio-label">
第{{ rIdx + 1 }}期
<span v-if="rIdx === formData.installmentRatios.length - 1" class="form-label-tip">自动补齐</span>
</span>
<el-input
v-model="formData.installmentRatios[rIdx]"
type="number"
:min="0"
:max="100"
step="any"
:disabled="rIdx === formData.installmentRatios.length - 1"
style="width: 72px"
@change="onInstallmentRatioChange(rIdx)"
/>
</div>
</div>
<div v-if="installmentRatiosError" class="form-tip form-tip--error">{{ installmentRatiosError }}</div>
</el-form-item>
```
- [ ] **Step 3: 脚本逻辑**
```typescript
function onInstallmentCountChange() {
const count = toIntOrUndefined(formData.installmentCount) || 0
formData.installmentRatios = finalizeInstallmentRatios(
resizeInstallmentRatios(formData.installmentRatios, count)
)
handleFqParamsChange()
}
function onInstallmentRatioChange(changedIdx: number) {
if (changedIdx === formData.installmentRatios.length - 1) return
formData.installmentRatios = finalizeInstallmentRatios([...formData.installmentRatios])
syncPaymentDueAmounts()
}
const installmentRatiosError = computed(() => {
const head = formData.installmentRatios.slice(0, -1)
const sum = head.reduce((s, v) => s + Number(v || 0), 0)
if (sum > 100) return '前 N−1 期比例之和不能超过 100%'
return ''
})
```
- [ ] **Step 4: 更新 `applyFqPaymentDefaults`**
```typescript
if (reset || formData.installmentAdvanceDays == null) {
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
}
if (reset || !formData.installmentRatios?.length) {
formData.installmentRatios = defaultInstallmentRatios(formData.installmentCount || 6)
}
```
- [ ] **Step 5: 更新 `syncPaymentDueAmounts`**
分期分支改用 `calcInstallmentDueAmountsByRatios`;有已缴时用归一化逻辑。
- [ ] **Step 6: 保存 payload 增加字段**
```typescript
payment: {
// ...existing...
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
installmentRatios: formData.payMode === 'fq' ? formData.installmentRatios : undefined
}
```
- [ ] **Step 7: 保存前校验**
在 submit 前:若 `payMode==='fq'` 且 `installmentRatiosError` 或 `!isInstallmentRatiosValid(...)` → `ElMessageBox.alert` 阻断。
- [ ] **Step 8: SCSS(scoped 或 app.scss)**
```scss
.installment-ratios-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.installment-ratio-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.installment-ratio-label {
font-size: 11px;
color: var(--mint-text-muted);
}
```
---
### Task 8: ProtocolViewDialog 只读展示
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue`
- [ ] **Step 1: 分期 metric 区块增加两项**
```vue
<div class="payment-metric">
<div class="pm-label">应缴费提前(天)</div>
<div class="pm-value">{{ dash(payment?.installmentAdvanceDays ?? 30) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">缴费比例</div>
<div class="pm-value">{{ formatInstallmentRatios(payment?.installmentRatios) }}</div>
</div>
```
```typescript
function formatInstallmentRatios(ratios?: number[] | null): string {
if (!ratios?.length) return '—'
return ratios.map((r) => `${r}%`).join(' / ')
}
```
---
### Task 9: 文档更新与图谱
**Files:**
- Modify: `docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md`(§2 决策表:10 天 → 30 天可配置)
- Modify: `docs/superpowers/specs/2026-06-10-protocol-installment-config-design.md`(状态 → 已实现)
- [ ] **Step 1: 更新 2026-06-04 spec 决策行**
| 第 2 期起提前天数 | 可配置,默认 30 天(原固定 10 天,见 2026-06-10 spec) |
- [ ] **Step 2: 更新知识图谱**
```bash
./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
```
---
### Task 10: 联调验证(手工)
- [ ] **T1** 新增协议 → 分期 → 默认 30 天、50/10/10/10/10/10,保存成功
- [ ] **T2** 改第 1 期为 60 → 末位自动 0,金额重算
- [ ] **T3** 前 5 期各 25 → 保存阻断
- [ ] **T4** 6→3 期 → `[50,10,40]`
- [ ] **T5** 3→6 期 → `[50,10,0,0,0,40]`
- [ ] **T6** 提前天数 30→15 → 确认后应缴日变化
- [ ] **T7** 2 期 yjf 后改比例 → yjf 不变
- [ ] **T8** 编辑旧数据(无新字段)→ 默认 30 + 均分比例
- [ ] **T9** 查看弹窗展示新字段
- [ ] **T10** 后端单测全部 PASS
---
## Spec Coverage Self-Review
| Spec § | Task |
|--------|------|
| §3 数据模型 | Task 1, 4 |
| §4.1 应缴日 | Task 2, 5 |
| §4.2/4.3 金额 | Task 3, 5, 6 |
| §4.4 期数联动 | Task 5, 7 |
| §5 UI | Task 7, 8 |
| §6 联动 | Task 6, 7 |
| §7 后端校验 | Task 4 |
| §9 测试 T1–T10 | Task 2, 3, 10 |
| §10 不在范围 | 无任务(符合) |
无 TBD / 占位符。
---
**Plan complete and saved to `docs/superpowers/plans/2026-06-10-protocol-installment-config.md`.**
**Two execution options:**
1. **Subagent-Driven (recommended)** — 每个 Task 派发独立 subagent,任务间 review,迭代快
2. **Inline Execution** — 本会话按 Task 顺序直接实现,checkpoint 处暂停确认
**Which approach?**