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

22 KiB
Raw Blame History

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

-- 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:

installment_advance_days = Column(Integer, nullable=True, default=30, comment="第2期起应缴费提前天数")
installment_ratios = Column(String(256), nullable=True, comment="各期缴费比例JSON数组")
  • Step 4: 本地执行迁移(开发环境)
# 按项目惯例连接 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 签名

# 删除 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 可配置用例

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: 运行测试
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: 新增纯函数

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: 编写单测
# 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: 运行测试
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 增加字段

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 支持新字段
_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 映射:

# 在 _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
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:

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: 字段一致性检查
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: 常量与默认比例

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
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)
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

export interface FqParams {
  installmentCount: number | null
  installmentIntervalMonths: number | null
  installmentStartDate: string
  installmentAdvanceDays: number | null
  installmentRatios: number[]
}

更新 fqParamsSnapshot、isSameFqParams(比较 advanceDays 与 ratios 数组)。

  • Step 2: generateInitialFqDetails / applyFqParamsChange
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 增加字段

installmentAdvanceDays: 30 as number | null,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6] as number[],

resetForm / 加载编辑数据时:

  • 新字段从 API 读取

  • 若 installmentRatios 为空,调用 defaultInstallmentRatios(installmentCount)

  • Step 2: 模板 — 分期区块新增控件

在 installmentStartDate 表单项后:

<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: 脚本逻辑
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
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 增加字段
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)
.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 区块增加两项

<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>
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: 更新知识图谱
./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?