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

597 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采集协议单 · 缴费默认定价 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,本任务以前端脚本为主 |
---
## 依赖顺序
```text
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`**
```typescript
// 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: 创建冒烟脚本**
```typescript
// 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: 运行冒烟**
```bash
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」改为「全部明细应缴合计 = 合同金额」:
```python
# 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 一致即可)
```
删除:
```python
if len(dfj_rows) > 1:
raise ValueError("趸交模式仅允许一条待缴费明细")
```
以及单条 `dfj` 的 `expected` 校验(已由 `total_due` 覆盖)。
- [ ] **Step 2: `djfq` 走分期分支**
```python
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`:
```python
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))
```
新增:
```python
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: 运行后端单测**
```bash
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,将:
```python
if pay.pay_mode != "fq" or not pay.installment_count:
```
改为:
```python
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**
```typescript
// protocol-pay-mode-switch.ts 顶部
import {
type PayMode,
isInstallmentPayMode,
resolvePaymentPreset,
getActiveSampleCount
} from '@/utils/protocol-payment-presets'
export type { PayMode }
```
- [ ] **Step 2: `canAddPaymentDetail` 趸交始终可添加**
```typescript
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: 类型检查**
```bash
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` 增加**
```typescript
djfq: { label: '趸交+分期', tagType: 'warning' } // 或 primary,与 fq 区分即可
```
- [ ] **Step 2: 只读组件分期条件**
`payment?.payMode === 'fq'` → `isInstallmentPayMode(payment?.payMode || '')`(ViewDialog、readonly、QcWorkOrderProtocolPaymentPanel)。
- [ ] **Step 3: 后端 `_PAY_MODE_LABELS`**
```python
_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**
```vue
<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`**
```typescript
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` 之前/之后:
```typescript
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` 改为空实现或仅刷新弱提示:
```typescript
function onSamplePriceChange() {
// 方案 B:不调用 syncContractAmount()
}
```
删除或停用 `syncContractAmount()` 内对 `formData.contractAmount` 的写入;保留函数供「恢复默认」P1 使用时可再启用。
可选弱提示(缴费 Tab):
```typescript
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` 缴费校验段:
```typescript
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` 末尾:
```typescript
paymentDefaultsApplied.value = { dj: true, fq: true, djfq: true }
```
- [ ] **Step 8: 编辑加载 `lastConfirmedPayMode`**
```typescript
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` 中:
```typescript
if (/趸交\s*[++]\s*分期|趸交.*分期/.test(v)) return { payMode: 'djfq' }
```
`ProtocolStandardImportParsed.payMode` 类型改为 `'dj' | 'fq' | 'djfq'`。
- [ ] **Step 2: 更新知识图谱**
```bash
./scripts/update-graph.sh frontend
```
- [ ] **Step 3: Lint**
```bash
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
告知选用哪种方式即可开始实现。