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 <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-03 11:40:33 +08:00
1 parent 1c38d82079
commit f01057d902
1 file changed
+436
@@ -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`。