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
| 文件 | 职责 |
|---|---|
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)
Files:
web-admin/src/utils/protocol-payment-presets.tsCreate: 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)
}
// 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')
cd web-admin && pnpm exec tsx scripts/test-protocol-payment-presets.ts
Expected: 输出 protocol-payment-presets: OK,exit 0。
Files:
backend/app/services/protocol_pay_mode_switch.pyModify: 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 覆盖)。
djfq 走分期分支installment_modes = ("fq", "djfq")
# ...
elif pay_mode in installment_modes:
# 原 fq 逻辑不变
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],
)
cd backend && source venv/bin/activate && python -m pytest tests/test_protocol_pay_mode_switch.py -v
Expected: 全部 PASS。
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" 在同文件内一并调整。
Files:
web-admin/src/utils/protocol-pay-mode-switch.tsModify: 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 }
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 导入)。
cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -40
Expected: 无新增 error(允许既有 warning)。
Files:
web-admin/src/utils/status-tag-registry.tsweb-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vueweb-admin/src/components/business/protocol-payment-detail-readonly/index.vueweb-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vueModify: backend/app/services/qc_work_order_fee_summary.py
[ ] Step 1: status-tag-registry.ts 增加
djfq: { label: '趸交+分期', tagType: 'warning' } // 或 primary,与 fq 区分即可
payment?.payMode === 'fq' → isInstallmentPayMode(payment?.payMode || '')(ViewDialog、readonly、QcWorkOrderProtocolPaymentPanel)。
_PAY_MODE_LABELS_PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"}
build_protocol_payment_summary 内 pay_mode == "fq" 改为 pay_mode in ("fq", "djfq")。
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))。
formData.payMode === 'fq' → isInstallmentPayMode(formData.payMode)(模板内所有分期表单项、分期金额行)。
paymentDefaultsAppliedconst 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
}
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(不覆盖库内数据)。
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 ''
})
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
}
applyPaymentFromImport 末尾:
paymentDefaultsApplied.value = { dj: true, fq: true, djfq: true }
lastConfirmedPayModelastConfirmedPayMode.value = (['fq', 'djfq'].includes(formData.payMode)
? formData.payMode
: 'dj') as PayMode
djfq 删 1 样本 → 保存拦截Files:
web-admin/src/utils/protocol-standard-import-parse.tsRun: ./scripts/update-graph.sh frontend
[ ] Step 1: 导入识别 djfq
parsePayMode 中:
if (/趸交\s*[++]\s*分期|趸交.*分期/.test(v)) return { payMode: 'djfq' }
ProtocolStandardImportParsed.payMode 类型改为 'dj' | 'fq' | 'djfq'。
./scripts/update-graph.sh frontend
cd web-admin && pnpm lint
cd backend && python -m pytest tests/test_protocol_pay_mode_switch.py -v
docs/superpowers/specs/2026-07-06-protocol-payment-defaults-design.md 表头 状态 → 已实现。
| 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) |
fq:25880 / 6 期模板djfq:41000 / 5 期dfj 保存成功djfq 标签与期次表Plan 已保存至 docs/superpowers/plans/2026-07-06-protocol-payment-defaults.md。
两种执行方式:
告知选用哪种方式即可开始实现。