Files
zkxw/docs/superpowers/plans/2026-07-06-protocol-payment-defaults.md
T
2026-07-06 19:41:24 +08:00

19 KiB
Raw Blame History

采集协议单 · 缴费默认定价 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: 按「样本数 × 缴费模式」在新建/首次选模式时填充默认合同金额与分期模板;支持双样本 djfq(趸交+分期);趸交允许多条缴费明细;样本数/样本价变化不再自动改合同金额。

Architecture: 前端新增 protocol-payment-presets.ts 集中定价表与 applyPaymentPreset();PayMode 扩展为 dj | fq | djfq,分期逻辑通过 isInstallmentPayMode() 统一处理 fq 与 djfq;后端校验放宽趸交多 dfj、合计校验,djfq 走分期分支;方案 B 用 paymentDefaultsApplied 标记避免重复填默认。

Tech Stack: Vue 3 · TypeScript · Element Plus · FastAPI · Python unittest · tsx(前端纯函数冒烟)

Design spec: docs/superpowers/specs/2026-07-06-protocol-payment-defaults-design.md


File Map

文件 职责
web-admin/src/utils/protocol-payment-presets.ts 新建 定价表、getActiveSampleCount、applyPaymentPreset、isInstallmentPayMode
web-admin/scripts/test-protocol-payment-presets.ts 新建 定价表冒烟脚本(tsx 运行)
web-admin/src/utils/protocol-pay-mode-switch.ts PayMode 含 djfq;趸交多明细;切换时用 preset 重建
web-admin/src/utils/protocol-payment-installment.ts alignFq* / syncFq* 识别 djfq
web-admin/src/utils/status-tag-registry.ts djfq 标签
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue 模式 UI、首次默认、去掉样本价联动、保存校验
web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue djfq 只读展示
web-admin/src/components/business/protocol-payment-detail-readonly/index.vue 分期字段展示含 djfq
web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue 摘要 djfq 按分期展示
web-admin/src/utils/protocol-standard-import-parse.ts (P1)识别「趸交+分期」
backend/app/services/protocol_pay_mode_switch.py 趸交多明细合计校验;djfq→分期分支
backend/app/crud/collection_protocol.py align_fq_* 等 djfq 与 fq 同等
backend/app/services/qc_work_order_fee_summary.py djfq 标签与分期摘要
backend/tests/test_protocol_pay_mode_switch.py 更新/新增校验单测
backend/tests/test_protocol_payment_presets.py 新建(可选)后端不依赖 preset,本任务以前端脚本为主

依赖顺序

Task 1 (presets 纯函数)
  → Task 2 (后端校验)
  → Task 3 (pay-mode-switch + installment 工具)
  → Task 4 (标签/只读组件)
  → Task 5 (ProtocolFormDialog 主改动)
  → Task 6 (导入 P1 + 收尾 QA)

Task 1: 定价表纯函数与冒烟测试

Files:

  • Create: web-admin/src/utils/protocol-payment-presets.ts

  • Create: web-admin/scripts/test-protocol-payment-presets.ts

  • Step 1: 创建 protocol-payment-presets.ts

// web-admin/src/utils/protocol-payment-presets.ts
export type PayMode = 'dj' | 'fq' | 'djfq'

export type PaymentPreset = {
  contractAmount: number
  installmentCount?: number
  installmentAmounts?: number[]
}

const PRESETS: Record<1 | 2, Partial<Record<PayMode, PaymentPreset>>> = {
  1: {
    dj: { contractAmount: 22880 },
    fq: {
      contractAmount: 25880,
      installmentCount: 6,
      installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880]
    }
  },
  2: {
    dj: { contractAmount: 37880 },
    djfq: {
      contractAmount: 41000,
      installmentCount: 5,
      installmentAmounts: [22880, 6480, 3880, 3880, 3880]
    },
    fq: {
      contractAmount: 44000,
      installmentCount: 5,
      installmentAmounts: [12960, 7760, 7760, 7760, 7760]
    }
  }
}

export function getActiveSampleCount(
  samples: Array<{ sampleStatus?: string }>
): 1 | 2 {
  const n = samples.filter((s) => String(s.sampleStatus || '') !== 'yzf').length
  return (n >= 2 ? 2 : 1) as 1 | 2
}

export function resolvePaymentPreset(
  sampleCount: 1 | 2,
  payMode: PayMode
): PaymentPreset | null {
  return PRESETS[sampleCount]?.[payMode] ?? null
}

export function isInstallmentPayMode(payMode: string): boolean {
  return payMode === 'fq' || payMode === 'djfq'
}

export function availablePayModes(sampleCount: 1 | 2): PayMode[] {
  return sampleCount === 2 ? ['dj', 'djfq', 'fq'] : ['dj', 'fq']
}

/** 校验双样本才能用 djfq */
export function isPayModeValidForSampleCount(
  payMode: PayMode,
  sampleCount: 1 | 2
): boolean {
  return availablePayModes(sampleCount).includes(payMode)
}
  • Step 2: 创建冒烟脚本
// web-admin/scripts/test-protocol-payment-presets.ts
import assert from 'node:assert/strict'
import {
  resolvePaymentPreset,
  getActiveSampleCount,
  isInstallmentPayMode,
  isPayModeValidForSampleCount
} from '../src/utils/protocol-payment-presets'

const fq1 = resolvePaymentPreset(1, 'fq')!
assert.equal(fq1.contractAmount, 25880)
assert.deepEqual(fq1.installmentAmounts, [6480, 3880, 3880, 3880, 3880, 3880])
assert.equal(
  fq1.installmentAmounts!.reduce((s, v) => s + v, 0),
  fq1.contractAmount
)

const djfq2 = resolvePaymentPreset(2, 'djfq')!
assert.equal(djfq2.contractAmount, 41000)
assert.equal(djfq2.installmentCount, 5)

assert.equal(getActiveSampleCount([{ sampleStatus: 'ycj' }]), 1)
assert.equal(getActiveSampleCount([{ sampleStatus: 'ycj' }, { sampleStatus: 'yzf' }]), 1)
assert.equal(
  getActiveSampleCount([{ sampleStatus: 'ycj' }, { sampleStatus: 'ycj' }]),
  2
)

assert.equal(isInstallmentPayMode('djfq'), true)
assert.equal(isInstallmentPayMode('dj'), false)
assert.equal(isPayModeValidForSampleCount('djfq', 1), false)
assert.equal(isPayModeValidForSampleCount('djfq', 2), true)

console.log('protocol-payment-presets: OK')
  • Step 3: 运行冒烟
cd web-admin && pnpm exec tsx scripts/test-protocol-payment-presets.ts

Expected: 输出 protocol-payment-presets: OK,exit 0。


Task 2: 后端保存校验(趸交多明细 + djfq)

Files:

  • Modify: backend/app/services/protocol_pay_mode_switch.py

  • Modify: backend/tests/test_protocol_pay_mode_switch.py

  • Step 1: 修改 validate_payment_details_for_save 趸交分支

将 dj 分支中「仅 1 条 dfj」改为「全部明细应缴合计 = 合同金额」:

# backend/app/services/protocol_pay_mode_switch.py — pay_mode == "dj" 分支替换为:

if pay_mode == "dj":
    total_due = sum(
        _dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount"))
        for r in rows
    )
    if contract and abs(total_due - contract) > 0.015:
        raise ValueError(
            f"趸交各明细应缴金额之和应为 {contract:.2f},当前为 {total_due:.2f}"
        )
    # 保留:存在 yjf 时,dfj 行 due 之和应等于 remaining(可选细化为与 total 一致即可)

删除:

if len(dfj_rows) > 1:
    raise ValueError("趸交模式仅允许一条待缴费明细")

以及单条 dfj 的 expected 校验(已由 total_due 覆盖)。

  • Step 2: djfq 走分期分支
installment_modes = ("fq", "djfq")
# ...
elif pay_mode in installment_modes:
    # 原 fq 逻辑不变
  • Step 3: 更新单测

test_dj_rejects_multiple_dfj 改名为 test_dj_allows_multiple_dfj_when_sum_matches:

def test_dj_allows_multiple_dfj_when_sum_matches(self):
    validate_payment_details_for_save(
        "dj",
        10000,
        None,
        [
            {"payStatus": "dfj", "dueAmount": 2000},
            {"payStatus": "dfj", "dueAmount": 8000},
        ],
    )

def test_dj_rejects_multiple_dfj_wrong_sum(self):
    with self.assertRaises(ValueError) as ctx:
        validate_payment_details_for_save(
            "dj",
            10000,
            None,
            [
                {"payStatus": "dfj", "dueAmount": 2000},
                {"payStatus": "dfj", "dueAmount": 7000},
            ],
        )
    self.assertIn("之和应为", str(ctx.exception))

新增:

def test_djfq_same_as_fq(self):
    validate_payment_details_for_save(
        "djfq",
        41000,
        5,
        [
            {"payStatus": "dfj", "installmentNo": 1, "dueAmount": 22880},
            {"payStatus": "dfj", "installmentNo": 2, "dueAmount": 6480},
            {"payStatus": "dfj", "installmentNo": 3, "dueAmount": 3880},
            {"payStatus": "dfj", "installmentNo": 4, "dueAmount": 3880},
            {"payStatus": "dfj", "installmentNo": 5, "dueAmount": 3880},
        ],
        installment_ratios=[22880, 6480, 3880, 3880, 3880],
    )
  • Step 4: 运行后端单测
cd backend && source venv/bin/activate && python -m pytest tests/test_protocol_pay_mode_switch.py -v

Expected: 全部 PASS。

  • Step 5: 修改 collection_protocol.py 分期对齐

约 L2814,将:

if pay.pay_mode != "fq" or not pay.installment_count:

改为:

if pay.pay_mode not in ("fq", "djfq") or not pay.installment_count:

全文检索 pay_mode != "fq" / pay_mode == "fq" 在同文件内一并调整。


Task 3: 前端工具层(pay-mode-switch + installment)

Files:

  • Modify: web-admin/src/utils/protocol-pay-mode-switch.ts

  • Modify: web-admin/src/utils/protocol-payment-installment.ts

  • Step 1: 扩展 PayMode 并导入 preset

// protocol-pay-mode-switch.ts 顶部
import {
  type PayMode,
  isInstallmentPayMode,
  resolvePaymentPreset,
  getActiveSampleCount
} from '@/utils/protocol-payment-presets'
export type { PayMode }
  • Step 2: canAddPaymentDetail 趸交始终可添加
export function canAddPaymentDetail(payMode: string, _details: DetailLike[]): boolean {
  if (isInstallmentPayMode(payMode)) return true
  return true // dj:允许多条明细
}
  • Step 3: evaluatePayModeSwitch / applyPayModeSwitch 支持 djfq

  • dj → djfq:与 dj → fq 相同(无已缴才允许)

  • djfq → dj:与 fq → dj

  • djfq ↔ fq:按分期规则处理

  • applyPayModeSwitch 重建分期明细时,从 resolvePaymentPreset(sampleCount, toMode) 取 installmentAmounts,无 preset 时回退 defaultInstallmentAmounts

  • Step 4: protocol-payment-installment.ts

将所有 payment.payMode !== 'fq' / === 'fq' 改为使用 isInstallmentPayMode(payment.payMode as string)(从 presets 导入)。

  • Step 5: 类型检查
cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -40

Expected: 无新增 error(允许既有 warning)。


Task 4: 标签与只读展示

Files:

  • Modify: web-admin/src/utils/status-tag-registry.ts

  • Modify: web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue

  • Modify: web-admin/src/components/business/protocol-payment-detail-readonly/index.vue

  • Modify: web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue

  • Modify: backend/app/services/qc_work_order_fee_summary.py

  • Step 1: status-tag-registry.ts 增加

djfq: { label: '趸交+分期', tagType: 'warning' } // 或 primary,与 fq 区分即可
  • Step 2: 只读组件分期条件

payment?.payMode === 'fq' → isInstallmentPayMode(payment?.payMode || '')(ViewDialog、readonly、QcWorkOrderProtocolPaymentPanel)。

  • Step 3: 后端 _PAY_MODE_LABELS
_PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"}

build_protocol_payment_summary 内 pay_mode == "fq" 改为 pay_mode in ("fq", "djfq")。


Task 5: ProtocolFormDialog 主改动

Files:

  • Modify: web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

  • Step 1: 缴费模式单选 UI

<el-radio-group v-model="formData.payMode" @change="onPayModeChange">
  <el-radio label="dj">趸交</el-radio>
  <el-radio v-if="activeSampleCount === 2" label="djfq">趸交+分期</el-radio>
  <el-radio label="fq">分期</el-radio>
</el-radio-group>

activeSampleCount = computed(() => getActiveSampleCount(formData.samples))。

  • Step 2: 分期区块条件

formData.payMode === 'fq' → isInstallmentPayMode(formData.payMode)(模板内所有分期表单项、分期金额行)。

  • Step 3: 新增状态 paymentDefaultsApplied
const paymentDefaultsApplied = ref<Record<PayMode, boolean>>({
  dj: false,
  fq: false,
  djfq: false
})

function applyPaymentPresetForMode(payMode: PayMode) {
  const preset = resolvePaymentPreset(getActiveSampleCount(formData.samples), payMode)
  if (!preset) return
  formData.contractAmount = preset.contractAmount
  if (isInstallmentPayMode(payMode) && preset.installmentCount) {
    formData.installmentCount = preset.installmentCount
    formData.installmentIntervalMonths = formData.installmentIntervalMonths ?? 6
    formData.installmentStartDate =
      formData.installmentStartDate || defaultInstallmentStartDateYmd()
    formData.installmentAdvanceDays =
      formData.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
    formData.installmentRatios = [...(preset.installmentAmounts || [])]
    formData.paymentDetails = generateInitialFqDetails(
      getFqParamsFromForm(),
      preset.contractAmount
    )
    initPaymentDetailExpandFlags(formData.paymentDetails)
    markPayDetailLocks(formData.paymentDetails)
    syncLastConfirmedFqParams()
  } else {
    formData.paymentDetails = [
      createPaymentDetailRow({
        dueAmount: preset.contractAmount,
        installmentDueDate: formData.lumpSumDeadline || '',
        expanded: true
      })
    ]
  }
  paymentDefaultsApplied.value[payMode] = true
}
  • Step 4: 改造 onPayModeChange

在既有 evaluatePayModeSwitch 之前/之后:

async function onPayModeChange(newMode: PayMode) {
  const oldMode = lastConfirmedPayMode.value
  // ... 既有 evaluate + apply 逻辑 ...

  if (!paymentDefaultsApplied.value[newMode]) {
    applyPaymentPresetForMode(newMode)
  }
  lastConfirmedPayMode.value = newMode
}

新建弹窗 resetForm / openCreate 时重置 paymentDefaultsApplied 全 false;编辑加载详情后全部标 true(不覆盖库内数据)。

  • Step 5: 去掉样本价 → 合同额自动联动

onSamplePriceChange 改为空实现或仅刷新弱提示:

function onSamplePriceChange() {
  // 方案 B:不调用 syncContractAmount()
}

删除或停用 syncContractAmount() 内对 formData.contractAmount 的写入;保留函数供「恢复默认」P1 使用时可再启用。

可选弱提示(缴费 Tab):

const samplePriceContractHint = computed(() => {
  const sampleTotal = formData.samples
    .filter((s) => s.sampleStatus !== 'yzf')
    .reduce((sum, s) => sum + (Number(s.price) || 0), 0)
  if (formData.contractAmount == null) return ''
  if (Math.abs(sampleTotal - Number(formData.contractAmount)) > 0.01) {
    return `样本价格合计 ${fmtMoney(sampleTotal)} 与合同金额不一致,请核对`
  }
  return ''
})
  • Step 6: 保存前校验 djfq 样本数

在 handleSubmit 缴费校验段:

if (
  formData.payMode === 'djfq' &&
  getActiveSampleCount(formData.samples) !== 2
) {
  await ElMessageBox.alert('「趸交+分期」仅适用于 2 个样本,请切换缴费模式', '无法保存', {
    type: 'warning'
  })
  return
}
if (!isPayModeValidForSampleCount(formData.payMode as PayMode, getActiveSampleCount(formData.samples))) {
  await ElMessageBox.alert('当前缴费模式与样本数量不匹配', '无法保存', { type: 'warning' })
  return
}
  • Step 7: 标准导入后标记默认已应用

applyPaymentFromImport 末尾:

paymentDefaultsApplied.value = { dj: true, fq: true, djfq: true }
  • Step 8: 编辑加载 lastConfirmedPayMode
lastConfirmedPayMode.value = (['fq', 'djfq'].includes(formData.payMode)
  ? formData.payMode
  : 'dj') as PayMode
  • Step 9: 手动验证(开发环境)
  1. 新建 → 添加 1 样本 → 首次选「分期」→ 合同 25880、6 期金额正确
  2. 再添加第 2 样本 → 合同/明细不变
  3. 新建 2 样本 → 选「趸交+分期」→ 41000、5 期
  4. 趸交 → 添加 2 条明细 2000+20880 → 保存成功
  5. 2 样本 djfq 删 1 样本 → 保存拦截

Task 6: 标准导入(P1)与收尾

Files:

  • Modify: web-admin/src/utils/protocol-standard-import-parse.ts

  • Run: ./scripts/update-graph.sh frontend

  • Step 1: 导入识别 djfq

parsePayMode 中:

if (/趸交\s*[++]\s*分期|趸交.*分期/.test(v)) return { payMode: 'djfq' }

ProtocolStandardImportParsed.payMode 类型改为 'dj' | 'fq' | 'djfq'。

  • Step 2: 更新知识图谱
./scripts/update-graph.sh frontend
  • Step 3: Lint
cd web-admin && pnpm lint
cd backend && python -m pytest tests/test_protocol_pay_mode_switch.py -v
  • Step 4: 更新 design spec 状态

docs/superpowers/specs/2026-07-06-protocol-payment-defaults-design.md 表头 状态 → 已实现。


Spec 覆盖自检

Spec § Task
§3 默认定价表 Task 1
§4 方案 B 填充时机 Task 5 Step 3–4、7
§5 趸交多明细 Task 2、3、5
§5.2 djfq 分期 Task 2–5
§6 模式切换修订 Task 3、5
§7 pay_mode=djfq Task 2、4
§8 UI Task 4、5
§8.3 样本价解耦 Task 5 Step 5
§9–10 前后端摘要 Task 2–5
§11 测试要点 各 Task Step 验证
§4.3 恢复默认 P1 范围外(未列入 Task)

人工回归清单(提测)

  • 新建 1 样本首次 fq:25880 / 6 期模板
  • 新建 2 样本首次 djfq:41000 / 5 期
  • 样本数 1→2 后金额不变
  • 趸交 2+ 条 dfj 保存成功
  • 分期合计 ≠ 合同额:前后端拦截
  • 已缴锁定 + 模式切换:仍符合 2026-06-04
  • 查看弹窗 / 质检费用摘要:djfq 标签与期次表
  • 标准导入多笔趸交转账保存成功

执行方式

Plan 已保存至 docs/superpowers/plans/2026-07-06-protocol-payment-defaults.md。

两种执行方式:

  1. Subagent-Driven(推荐) — 每 Task 派发独立子代理,Task 间人工/代理复核
  2. Inline Execution — 本会话按 Task 1→6 连续实现,每 Task 末 checkpoint

告知选用哪种方式即可开始实现。