diff --git a/docs/superpowers/plans/2026-10-13-protocol-payment-last-period-autofill.md b/docs/superpowers/plans/2026-10-13-protocol-payment-last-period-autofill.md new file mode 100644 index 00000000..c0b7e2ed --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-protocol-payment-last-period-autofill.md @@ -0,0 +1,470 @@ +# 采集协议单 · 分期尾期金额与趸交应缴日自动填入 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:** 在采集协议单 `SamplePaymentPanel` 中,分期时自动补齐最后一期应缴费金额(可手改覆盖),趸交时把趸交截止时间同步到最后一期应缴费时间。 + +**Architecture:** 在 `protocol-sample-payment.ts` 增加可单测纯函数;`SamplePaymentPanel.vue` 在 `patchSchedule` / 截止时间变更时调用。覆盖标记仅组件内存维护,不落库。范围仅 v2 按样本缴费;分改趸不做截止日联动。 + +**Tech Stack:** Vue 3、TypeScript、`node --import tsx --test`(与现有 `protocol-sample-payment.test.ts` 一致) + +**Spec:** `docs/superpowers/specs/2026-10-13-protocol-payment-last-period-autofill-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `web-admin/src/utils/protocol-sample-payment.ts` | 新增纯函数:尾期金额计算、是否自动填、趸交截止日写入应缴日 | +| `web-admin/src/utils/protocol-sample-payment.test.ts` | 单测上述纯函数 | +| `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue` | 接入联动与覆盖标记 | + +--- + +### Task 1: 纯函数 + 失败单测(尾期金额 / 是否自动填) + +**Files:** +- Modify: `web-admin/src/utils/protocol-sample-payment.test.ts` +- Modify: `web-admin/src/utils/protocol-sample-payment.ts`(本 Task 末尾实现) + +- [ ] **Step 1: 在测试文件追加用例(先不实现函数,确认失败)** + +在 `protocol-sample-payment.test.ts` 的 import 列表增加: + +```ts + calcLastPeriodDueAmount, + shouldAutoFillLastPeriodDue, + applyLumpSumDeadlineToLastDueDate +``` + +在文件末尾追加: + +```ts +describe('calcLastPeriodDueAmount', () => { + it('last = contract − sum(priors), cents remainder on last', () => { + assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), 33.34) + assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000) + }) + it('returns null when contract invalid', () => { + assert.equal(calcLastPeriodDueAmount(null, [1000]), null) + assert.equal(calcLastPeriodDueAmount(NaN, [1000]), null) + }) + it('returns null when all priors empty', () => { + assert.equal(calcLastPeriodDueAmount(20000, [null, '', undefined]), null) + }) + it('treats empty slot as 0 when at least one prior filled', () => { + assert.equal(calcLastPeriodDueAmount(20000, [10000, null, '']), 10000) + }) + it('clamps negative result to 0', () => { + assert.equal(calcLastPeriodDueAmount(1000, [800, 800]), 0) + }) +}) + +describe('shouldAutoFillLastPeriodDue', () => { + it('blocks when override or locked', () => { + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: true, lastLocked: false }), + false + ) + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: true }), + false + ) + }) + it('allows when neither override nor locked', () => { + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: false }), + true + ) + }) +}) +``` + +- [ ] **Step 2: 跑测确认失败** + +Run: + +```bash +cd web-admin && node --import tsx --test src/utils/protocol-sample-payment.test.ts +``` + +Expected: FAIL(`calcLastPeriodDueAmount` / `shouldAutoFillLastPeriodDue` not exported) + +- [ ] **Step 3: 实现两个纯函数** + +在 `protocol-sample-payment.ts` 末尾追加: + +```ts +/** 分期:最后一期应缴 = 合同 − 前 N−1 期;无法计算返回 null;负值钳为 0 */ +export function calcLastPeriodDueAmount( + contractAmount: number | null | undefined, + priorAmounts: Array +): number | null { + const contract = Number(contractAmount) + if (!Number.isFinite(contract)) return null + if (!priorAmounts.length) return null + let anyFilled = false + let sumCents = 0 + for (const raw of priorAmounts) { + if (raw === null || raw === undefined || raw === '') continue + const n = Number(raw) + if (!Number.isFinite(n)) continue + anyFilled = true + sumCents += Math.round(n * 100) + } + if (!anyFilled) return null + const last = Math.round(contract * 100 - sumCents) / 100 + return last < 0 ? 0 : last +} + +export function shouldAutoFillLastPeriodDue(opts: { + lastManualOverride: boolean + lastLocked: boolean +}): boolean { + return !opts.lastManualOverride && !opts.lastLocked +} +``` + +- [ ] **Step 4: 跑测确认通过** + +Run: + +```bash +cd web-admin && node --import tsx --test src/utils/protocol-sample-payment.test.ts +``` + +Expected: PASS(含新建 describe) + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/utils/protocol-sample-payment.ts web-admin/src/utils/protocol-sample-payment.test.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add last-period due amount calc helpers + +Pure functions for fq auto-fill of the final installment amount +with override/lock gates, covered by unit tests. +EOF +)" +``` + +--- + +### Task 2: 纯函数 + 单测(趸交截止日 → 应缴日) + +**Files:** +- Modify: `web-admin/src/utils/protocol-sample-payment.ts` +- Modify: `web-admin/src/utils/protocol-sample-payment.test.ts` + +- [ ] **Step 1: 追加失败用例** + +在同一测试文件末尾追加: + +```ts +describe('applyLumpSumDeadlineToLastDueDate', () => { + it('writes deadline to last unlocked schedule', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [ + { scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }, + { scheduleNo: 2, dueDate: '20260101', scheduleStatus: 'open' } + ], + '20261231', + (s) => false + ) + assert.equal(next[0].dueDate, '') + assert.equal(next[1].dueDate, '20261231') + }) + it('clears dueDate when deadline empty', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [{ scheduleNo: 1, dueDate: '20261231', scheduleStatus: 'open' }], + '', + () => false + ) + assert.equal(next[0].dueDate, '') + }) + it('skips when last schedule locked', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [{ scheduleNo: 1, dueDate: '20260101', scheduleStatus: 'open' }], + '20261231', + () => true + ) + assert.equal(next[0].dueDate, '20260101') + }) + it('ignores void schedules when picking last', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [ + { scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }, + { scheduleNo: 2, dueDate: 'old', scheduleStatus: 'void' } + ], + '20261231', + () => false + ) + assert.equal(next[0].dueDate, '20261231') + assert.equal(next[1].dueDate, 'old') + }) +}) +``` + +- [ ] **Step 2: 跑测确认失败** + +Run: + +```bash +cd web-admin && node --import tsx --test src/utils/protocol-sample-payment.test.ts +``` + +Expected: FAIL(`applyLumpSumDeadlineToLastDueDate` not exported) + +- [ ] **Step 3: 实现函数** + +在 `protocol-sample-payment.ts` 追加: + +```ts +/** 趸交:将截止时间写入未作废批次中的最后一期 dueDate;锁定则跳过 */ +export function applyLumpSumDeadlineToLastDueDate( + schedules: T[], + deadline: string | null | undefined, + isLocked: (sch: T) => boolean +): T[] { + const due = String(deadline || '').trim() + const activeIdx: number[] = [] + schedules.forEach((s, i) => { + if (String(s.scheduleStatus || 'open') !== 'void') activeIdx.push(i) + }) + if (!activeIdx.length) return schedules.map((s) => ({ ...s })) + const lastIdx = activeIdx[activeIdx.length - 1] + if (isLocked(schedules[lastIdx])) { + return schedules.map((s) => ({ ...s })) + } + return schedules.map((s, i) => (i === lastIdx ? { ...s, dueDate: due } : { ...s })) +} +``` + +- [ ] **Step 4: 跑测确认通过** + +Run: + +```bash +cd web-admin && node --import tsx --test src/utils/protocol-sample-payment.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/utils/protocol-sample-payment.ts web-admin/src/utils/protocol-sample-payment.test.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): sync lump-sum deadline to last due date + +Add applyLumpSumDeadlineToLastDueDate for dj payment schedules, +skipping locked or void rows. +EOF +)" +``` + +--- + +### Task 3: SamplePaymentPanel 接入分期尾期自动填 + +**Files:** +- Modify: `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue` + +- [ ] **Step 1: 增加 import 与覆盖标记** + +在现有 `@/utils/protocol-sample-payment` import 中增加: + +```ts + calcLastPeriodDueAmount, + shouldAutoFillLastPeriodDue, + applyLumpSumDeadlineToLastDueDate +``` + +在 `script setup` 中(`props` / `emit` 附近)增加: + +```ts + /** 按样本 key:用户手改过最后一期应缴金额则 true,前几期再变时清除 */ + const lastPeriodDueOverrideByKey = ref>({}) + + function samplePayKey(pay: Record, pIdx: number): string { + return String(pay.sampleCode || pay.protocolSampleId || pIdx) + } + + function activeScheduleIndices(schedules: Record[]): number[] { + const idx: number[] = [] + schedules.forEach((s, i) => { + if (String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) idx.push(i) + }) + return idx + } +``` + +- [ ] **Step 2: 改写 `patchSchedule`** + +用下列实现替换现有 `patchSchedule`: + +```ts + function patchSchedule(pIdx: number, sIdx: number, patch: Record) { + const list = [...(props.modelValue || [])] + const pay = { ...list[pIdx] } + const schedules = [...(pay.schedules || [])] + schedules[sIdx] = { ...schedules[sIdx], ...patch } + const mode = String(pay.payMode || '') + const key = samplePayKey(pay, pIdx) + const activeIdx = activeScheduleIndices(schedules) + + if ('dueAmount' in patch && mode === 'fq' && activeIdx.length >= 2) { + const lastIdx = activeIdx[activeIdx.length - 1] + if (sIdx === lastIdx) { + lastPeriodDueOverrideByKey.value = { + ...lastPeriodDueOverrideByKey.value, + [key]: true + } + } else if (activeIdx.slice(0, -1).includes(sIdx)) { + lastPeriodDueOverrideByKey.value = { + ...lastPeriodDueOverrideByKey.value, + [key]: false + } + const lastSch = schedules[lastIdx] + const canFill = shouldAutoFillLastPeriodDue({ + lastManualOverride: false, + lastLocked: isScheduleDueLocked(lastSch) + }) + if (canFill) { + const priors = activeIdx.slice(0, -1).map((i) => schedules[i]?.dueAmount) + const lastAmt = calcLastPeriodDueAmount(pay.contractAmount, priors) + if (lastAmt != null) { + schedules[lastIdx] = { ...schedules[lastIdx], dueAmount: lastAmt } + } + } + } + } + + pay.schedules = schedules + if ('dueAmount' in patch) { + pay.installmentRatios = schedules.map((s: Record) => { + const n = Number(s.dueAmount) + return Number.isFinite(n) && n > 0 ? n : 0 + }) + } + list[pIdx] = pay + emit('update:modelValue', list) + } +``` + +- [ ] **Step 3: 手动冒烟清单(本 Task 只验分期)** + +在浏览器打开 `http://localhost:3006/#/market/cell-storage/protocol-audit`: + +1. 新增协议 → 样本缴费选「分期」、合同 20000、6 期。 +2. 第 1 期填 10000 → 最后一期应变为 `10000`(其余空当 0)。 +3. 手改最后一期为 8000 → 保持 8000。 +4. 再改第 1 期为 9000 → 最后一期自动变为 `11000`。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): auto-fill fq last-period due amount in SamplePaymentPanel + +When earlier installment amounts change, recompute the final period +unless the user overrode it or the period is locked. +EOF +)" +``` + +--- + +### Task 4: SamplePaymentPanel 接入趸交截止时间同步 + +**Files:** +- Modify: `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue` + +- [ ] **Step 1: 增加截止时间变更处理函数** + +在 `patchPay` 附近增加: + +```ts + function onLumpSumDeadlineChange(pIdx: number, raw: string) { + const list = [...(props.modelValue || [])] + const pay = { ...list[pIdx] } + const deadline = raw || '' + pay.lumpSumDeadline = deadline + if (String(pay.payMode || '') === 'dj') { + pay.schedules = applyLumpSumDeadlineToLastDueDate( + [...(pay.schedules || [])], + deadline, + isScheduleDueLocked + ) + } + list[pIdx] = pay + emit('update:modelValue', list) + } +``` + +- [ ] **Step 2: 模板绑定改为调用上述函数** + +将趸交截止时间的: + +```vue +@update:model-value="(v: string) => patchPay(pIdx, { lumpSumDeadline: v || '' })" +``` + +改为: + +```vue +@update:model-value="(v: string) => onLumpSumDeadlineChange(pIdx, v || '')" +``` + +- [ ] **Step 3: 手动冒烟** + +1. 样本缴费选「趸交」,填趸交截止时间 → 第 1 期应缴费时间等于该日。 +2. 清空截止时间 → 应缴费时间清空。 +3. 分改趸:确认仍无「趸交截止时间」字段,行为不变。 + +- [ ] **Step 4: 全量相关单测** + +Run: + +```bash +cd web-admin && node --import tsx --test src/utils/protocol-sample-payment.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): sync dj lump-sum deadline to schedule due date + +When lumpSumDeadline changes in SamplePaymentPanel, update the last +unlocked schedule dueDate without a full rebuild. +EOF +)" +``` + +--- + +## Spec coverage checklist + +| Spec 要求 | Task | +|-----------|------| +| fq 尾期 = 合同 − 前 N−1 | Task 1 + 3 | +| 尾期可手改,前几期再变才重算 | Task 1 (`shouldAutoFill`) + 3 | +| 锁定跳过 | Task 1 + 3 | +| 负值钳 0 | Task 1 | +| 前几期全空不写尾期 | Task 1 | +| dj 截止时间 ↔ 应缴日(含清空) | Task 2 + 4 | +| 锁定跳过截止日同步 | Task 2 + 4 | +| 分改趸不加字段/不联动 | Task 3/4 仅 `fq`/`dj` 分支 | +| 不改 v1 / 后端 | 无对应 Task | + +## Self-review notes + +- 无 TBD/placeholder。 +- 函数名与 Task 3/4 引用一致。 +- 测试命令统一为 `node --import tsx --test`(与仓库既有 TS 单测一致)。