# 合同结算方式新增选项 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 = { 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 { if (!raw) return {} try { const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} const drafts: Record = {} 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 |