# 分期缴费可配置化 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
第{{ rIdx + 1 }}期 自动补齐
{{ installmentRatiosError }}
``` - [ ] **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
应缴费提前(天)
{{ dash(payment?.installmentAdvanceDays ?? 30) }}
缴费比例
{{ formatInstallmentRatios(payment?.installmentRatios) }}
``` ```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?**