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:
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 单测一致)。
|
||||
Reference in new issue
Block a user