Jelajahi Sumber

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>
zlwu 3 minggu lalu
induk
melakukan
13ac45d10d

+ 470 - 0
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 | 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 单测一致)。