Files
shanxia2.0/docs/superpowers/plans/2026-08-16-contract-settlement-method-options.md
T
2026-08-17 09:22:31 +08:00

362 lines
12 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:** 为合同结算方式增加按量结算/月结/季结/其他四项,并让第五条条款按「条款驱动两项 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 |