docs: add customer profile expand and overwrite sync plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-08 18:00:02 +08:00
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 |