docs: add protocol payment last-period autofill implementation plan

Bite-sized TDD tasks for SamplePaymentPanel fq/dj autofill helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-13 14:44:51 +08:00
1 parent 0efcd6a6e5
commit 13ac45d10d
1 file changed
+470
@@ -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 | string | null | undefined>
): 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<T extends ScheduleLike>(
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<Record<string, boolean>>({})
function samplePayKey(pay: Record<string, any>, pIdx: number): string {
return String(pay.sampleCode || pay.protocolSampleId || pIdx)
}
function activeScheduleIndices(schedules: Record<string, any>[]): 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<string, any>) {
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<string, any>) => {
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 单测一致)。