19 KiB
采集协议单 · 缴费默认定价 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 样本 → 首次选「分期」→ 合同 25880、6 期金额正确
- 再添加第 2 样本 → 合同/明细不变
- 新建 2 样本 → 选「趸交+分期」→ 41000、5 期
- 趸交 → 添加 2 条明细 2000+20880 → 保存成功
- 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。
两种执行方式:
- Subagent-Driven(推荐) — 每 Task 派发独立子代理,Task 间人工/代理复核
- Inline Execution — 本会话按 Task 1→6 连续实现,每 Task 末 checkpoint
告知选用哪种方式即可开始实现。