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:
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`。
|
||||
Reference in new issue
Block a user