# 分期缴费可配置化 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