docs: add customer profile expand and overwrite sync plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e69ddd0d32
commit
702c8a2f12
1 file changed
+462
@@ -0,0 +1,462 @@
|
||||
# 客户主档扩展 + 协议单向覆盖同步 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:** 扩展 `biz_customer` 通用个人字段(出生年月/民族/邮箱/住址),客户管理可维护;协议保存时当事人快照照旧写入,并以协议非空字段覆盖对应客户主档。
|
||||
|
||||
**Architecture:** 当事人表 `biz_protocol_party_customer` 仍是协议快照权威(不拆子表)。在 `party_customer_sync` 中新增「角色档案覆盖」函数(有值覆盖、空不覆盖),与现有撞库 `merge_customer_fields`(空才补 + 标签并集)分离。CRUD/API/前端类型与客户管理 UI 同步四字段;协议选人时把主档扩展字段填入表单空位。
|
||||
|
||||
**Tech Stack:** MySQL 5.7、FastAPI + SQLAlchemy + Pydantic v2、Vue 3 + Element Plus、pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-08-customer-profile-expand-sync-design.md`
|
||||
|
||||
**Commits:** 本会话默认不自动 `git commit`(除非用户明确要求);完成各 Task 后勾选 checkbox 即可。
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `docs/sql/migrations/2026-10-08-customer-profile-expand.sql` | `biz_customer` 加四列 |
|
||||
| `backend/app/models/models.py` | `BizCustomer` 四列 |
|
||||
| `backend/app/crud/customer.py` | `_row_to_dict` / create / update |
|
||||
| `backend/app/api/basic_info.py` | Create/Update Body |
|
||||
| `backend/app/services/party_customer_sync.py` | 角色字段映射 + 有值覆盖 |
|
||||
| `backend/tests/test_party_customer_sync.py` | 覆盖 / 空不覆盖 / 母亲扩展字段 |
|
||||
| `web-admin/src/types/api.d.ts` | `CustomerItem` 四字段 |
|
||||
| `web-admin/src/views/basic-info/customer/index.vue` | 表单 / 查看 / payload |
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` | 选母亲回填扩展字段 |
|
||||
| `web-admin/src/views/research-donation/list/DonationFormDialog.vue` | 同上 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SQL 迁移 + ORM
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-08-customer-profile-expand.sql`
|
||||
- Modify: `backend/app/models/models.py`(`BizCustomer`,约 327–347 行)
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- 客户主档:通用个人档案字段
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-customer-profile-expand-sync-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_customer`
|
||||
ADD COLUMN `birth_ym` char(6) DEFAULT NULL COMMENT '出生年月YYYYMM' AFTER `age`,
|
||||
ADD COLUMN `ethnic` varchar(32) DEFAULT NULL COMMENT '民族' AFTER `birth_ym`,
|
||||
ADD COLUMN `email` varchar(128) DEFAULT NULL COMMENT '邮箱' AFTER `ethnic`,
|
||||
ADD COLUMN `address` varchar(512) DEFAULT NULL COMMENT '家庭住址' AFTER `email`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ORM 增加字段**
|
||||
|
||||
在 `BizCustomer` 中 `age` 与 `phone_no` 之间(或与迁移 AFTER 顺序一致)增加:
|
||||
|
||||
```python
|
||||
birth_ym = Column(CHAR(6), nullable=True, comment="出生年月YYYYMM")
|
||||
ethnic = Column(String(32), nullable=True, comment="民族")
|
||||
email = Column(String(128), nullable=True, comment="邮箱")
|
||||
address = Column(String(512), nullable=True, comment="家庭住址")
|
||||
```
|
||||
|
||||
确认文件顶部已 `from sqlalchemy import ... CHAR`(或与同文件其他 `CHAR` 用法一致)。
|
||||
|
||||
- [ ] **Step 3: 本地执行迁移**(按 `.env.local` 库)
|
||||
|
||||
```bash
|
||||
mysql -h 127.0.0.1 -u <user> -p <database> < docs/sql/migrations/2026-10-08-customer-profile-expand.sql
|
||||
```
|
||||
|
||||
Expected: 无报错;`SHOW COLUMNS FROM biz_customer LIKE 'birth_ym';` 有行。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Customer CRUD + API Body
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/customer.py`
|
||||
- Modify: `backend/app/api/basic_info.py`(`CustomerCreateBody` / `CustomerUpdateBody`)
|
||||
|
||||
- [ ] **Step 1: 扩展 `_row_to_dict`**
|
||||
|
||||
在 `age` 后增加:
|
||||
|
||||
```python
|
||||
"birthYm": c.birth_ym or "",
|
||||
"ethnic": c.ethnic or "",
|
||||
"email": c.email or "",
|
||||
"address": c.address or "",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 扩展 `create_customer` / `update_customer` 签名与赋值**
|
||||
|
||||
`create_customer` 增加可选参数:
|
||||
|
||||
```python
|
||||
birth_ym: Optional[str] = None,
|
||||
ethnic: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
```
|
||||
|
||||
构造 `BizCustomer` 时:
|
||||
|
||||
```python
|
||||
birth_ym=(birth_ym or "").strip()[:6] or None,
|
||||
ethnic=(ethnic or "").strip() or None,
|
||||
email=(email or "").strip() or None,
|
||||
address=(address or "").strip() or None,
|
||||
```
|
||||
|
||||
`update_customer`:各参数 `is not None` 时写入;空串写 `None`(与 phone 等一致)。
|
||||
|
||||
- [ ] **Step 3: 扩展 Pydantic Body**
|
||||
|
||||
`CustomerCreateBody` / `CustomerUpdateBody` 增加:
|
||||
|
||||
```python
|
||||
birth_ym: Optional[str] = Field(None, alias="birthYm")
|
||||
ethnic: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
```
|
||||
|
||||
创建/更新路由调用 `create_customer` / `update_customer` 时传入这四个字段(若路由内是 `body.xxx` 展开,一并补上)。
|
||||
|
||||
- [ ] **Step 4: 快速冒烟(可选)**
|
||||
|
||||
启动后端后 `POST /api/web/admin/customers` 带 `birthYm`/`ethnic`/`email`/`address`,`GET` 列表项应原样返回。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 同步覆盖逻辑(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_party_customer_sync.py`
|
||||
- Modify: `backend/app/services/party_customer_sync.py`
|
||||
|
||||
**设计要点(实现时必须遵守):**
|
||||
|
||||
- 保留 `merge_customer_fields`:仅用于撞库时两客户记录「空才补 + 标签并集」(现有单测继续通过)。
|
||||
- 新增 `apply_role_profile_overwrite(target, profile: SimpleNamespace)`:对 `customer_name` / `phone_no` / `id_card_no` / `sex` / `age` / `birth_ym` / `ethnic` / `email` / `address` —— **源有非空值则覆盖,空则跳过**;不碰 `customer_tags` / `remark`。
|
||||
- `_ROLE_SPECS` 为母亲增加 keys:`birth_ym_keys`、`ethnic_keys`、`email_keys`、`address_keys`;父亲/子女对应 keys 为空元组或仅有实际存在的字段。
|
||||
- `_sync_one_role`:从 party 抽出 profile → 创建时传入 create;更新 linked/found 时先(撞库则 merge)再 `apply_role_profile_overwrite`。
|
||||
- `_create_role_customer` / `create_customer` 调用传入新字段。
|
||||
- `_apply_direct_identity` 可改为调用 `apply_role_profile_overwrite`,或扩展为同样覆盖四字段。
|
||||
|
||||
- [ ] **Step 1: 写失败单测(覆盖 + 空不覆盖 + 母亲扩展)**
|
||||
|
||||
在 `test_party_customer_sync.py` 追加:
|
||||
|
||||
```python
|
||||
from app.services.party_customer_sync import apply_role_profile_overwrite
|
||||
|
||||
|
||||
class TestApplyRoleProfileOverwrite(unittest.TestCase):
|
||||
def test_nonempty_overwrites_empty_skips(self):
|
||||
target = MagicMock(
|
||||
customer_name="旧名",
|
||||
phone_no="111",
|
||||
id_card_no="A",
|
||||
sex="1",
|
||||
age=20,
|
||||
birth_ym="199001",
|
||||
ethnic="汉族",
|
||||
email="old@x.com",
|
||||
address="旧址",
|
||||
)
|
||||
apply_role_profile_overwrite(
|
||||
target,
|
||||
MagicMock(
|
||||
customer_name="新名",
|
||||
phone_no="222",
|
||||
id_card_no="",
|
||||
sex="1",
|
||||
age=None,
|
||||
birth_ym="199102",
|
||||
ethnic="",
|
||||
email="new@x.com",
|
||||
address=None,
|
||||
),
|
||||
)
|
||||
self.assertEqual(target.customer_name, "新名")
|
||||
self.assertEqual(target.phone_no, "222")
|
||||
self.assertEqual(target.id_card_no, "A") # 空不覆盖
|
||||
self.assertEqual(target.birth_ym, "199102")
|
||||
self.assertEqual(target.ethnic, "汉族") # 空不覆盖
|
||||
self.assertEqual(target.email, "new@x.com")
|
||||
self.assertEqual(target.address, "旧址")
|
||||
|
||||
def test_mother_sync_overwrites_extended_fields_on_linked(self):
|
||||
db = MagicMock()
|
||||
linked = MagicMock(
|
||||
id="cust-a",
|
||||
customer_name="旧名",
|
||||
phone_no="111",
|
||||
id_card_no="110101199001011234",
|
||||
sex="1",
|
||||
age=30,
|
||||
birth_ym="199001",
|
||||
ethnic="回族",
|
||||
email="old@x.com",
|
||||
address="旧址",
|
||||
)
|
||||
party = {
|
||||
"mother_name": "妈",
|
||||
"mother_phone": "13900000000",
|
||||
"mother_id_card": "110101199001011234",
|
||||
"mother_birth_ym": "199002",
|
||||
"mother_ethnic": "汉族",
|
||||
"mother_email": "new@x.com",
|
||||
"mother_address": "新址",
|
||||
"customer_id": "cust-a",
|
||||
}
|
||||
with patch("app.services.party_customer_sync.find_customer_by_id_card", return_value=None):
|
||||
with patch("app.services.party_customer_sync.get_customer", return_value=linked):
|
||||
with patch("app.services.party_customer_sync.customer_crud.create_customer") as create:
|
||||
out = sync_party_customers(db, party, tenant_no="zkxw", user_id="u1")
|
||||
create.assert_not_called()
|
||||
self.assertEqual(out.get("customer_id"), "cust-a")
|
||||
self.assertEqual(linked.customer_name, "妈")
|
||||
self.assertEqual(linked.phone_no, "13900000000")
|
||||
self.assertEqual(linked.birth_ym, "199002")
|
||||
self.assertEqual(linked.ethnic, "汉族")
|
||||
self.assertEqual(linked.email, "new@x.com")
|
||||
self.assertEqual(linked.address, "新址")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py::TestApplyRoleProfileOverwrite -v
|
||||
```
|
||||
|
||||
Expected: FAIL(`apply_role_profile_overwrite` 未定义或行为不符)。
|
||||
|
||||
- [ ] **Step 3: 实现 `apply_role_profile_overwrite` 与角色映射**
|
||||
|
||||
示例实现骨架:
|
||||
|
||||
```python
|
||||
_PROFILE_ATTRS = (
|
||||
"customer_name",
|
||||
"phone_no",
|
||||
"id_card_no",
|
||||
"sex",
|
||||
"age",
|
||||
"birth_ym",
|
||||
"ethnic",
|
||||
"email",
|
||||
"address",
|
||||
)
|
||||
|
||||
|
||||
def apply_role_profile_overwrite(target: Any, profile: Any) -> None:
|
||||
for attr in _PROFILE_ATTRS:
|
||||
val = getattr(profile, attr, None)
|
||||
if _is_empty(val):
|
||||
continue
|
||||
if attr == "sex":
|
||||
setattr(target, attr, str(val).strip()[:1])
|
||||
elif attr == "birth_ym":
|
||||
setattr(target, attr, str(val).strip()[:6])
|
||||
elif attr == "age":
|
||||
setattr(target, attr, val)
|
||||
else:
|
||||
setattr(target, attr, str(val).strip() if not isinstance(val, int) else val)
|
||||
```
|
||||
|
||||
母亲 `_ROLE_SPECS` 项增加:
|
||||
|
||||
```python
|
||||
"birth_ym_keys": ("mother_birth_ym", "motherBirthYm"),
|
||||
"ethnic_keys": ("mother_ethnic", "motherEthnic"),
|
||||
"email_keys": ("mother_email", "motherEmail"),
|
||||
"address_keys": ("mother_address", "motherAddress"),
|
||||
```
|
||||
|
||||
父亲/子女:这些 keys 设为 `()`。
|
||||
|
||||
在 `_sync_one_role` 内构造 profile(含 name→`customer_name` 等),对 found/linked 在 merge 之后调用 `apply_role_profile_overwrite`;创建路径把扩展字段传入 `create_customer`。
|
||||
|
||||
注意:撞库分支当前 `merge_customer_fields(found, SimpleNamespace(phone...))` 是空才补——改为:撞库时仍 `merge_customer_fields(found, source_customer)`(两客户合并),再对 `found` 执行 `apply_role_profile_overwrite(found, party_profile)`,保证协议非空字段最终覆盖。
|
||||
|
||||
同步调整 `_apply_direct_identity`,使姓名/电话/身份证/扩展字段走覆盖语义(与单测 `test_name_and_id_card_keeps_linked_id_when_not_found` 兼容:有值仍写入)。
|
||||
|
||||
- [ ] **Step 4: 全量跑该文件**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py -v
|
||||
```
|
||||
|
||||
Expected: 全部 PASS(含原有 merge 空才补用例)。
|
||||
|
||||
若 `test_id_card_collision_merges_and_repoints` 因「协议无 phone 但 merge 后期望 B.phone=111」仍依赖 merge:保持 merge 行为;若协议带了 `motherPhone`,断言改为协议覆盖值——按实际 party 构造保持原断言可通过。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端类型 + 客户管理 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/types/api.d.ts`(`Api.BizCustomer.CustomerItem`)
|
||||
- Modify: `web-admin/src/views/basic-info/customer/index.vue`
|
||||
|
||||
- [ ] **Step 1: 扩展 `CustomerItem`**
|
||||
|
||||
```typescript
|
||||
birthYm: string
|
||||
ethnic: string
|
||||
email: string
|
||||
address: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 表单增加四项**
|
||||
|
||||
在 `formData` / `resetForm` / `handleEdit` / `handleSubmit` payload 增加 `birthYm`、`ethnic`、`email`、`address`。
|
||||
|
||||
模板(`form-grid-2` 内,电话后或标签前)示意:
|
||||
|
||||
```vue
|
||||
<el-form-item label="出生年月">
|
||||
<el-date-picker
|
||||
v-model="formData.birthYm"
|
||||
type="month"
|
||||
value-format="YYYYMM"
|
||||
format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="民族">
|
||||
<el-select
|
||||
v-model="formData.ethnic"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
:loading="ethnicLoading"
|
||||
placeholder="请选择或检索民族"
|
||||
>
|
||||
<el-option
|
||||
v-for="e in ethnicOptions"
|
||||
:key="e.ethnicCode"
|
||||
:label="e.ethnicName"
|
||||
:value="e.ethnicName"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="formData.email" placeholder="可选" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="家庭住址" class="form-col-span-2">
|
||||
<el-input v-model="formData.address" placeholder="可选" />
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
复用 `@/api/ethnic` 的 `fetchEthnicList`(对齐 `ProtocolFormDialog` 的 `loadEthnicOptions`)。页面 `onMounted` 或打开弹窗时加载民族列表。
|
||||
|
||||
- [ ] **Step 3: 查看弹窗 `viewSections` 基本信息增加**
|
||||
|
||||
```typescript
|
||||
{ label: '出生年月', value: formatYm(row.birthYm) }, // 可内联:YYYYMM → YYYY-MM
|
||||
{ label: '民族', value: row.ethnic },
|
||||
{ label: '邮箱', value: row.email },
|
||||
{ label: '家庭住址', value: row.address },
|
||||
```
|
||||
|
||||
列表列可不加(spec 允许);至少查看齐全。
|
||||
|
||||
- [ ] **Step 4: 本地 UI 冒烟**
|
||||
|
||||
打开 `/#/market/market-info/customer`:新增带四字段 → 保存 → 查看可见 → 编辑改邮箱再保存。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 协议选人回填扩展字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`(`onMotherCustomerSelect`)
|
||||
- Modify: `web-admin/src/views/research-donation/list/DonationFormDialog.vue`(同名逻辑)
|
||||
|
||||
- [ ] **Step 1: 母亲选中时回填**
|
||||
|
||||
在现有赋值后增加(仅填主档有值的字段;若希望「只填表单空位」,用 `if (!formData.motherEmail) ...` 判断):
|
||||
|
||||
```typescript
|
||||
const birthYm = String(item.birthYm || '').trim()
|
||||
const ethnic = String(item.ethnic || '').trim()
|
||||
const email = String(item.email || '').trim()
|
||||
const address = String(item.address || '').trim()
|
||||
if (birthYm) formData.motherBirthYm = birthYm
|
||||
if (ethnic) {
|
||||
formData.motherEthnic = ethnic
|
||||
ensureEthnicOptionInList?.(ethnic) // 若页面已有该 helper
|
||||
}
|
||||
if (email) formData.motherEmail = email
|
||||
if (address) formData.motherAddress = address
|
||||
```
|
||||
|
||||
spec:选人时把主档填入表单空位——实现为:**表单该字段为空时才用主档填充**;姓名/电话/身份证可保持现有「选人即带出」行为(与现状一致)。
|
||||
|
||||
空位示例:
|
||||
|
||||
```typescript
|
||||
if (!(formData.motherEmail || '').trim() && email) formData.motherEmail = email
|
||||
if (!(formData.motherAddress || '').trim() && address) formData.motherAddress = address
|
||||
if (!(formData.motherBirthYm || '').trim() && birthYm) formData.motherBirthYm = birthYm
|
||||
if (!(formData.motherEthnic || '').trim() && ethnic) {
|
||||
formData.motherEthnic = ethnic
|
||||
ensureEthnicOptionInList(ethnic)
|
||||
}
|
||||
```
|
||||
|
||||
父亲侧无协议扩展字段源,无需回填四新字段。
|
||||
|
||||
- [ ] **Step 2: 科研捐赠表单同样改 `onMotherCustomerSelect`**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 验收与图谱
|
||||
|
||||
- [ ] **Step 1: 跑后端相关测试**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py tests/test_customer_relations.py -v
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 2: 按 spec §6 手工验收**
|
||||
|
||||
1. 协议填全母亲扩展字段保存 → 当事人有快照 + 客户主档一致。
|
||||
2. 客户管理改电话 → 旧协议查看仍是当事人旧电话。
|
||||
3. 再保存协议(新电话)→ 客户主档电话被覆盖。
|
||||
4. 协议某字段清空再保存 → 客户该字段不被清空。
|
||||
5. 客户管理独立维护四字段。
|
||||
6. 父亲同步不误清空客户已有邮箱/住址。
|
||||
|
||||
- [ ] **Step 3: 更新知识图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| `biz_customer` 四列 | Task 1 |
|
||||
| CRUD/API `_row_to_dict` / Body | Task 2 |
|
||||
| 有值覆盖、空不覆盖 | Task 3 |
|
||||
| 撞库 merge 与覆盖分离 | Task 3 |
|
||||
| 客户管理 UI | Task 4 |
|
||||
| 协议选人回填空位 | Task 5 |
|
||||
| 不拆当事人子表 / 不回写协议 | 全程不改当事人表结构、无客户→协议写回 |
|
||||
| 新生儿业务字段不进客户 | Task 3 映射不含分娩字段 |
|
||||
| 验收 | Task 6 |
|
||||
Reference in new issue
Block a user