diff --git a/docs/superpowers/plans/2026-10-08-customer-profile-expand-sync.md b/docs/superpowers/plans/2026-10-08-customer-profile-expand-sync.md new file mode 100644 index 00000000..ef0aa06a --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-customer-profile-expand-sync.md @@ -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 -p < 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 + + + + + + + + + + + + + + +``` + +复用 `@/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 |