362 lines
12 KiB
Markdown
362 lines
12 KiB
Markdown
# 合同结算方式新增选项 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:** 为合同结算方式增加按量结算/月结/季结/其他四项,并让第五条条款按「条款驱动两项 vs 新四项约定句」规则解析,前后端一致。
|
||
|
||
**Architecture:** 字典扩 4 条;抽出前后端 `defaultClause(settlementMethod)`;保留 `{{SETTLEMENT_METHOD_CONTENT}}` + `settlement_clause_drafts[method]`;新四项默认文案为「请根据双方约定填写结算方式:{label}。供方开具13%增值税专用发票,无异议后,以现汇方式月结。」
|
||
|
||
**Tech Stack:** MySQL 字典、Python 3.9 / pytest、Vue 3 / TypeScript
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-16-contract-settlement-method-options-design.md`
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `backend/scripts/migrate_20260816_crm_settlement_method_options.sql` | 幂等插入 4 条字典 |
|
||
| `backend/app/services/crm_settlement_clause.py` | 后端 defaultClause 纯函数 |
|
||
| `backend/app/crud/crm/contract.py` | `save_template` 调用纯函数 |
|
||
| `backend/tests/test_crm_settlement_clause.py` | 默认文案单测 |
|
||
| `web-admin/src/views/crm/contract/settlementClause.ts` | 前端解析/默认文案 |
|
||
| `web-admin/src/views/crm/contract/components/ContractDetailDialog.vue` | 改用共享函数 |
|
||
| `web-admin/src/views/crm/contract/detail.vue` | 改用共享函数(若仍保留同逻辑) |
|
||
|
||
---
|
||
|
||
### Task 1: 字典 SQL 迁移
|
||
|
||
**Files:**
|
||
- Create: `backend/scripts/migrate_20260816_crm_settlement_method_options.sql`
|
||
|
||
- [x] **Step 1: 写入幂等 INSERT**
|
||
|
||
```sql
|
||
-- backend/scripts/migrate_20260816_crm_settlement_method_options.sql
|
||
-- 合同结算方式:按量结算 / 月结 / 季结 / 其他
|
||
-- 可重复执行(幂等)
|
||
|
||
INSERT INTO `sys_dict_data` (
|
||
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`,
|
||
`order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`,
|
||
`update_by`, `update_time`, `del_flag`
|
||
) VALUES
|
||
(
|
||
'SXDC20260816SETTLE00000000000001', '按量结算', 'volume_settlement', '',
|
||
'crm_settlement_method', 'shanxia2.0', 2, '', '1', '', NOW(), NULL, NOW(), 0
|
||
),
|
||
(
|
||
'SXDC20260816SETTLE00000000000002', '月结', 'monthly_settlement', '',
|
||
'crm_settlement_method', 'shanxia2.0', 3, '', '1', '', NOW(), NULL, NOW(), 0
|
||
),
|
||
(
|
||
'SXDC20260816SETTLE00000000000003', '季结', 'quarterly_settlement', '',
|
||
'crm_settlement_method', 'shanxia2.0', 4, '', '1', '', NOW(), NULL, NOW(), 0
|
||
),
|
||
(
|
||
'SXDC20260816SETTLE00000000000004', '其他', 'other_settlement', '',
|
||
'crm_settlement_method', 'shanxia2.0', 5, '', '1', '', NOW(), NULL, NOW(), 0
|
||
)
|
||
ON DUPLICATE KEY UPDATE
|
||
`dict_label` = VALUES(`dict_label`),
|
||
`dict_value` = VALUES(`dict_value`),
|
||
`dict_type` = VALUES(`dict_type`),
|
||
`tenant_no` = VALUES(`tenant_no`),
|
||
`order_num` = VALUES(`order_num`),
|
||
`del_flag` = 0,
|
||
`update_time` = NOW();
|
||
```
|
||
|
||
- [x] **Step 2: 在目标库执行该 SQL,刷新管理端字典缓存后确认下拉出现 6 项**
|
||
|
||
---
|
||
|
||
### Task 2: 后端 defaultClause 纯函数 + 单测(TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/crm_settlement_clause.py`
|
||
- Create: `backend/tests/test_crm_settlement_clause.py`
|
||
|
||
- [x] **Step 1: 先写失败单测**
|
||
|
||
```python
|
||
# backend/tests/test_crm_settlement_clause.py
|
||
from app.services.crm_settlement_clause import default_settlement_clause
|
||
|
||
|
||
def test_advance_payment_clause():
|
||
assert "款到发货" in default_settlement_clause("advance_payment")
|
||
|
||
|
||
def test_arrive_payment_clause():
|
||
assert "货到付款" in default_settlement_clause("arrive_payment")
|
||
|
||
|
||
def test_monthly_settlement_clause_uses_label():
|
||
text = default_settlement_clause("monthly_settlement")
|
||
assert text.startswith("请根据双方约定填写结算方式:月结。")
|
||
assert "13%增值税专用发票" in text
|
||
|
||
|
||
def test_volume_quarterly_other_labels():
|
||
assert "按量结算" in default_settlement_clause("volume_settlement")
|
||
assert "季结" in default_settlement_clause("quarterly_settlement")
|
||
assert "其他" in default_settlement_clause("other_settlement")
|
||
|
||
|
||
def test_unknown_falls_back_to_arrive():
|
||
assert default_settlement_clause("weird") == default_settlement_clause("arrive_payment")
|
||
|
||
|
||
def test_empty_falls_back_to_arrive():
|
||
assert default_settlement_clause("") == default_settlement_clause("arrive_payment")
|
||
assert default_settlement_clause(None) == default_settlement_clause("arrive_payment")
|
||
```
|
||
|
||
- [x] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_crm_settlement_clause.py -v
|
||
```
|
||
|
||
Expected: FAIL(模块不存在)
|
||
|
||
- [x] **Step 3: 实现纯函数**
|
||
|
||
```python
|
||
# backend/app/services/crm_settlement_clause.py
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional
|
||
|
||
ADVANCE_PAYMENT = "advance_payment"
|
||
ARRIVE_PAYMENT = "arrive_payment"
|
||
|
||
CLAUSE_LABELS = {
|
||
"volume_settlement": "按量结算",
|
||
"monthly_settlement": "月结",
|
||
"quarterly_settlement": "季结",
|
||
"other_settlement": "其他",
|
||
}
|
||
|
||
ADVANCE_CLAUSE = (
|
||
"款到发货。需方付款后安排发货,需方收到货物后,供方开具13%增值税专用发票。"
|
||
)
|
||
ARRIVE_CLAUSE = (
|
||
"货到付款,需方收到供方货物后,供方开具13%增值税专用发票,无异议后,以现汇方式月结。"
|
||
)
|
||
|
||
|
||
def default_settlement_clause(settlement_method: Optional[str]) -> str:
|
||
method = (settlement_method or "").strip() or ARRIVE_PAYMENT
|
||
if method == ADVANCE_PAYMENT:
|
||
return ADVANCE_CLAUSE
|
||
if method in CLAUSE_LABELS:
|
||
label = CLAUSE_LABELS[method]
|
||
return (
|
||
f"请根据双方约定填写结算方式:{label}。"
|
||
"供方开具13%增值税专用发票,无异议后,以现汇方式月结。"
|
||
)
|
||
# arrive_payment 与未知值
|
||
return ARRIVE_CLAUSE
|
||
```
|
||
|
||
- [x] **Step 4: 再跑单测,期望全部 PASS**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_crm_settlement_clause.py -v
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 后端 `save_template` 改用纯函数
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/crud/crm/contract.py`(`save_template` 内空草稿兜底,约 1661–1669 行)
|
||
|
||
- [x] **Step 1: 增加 import**
|
||
|
||
```python
|
||
from app.services.crm_settlement_clause import default_settlement_clause
|
||
```
|
||
|
||
- [x] **Step 2: 替换空草稿兜底**
|
||
|
||
将:
|
||
|
||
```python
|
||
method = (entity.settlement_method or "").strip() or "arrive_payment"
|
||
default_clause = (
|
||
"款到发货。需方付款后安排发货,需方收到货物后,供方开具13%增值税专用发票。"
|
||
if method == "advance_payment"
|
||
else "货到付款,需方收到供方货物后,供方开具13%增值税专用发票,无异议后,以现汇方式月结。"
|
||
)
|
||
drafts = json.dumps({method: default_clause}, ensure_ascii=False)
|
||
```
|
||
|
||
改为:
|
||
|
||
```python
|
||
method = (entity.settlement_method or "").strip() or "arrive_payment"
|
||
drafts = json.dumps(
|
||
{method: default_settlement_clause(method)},
|
||
ensure_ascii=False,
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 前端 `settlementClause.ts`
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/views/crm/contract/settlementClause.ts`
|
||
|
||
- [x] **Step 1: 新增共享模块**
|
||
|
||
```typescript
|
||
/** 与后端 app.services.crm_settlement_clause 保持一致 */
|
||
|
||
export const SETTLEMENT_ADVANCE = 'advance_payment'
|
||
export const SETTLEMENT_ARRIVE = 'arrive_payment'
|
||
|
||
export const SETTLEMENT_CLAUSE_LABELS: Record<string, string> = {
|
||
volume_settlement: '按量结算',
|
||
monthly_settlement: '月结',
|
||
quarterly_settlement: '季结',
|
||
other_settlement: '其他'
|
||
}
|
||
|
||
const ADVANCE_CLAUSE =
|
||
'款到发货。需方付款后安排发货,需方收到货物后,供方开具13%增值税专用发票。'
|
||
const ARRIVE_CLAUSE =
|
||
'货到付款,需方收到供方货物后,供方开具13%增值税专用发票,无异议后,以现汇方式月结。'
|
||
|
||
export function defaultSettlementClause(
|
||
settlementMethod?: string | null,
|
||
labelResolver?: (method: string) => string | undefined
|
||
): string {
|
||
const method = (settlementMethod || '').trim() || SETTLEMENT_ARRIVE
|
||
if (method === SETTLEMENT_ADVANCE) return ADVANCE_CLAUSE
|
||
if (method in SETTLEMENT_CLAUSE_LABELS) {
|
||
const label =
|
||
(labelResolver && labelResolver(method)) || SETTLEMENT_CLAUSE_LABELS[method]
|
||
return `请根据双方约定填写结算方式:${label}。供方开具13%增值税专用发票,无异议后,以现汇方式月结。`
|
||
}
|
||
return ARRIVE_CLAUSE
|
||
}
|
||
|
||
export function parseSettlementClauseDrafts(raw?: string | null): Record<string, string> {
|
||
if (!raw) return {}
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
||
const drafts: Record<string, string> = {}
|
||
Object.entries(parsed).forEach(([key, value]) => {
|
||
if (typeof value === 'string' && value.trim()) {
|
||
drafts[key] = value.trim()
|
||
}
|
||
})
|
||
return drafts
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
export function resolveSettlementClauseContent(options: {
|
||
settlementMethod?: string | null
|
||
settlementClauseDrafts?: string | null
|
||
labelResolver?: (method: string) => string | undefined
|
||
}): string {
|
||
const method = (options.settlementMethod || '').trim() || SETTLEMENT_ARRIVE
|
||
const drafts = parseSettlementClauseDrafts(options.settlementClauseDrafts)
|
||
return drafts[method] || defaultSettlementClause(method, options.labelResolver)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `ContractDetailDialog.vue` 改用共享函数
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/crm/contract/components/ContractDetailDialog.vue`
|
||
|
||
- [x] **Step 1: import**
|
||
|
||
```typescript
|
||
import {
|
||
parseSettlementClauseDrafts,
|
||
resolveSettlementClauseContent
|
||
} from '../settlementClause'
|
||
```
|
||
|
||
- [x] **Step 2: 删除组件内重复的 `getDefaultSettlementClauseContent` / 内层 `parseSettlementClauseDrafts`(`replacePlaceholders` 内与文件级各一处),改为调用共享函数**
|
||
|
||
在 `replacePlaceholders` 内,第五条取值改为:
|
||
|
||
```typescript
|
||
const getSettlementClauseContent = (): string =>
|
||
resolveSettlementClauseContent({
|
||
settlementMethod: contractData.settlementMethod,
|
||
settlementClauseDrafts: contractData.settlementClauseDrafts,
|
||
labelResolver: (method) => dictStore.getLabel('crm_settlement_method', method) || undefined
|
||
})
|
||
```
|
||
|
||
文件级若仍有 `parseSettlementClauseDrafts` 供保存草稿使用,改为直接使用 import 的同名函数,删除本地实现。
|
||
|
||
- [ ] **Step 3: 手工验证**(需在浏览器确认)
|
||
|
||
1. 选月结 → 打开打印 → 第五条含「结算方式:月结。」
|
||
2. 选手改第五条并保存 → 再打开仍为改后文案
|
||
3. 选款到发货 → 仍为款到发货默认句
|
||
4. 生成 PDF 与弹窗一致
|
||
|
||
---
|
||
|
||
### Task 6: `detail.vue` 对齐(若仍含同逻辑)
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/crm/contract/detail.vue`
|
||
|
||
- [x] **Step 1: 同样 import `resolveSettlementClauseContent` / `parseSettlementClauseDrafts`,删除本地重复实现,第五条解析走共享函数**(与 Task 5 相同规则)
|
||
|
||
若该页已废弃不再进入打印路径,仍建议对齐,避免双份逻辑分叉。
|
||
|
||
---
|
||
|
||
### Task 7: 验收与图谱
|
||
|
||
- [x] **Step 1: 再跑**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_crm_settlement_clause.py -v
|
||
```
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 2: 对照 spec「验收要点」手工走一遍 6 项结算方式**(需在浏览器确认)
|
||
|
||
- [x] **Step 3: 更新图谱**
|
||
|
||
```bash
|
||
./scripts/update-graph.sh backend
|
||
./scripts/update-graph.sh frontend
|
||
```
|
||
|
||
(或按仓库惯例 `graphify update .`)
|
||
|
||
---
|
||
|
||
## Spec coverage
|
||
|
||
| Spec 要求 | Task |
|
||
|-----------|------|
|
||
| 字典 4 项 | Task 1 |
|
||
| defaultClause 六种 + 未知回退 | Task 2–3 |
|
||
| 前端打印/PDF 解析 | Task 4–5 |
|
||
| detail 双份逻辑对齐 | Task 6 |
|
||
| 草稿按 method 保存不覆盖 | 现有 save 路径 + Task 5 手工验 |
|
||
| 验收与图谱 | Task 7 |
|