|
|
@@ -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 单测一致)。
|