From f01057d90214e6ebbf5b9552275d820dc894bb8a Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 3 Aug 2026 11:40:33 +0800 Subject: [PATCH] docs: add implementation plan for contract no follow sign date Break work into backend align helpers, create/update wiring, and frontend preview tasks. Co-authored-by: Cursor --- ...-08-03-crm-contract-no-follow-sign-date.md | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-crm-contract-no-follow-sign-date.md diff --git a/docs/superpowers/plans/2026-08-03-crm-contract-no-follow-sign-date.md b/docs/superpowers/plans/2026-08-03-crm-contract-no-follow-sign-date.md new file mode 100644 index 000000000..186add540 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-crm-contract-no-follow-sign-date.md @@ -0,0 +1,436 @@ +# CRM 合同编号日期跟随签订日期 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:** 签订日期变更时,标准格式合同编号的日期段跟随更新(保留序号);前端即时预览,后端保存时权威校正并处理撞号;合同名称括号内编号同步。 + +**Architecture:** 后端在 `contract.py` 增加纯日期替换 + `align_contract_no_to_sign_date`(撞号顺延);接入 `create_contract` / `update_contract`。前端在 `handleSignDateChange` 中同步替换编号日期段并更新名称。编辑路径当前 **不会** 持久化 `contract_no`(`_apply_contract_entity_fields` 未映射该字段),本计划在 update 中显式写回 align 后的编号。 + +**Tech Stack:** Python 3.9 / FastAPI CRUD、pytest、Vue 3 / Element Plus(`ContractFormDialog.vue`) + +**Spec:** `docs/superpowers/specs/2026-08-03-crm-contract-no-follow-sign-date-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `backend/app/crud/crm/contract.py` | `replace_contract_no_date`、`align_contract_no_to_sign_date`;create/update 接入 | +| `backend/tests/test_crm_contract_no_align.py` | 纯函数 + align(mock `contract_no_exists`)单测 | +| `web-admin/.../ContractFormDialog.vue` | 主表单:签订日变更 → 编号日期预览 + 名称同步 | +| `web-admin/.../form.vue` | 遗留表单对齐同一行为(若仍可路由进入) | + +--- + +### Task 1: 后端纯函数 + align + 单测 + +**Files:** +- Create: `backend/tests/test_crm_contract_no_align.py` +- Modify: `backend/app/crud/crm/contract.py`(紧挨 `generate_contract_no` 之后) + +- [ ] **Step 1: 写失败单测** + +```python +# backend/tests/test_crm_contract_no_align.py +from datetime import date +from unittest.mock import MagicMock, patch + +from app.crud.crm.contract import align_contract_no_to_sign_date, replace_contract_no_date + + +def test_replace_keeps_seq_and_swaps_date(): + assert ( + replace_contract_no_date("SX/X-JT20250803001", date(2025, 8, 10)) + == "SX/X-JT20250810001" + ) + + +def test_replace_noop_when_same_date(): + assert replace_contract_no_date("SX/X-JT20250803001", date(2025, 8, 3)) == "SX/X-JT20250803001" + + +def test_replace_returns_none_for_nonstandard(): + assert replace_contract_no_date("CUSTOM-001", date(2025, 8, 10)) is None + assert replace_contract_no_date("", date(2025, 8, 10)) is None + assert replace_contract_no_date(None, date(2025, 8, 10)) is None + + +def test_align_returns_original_when_nonstandard(): + db = MagicMock() + assert ( + align_contract_no_to_sign_date( + db, "t1", "CUSTOM-001", date(2025, 8, 10) + ) + == "CUSTOM-001" + ) + + +def test_align_bumps_seq_when_occupied(): + db = MagicMock() + + def _exists(db_, tenant, no, exclude_id=None): + return no == "SX/X-JT20250810001" + + with patch("app.crud.crm.contract.contract_no_exists", side_effect=_exists): + out = align_contract_no_to_sign_date( + db, "t1", "SX/X-JT20250803001", date(2025, 8, 10) + ) + assert out == "SX/X-JT20250810002" + + +def test_align_exclude_id_skips_self(): + db = MagicMock() + + def _exists(db_, tenant, no, exclude_id=None): + # without exclude would collide; with exclude_id self is free + if exclude_id == "self-id": + return False + return no == "SX/X-JT20250810001" + + with patch("app.crud.crm.contract.contract_no_exists", side_effect=_exists): + out = align_contract_no_to_sign_date( + db, + "t1", + "SX/X-JT20250803001", + date(2025, 8, 10), + exclude_id="self-id", + ) + assert out == "SX/X-JT20250810001" +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_crm_contract_no_align.py -v +``` + +Expected: FAIL(`replace_contract_no_date` / `align_contract_no_to_sign_date` 未定义) + +- [ ] **Step 3: 实现函数** + +在 `generate_contract_no` 之后插入: + +```python +_CONTRACT_NO_STD_RE = re.compile(r"^([A-Z]+)/X-JT(\d{8})(\d{3})$") + + +def replace_contract_no_date(contract_no: str, sign_date: date) -> Optional[str]: + """标准编号替换日期段,保留序号;非标准返回 None。""" + if not contract_no or not sign_date: + return None + m = _CONTRACT_NO_STD_RE.match(contract_no.strip()) + if not m: + return None + supplier_code, _old_date, seq = m.group(1), m.group(2), m.group(3) + date_str = sign_date.strftime("%Y%m%d") + return f"{supplier_code}/X-JT{date_str}{seq}" + + +def align_contract_no_to_sign_date( + db: Session, + tenant_no: str, + contract_no: str, + sign_date: date, + *, + exclude_id: str = None, +) -> str: + """将标准合同编号日期段对齐签订日;撞号则序号顺延(含软删查重)。 + + 非标准编号原样返回。无签订日则原样返回。 + """ + if not (contract_no or "").strip(): + return contract_no or "" + if not sign_date: + return contract_no.strip() + + replaced = replace_contract_no_date(contract_no, sign_date) + if replaced is None: + return contract_no.strip() + + m = _CONTRACT_NO_STD_RE.match(replaced) + if not m: + return replaced + supplier_code, date_str, seq = m.group(1), m.group(2), m.group(3) + start_seq = int(seq) + for retry in range(50): + candidate = f"{supplier_code}/X-JT{date_str}{start_seq + retry:03d}" + if not contract_no_exists(db, tenant_no, candidate, exclude_id=exclude_id): + return candidate + raise ValueError("生成合同编号失败,请稍后重试") +``` + +确保文件顶部已有 `Optional` 导入(若无则从 `typing` 补充)。 + +- [ ] **Step 4: 跑测确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_crm_contract_no_align.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/crud/crm/contract.py backend/tests/test_crm_contract_no_align.py +git commit -m "$(cat <<'EOF' +feat(crm): add contract no date align helpers + +Replace standard contract-no date segment from sign date and bump sequence on collision. +EOF +)" +``` + +--- + +### Task 2: create / update 接入 align + +**Files:** +- Modify: `backend/app/crud/crm/contract.py` — `create_contract`、`update_contract` + +- [ ] **Step 1: 改 `create_contract` 编号分支** + +将现有: + +```python + contract_no = (_get_field(data, "contract_no", "contractNo") or "").strip() + if not contract_no: + contract_no = generate_contract_no(db, tenant_no, supplier_id=supplier_id, sign_date=sign_date) + elif contract_no_exists(db, tenant_no, contract_no): + raise ValueError(f"合同编号已存在:{contract_no}") +``` + +改为: + +```python + contract_no = (_get_field(data, "contract_no", "contractNo") or "").strip() + if not contract_no: + contract_no = generate_contract_no(db, tenant_no, supplier_id=supplier_id, sign_date=sign_date) + else: + contract_no = align_contract_no_to_sign_date( + db, tenant_no, contract_no, sign_date + ) + if contract_no_exists(db, tenant_no, contract_no): + raise ValueError(f"合同编号已存在:{contract_no}") +``` + +说明:align 已尽量避开占用号;末尾 exists 作最终兜底(极端并发)。 + +- [ ] **Step 2: 改 `update_contract` — 在名称校正之前对齐并写回编号** + +在 `_apply_contract_entity_fields(...)` 之后、`supplier_short_name = ...` 之前(或紧挨 `generate_contract_name` 之前)插入: + +```python + sign_date = old.sign_date or date.today() + submitted_no = (_get_field(data, "contract_no", "contractNo") or "").strip() or ( + old.contract_no or "" + ).strip() + if submitted_no: + aligned_no = align_contract_no_to_sign_date( + db, + tenant_no, + submitted_no, + sign_date, + exclude_id=old.id, + ) + if aligned_no != (old.contract_no or ""): + if contract_no_exists(db, tenant_no, aligned_no, exclude_id=old.id): + raise ValueError(f"合同编号已存在:{aligned_no}") + old.contract_no = aligned_no + elif submitted_no != (old.contract_no or ""): + # 非标准手改编号:允许写回(仍须唯一) + if contract_no_exists(db, tenant_no, submitted_no, exclude_id=old.id): + raise ValueError(f"合同编号已存在:{submitted_no}") + old.contract_no = submitted_no +``` + +注意:`_apply_contract_entity_fields` 已可能更新 `old.sign_date`,故 `sign_date` 取 `old.sign_date`。 + +随后现有 `generate_contract_name(old.contract_no or "", ...)` 会用新编号同步名称,无需再改名称逻辑。 + +- [ ] **Step 3: 跑单测 + 快速语法检查** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_crm_contract_no_align.py -v && python -c "from app.crud.crm.contract import create_contract, update_contract, align_contract_no_to_sign_date; print('ok')" +``` + +Expected: PASS + `ok` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/crud/crm/contract.py +git commit -m "$(cat <<'EOF' +feat(crm): align contract no to sign date on create and update + +Persist aligned contract numbers for draft edits and keep name generation in sync. +EOF +)" +``` + +--- + +### Task 3: 前端 ContractFormDialog 即时预览 + +**Files:** +- Modify: `web-admin/src/views/crm/contract/components/ContractFormDialog.vue` + +- [ ] **Step 1: 增加同步辅助函数**(放在 `updateContractNoBySupplier` 附近) + +```typescript +const CONTRACT_NO_STD_RE = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/ + +/** 签订日变更时:标准编号只换日期段,保留序号;并同步名称括号内编号 */ +const syncContractNoDateFromSignDate = (signDateVal: string | null) => { + if (!signDateVal || !form.value.contractNo) return + const m = String(form.value.contractNo).match(CONTRACT_NO_STD_RE) + if (!m) return + + const dateStr = signDateVal.replace(/-/g, '') + if (dateStr.length !== 8) return + if (m[2] === dateStr) return + + const nextNo = `${m[1]}/X-JT${dateStr}${m[3]}` + form.value.contractNo = nextNo + + if (form.value.contractName) { + const replaced = form.value.contractName.replace(/[((][^))]*[))]$/, `(${nextNo})`) + if (replaced !== form.value.contractName) { + form.value.contractName = replaced + } else { + rebuildContractName() + } + } +} +``` + +- [ ] **Step 2: 在 `handleSignDateChange` 末尾调用** + +在现有期限更新逻辑之后、`nextTick` 校验之前增加: + +```typescript + syncContractNoDateFromSignDate(val) +``` + +完整函数结构: + +```typescript +const handleSignDateChange = (val: string | null) => { + if (val) { + // ... existing start/end date logic unchanged ... + } else { + // ... existing clear logic unchanged ... + } + syncContractNoDateFromSignDate(val) + nextTick(() => { + formRef.value?.validateField('startDate') + }) +} +``` + +说明:选报价单已调用 `handleSignDateChange(quote.quoteDate)`,无需另改。 + +- [ ] **Step 3: 手动核对(开发者)** + +1. 打开 `/#/crm/contract`,编辑或新增草稿,编号形如 `SX/X-JT20250803001`。 +2. 改签订日 → 编号日期段变、序号不变;名称括号同步。 +3. 把编号改成 `ABC-手工` → 再改签订日 → 编号不变。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/views/crm/contract/components/ContractFormDialog.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): preview contract no date when sign date changes + +Keep sequence, sync name parentheses; backend remains source of truth on save. +EOF +)" +``` + +--- + +### Task 4: 遗留 `form.vue` 对齐(若仍使用) + +**Files:** +- Modify: `web-admin/src/views/crm/contract/form.vue` + +- [ ] **Step 1: 确认路由是否仍入口该页** + +```bash +rg "contract/form|views/crm/contract/form" web-admin/src --glob '*.{ts,vue,js}' +``` + +若无路由引用,可跳过本 Task,在计划勾选处注明「未引用,跳过」。 + +- [ ] **Step 2: 若仍引用,复制 Task 3 同等逻辑** + +在 `form.vue` 的 `handleSignDateChange` 中增加相同的 `CONTRACT_NO_STD_RE` + `syncContractNoDateFromSignDate`(可用该文件已有的名称更新辅助函数替换括号编号)。 + +- [ ] **Step 3: Commit(仅当有改动)** + +```bash +git add web-admin/src/views/crm/contract/form.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): align legacy contract form sign-date no preview +EOF +)" +``` + +--- + +### Task 5: 验收与文档状态 + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-03-crm-contract-no-follow-sign-date-design.md`(状态改为已实现) +- 可选:`./scripts/update-graph.sh backend`(若改了 backend AST) + +- [ ] **Step 1: 跑后端单测** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_crm_contract_no_align.py -v +``` + +Expected: all PASS + +- [ ] **Step 2: 对照设计验收清单手工点检** + +1. 改签订日 → 日期段变、序号不变;名称同步 +2. 目标号占用 → 保存后序号顺延(可用 DB 预插一条同号草稿验证) +3. 非标准编号不联动 +4. 选报价单改签订日联动 +5. 换交易主体仍只改供方前缀 +6. 空编号新增仍按签订日生成 + +- [ ] **Step 3: 更新设计状态** + +将 spec 头部 `状态:已评审,待实现` 改为 `状态:已实现`。 + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-08-03-crm-contract-no-follow-sign-date-design.md +git commit -m "$(cat <<'EOF' +docs: mark contract-no follow sign-date design implemented +EOF +)" +``` + +--- + +## Spec coverage check + +| Spec 要求 | Task | +|-----------|------| +| 前端即时换日期段、保留序号 | Task 3(+4) | +| 后端 align + 撞号顺延 | Task 1–2 | +| 名称括号同步 | Task 2(`generate_contract_name`)+ Task 3 | +| 非标准不联动 | Task 1 单测 + Task 3 | +| 空号仍 generate | Task 2 create 分支 | +| 报价单路径 | Task 3(共用 `handleSignDateChange`) | +| 不改唯一索引/续签/历史刷数 | 未纳入任务 | + +## Placeholder scan + +无 TBD /「类似 Task N」占位;函数名前后一致:`replace_contract_no_date`、`align_contract_no_to_sign_date`、`syncContractNoDateFromSignDate`。