From 69e76b85ab2ed74698127f95f63ff0c43cca5246 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 7 Oct 2026 15:54:15 +0800 Subject: [PATCH] docs: add implementation plan for customer family protocol link. Break work into schema, sync service, save wiring, relations API, backfill, and customer detail UI tasks. Co-authored-by: Cursor --- ...026-10-07-customer-family-protocol-link.md | 559 ++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-customer-family-protocol-link.md diff --git a/docs/superpowers/plans/2026-10-07-customer-family-protocol-link.md b/docs/superpowers/plans/2026-10-07-customer-family-protocol-link.md new file mode 100644 index 00000000..474f9dd7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-customer-family-protocol-link.md @@ -0,0 +1,559 @@ +# 客户管理 · 协议家庭成员关联 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` 并写回当事人三个客户 ID;客户详情按协议展示关联人与协议摘要,点协议号打开只读详情。 + +**Architecture:** 当事人表各增 `father_customer_id` / `newborn_customer_id`(母亲沿用 `customer_id`)。公共同步服务 `party_customer_sync` 在保存当前协议时 upsert 三人,补证撞库则合并后改挂并软删临时客户。详情用 `GET .../customers/{id}/relations` 聚合两边当事人。前端客户查看弹窗扩展关联区块与协议弹窗。 + +**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus;pytest + +**Design Doc:** `docs/superpowers/specs/2026-10-07-customer-family-protocol-link-design.md` + +--- + +## File Structure Map + +### New files + +| File | Responsibility | +|------|----------------| +| `docs/sql/migrations/2026-10-07-party-father-newborn-customer-id.sql` | 两张当事人表加列 | +| `backend/app/services/party_customer_sync.py` | 三角色 upsert、撞库合并、改挂、存量回填入口 | +| `backend/app/services/customer_relations.py` | 按客户 ID 聚合关联协议与同单成员 | +| `backend/tests/test_party_customer_sync.py` | 同步 / 合并规则单测 | +| `backend/tests/test_customer_relations.py` | 关联聚合单测 | +| `backend/scripts/backfill_party_customer_ids.py` | 存量幂等回填脚本 | + +### Modified files + +| File | Change | +|------|--------| +| `backend/app/models/models.py` | `BizProtocolPartyCustomer` 加两列 | +| `backend/app/models/research_donation.py` | `BizResearchDonationParty` 加两列 | +| `backend/app/crud/collection_protocol.py` | `_party_to_dict` / `upsert_party_customer` 支持新字段 | +| `backend/app/crud/research_donation.py` | `_party_to_dict` / `_PARTY_STR_FIELDS` / convert 复制字段 | +| `backend/app/api/collection_protocol.py` | 用 sync 服务替换 `_ensure_customer_for_mother` | +| `backend/app/api/research_donation.py` 或 `crud/research_donation.py` | create/update party 前调用 sync | +| `backend/app/api/basic_info.py` | `GET /customers/{id}/relations` | +| `web-admin/src/api/admin.ts` | `fetchCustomerRelations` | +| `web-admin/src/types/api.d.ts` | Relations 类型 | +| `web-admin/src/components/business/system-view-dialog/index.vue` | 增加 `#append` slot(基本信息之后) | +| `web-admin/src/views/basic-info/customer/index.vue` | 详情加载 relations;关联客户/协议;点协议号弹窗 | +| `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`(若 payload 需透传) | 确保当事人可带 `fatherCustomerId` / `newbornCustomerId`(读回写回即可,一般后端覆盖) | + +### Unchanged (explicit) + +- `biz_customer` 表结构 +- 中试患者 ↔ 客户逻辑 +- 不新建关系表 + +--- + +### Task 1: 数据库迁移 + 模型字段 + +**Files:** +- Create: `docs/sql/migrations/2026-10-07-party-father-newborn-customer-id.sql` +- Modify: `backend/app/models/models.py`(`BizProtocolPartyCustomer`) +- Modify: `backend/app/models/research_donation.py`(`BizResearchDonationParty`) + +- [ ] **Step 1: 写迁移 SQL** + +```sql +-- 采集协议当事人:父亲/子女客户 ID(母亲沿用 customer_id) +ALTER TABLE biz_protocol_party_customer + ADD COLUMN father_customer_id VARCHAR(64) DEFAULT NULL COMMENT '父亲客户ID' AFTER customer_id, + ADD COLUMN newborn_customer_id VARCHAR(64) DEFAULT NULL COMMENT '子女客户ID' AFTER father_customer_id; + +-- 科研捐赠当事人:同上 +ALTER TABLE biz_research_donation_party + ADD COLUMN father_customer_id VARCHAR(64) DEFAULT NULL COMMENT '父亲客户ID' AFTER customer_id, + ADD COLUMN newborn_customer_id VARCHAR(64) DEFAULT NULL COMMENT '子女客户ID' AFTER father_customer_id; +``` + +- [ ] **Step 2: 更新 SQLAlchemy 模型** + +在 `BizProtocolPartyCustomer` 的 `customer_id` 后增加: + +```python +father_customer_id = Column(String(64), nullable=True, comment="父亲客户ID") +newborn_customer_id = Column(String(64), nullable=True, comment="子女客户ID") +``` + +在 `BizResearchDonationParty` 同样增加两列(注释写明母亲仍用 `customer_id`)。 + +- [ ] **Step 3: Commit** + +```bash +git add docs/sql/migrations/2026-10-07-party-father-newborn-customer-id.sql \ + backend/app/models/models.py backend/app/models/research_donation.py +git commit -m "feat(db): add father/newborn customer ids on protocol parties" +``` + +--- + +### Task 2: `party_customer_sync` 核心逻辑(TDD) + +**Files:** +- Create: `backend/app/services/party_customer_sync.py` +- Create: `backend/tests/test_party_customer_sync.py` + +- [ ] **Step 1: 写失败单测(无 DB 依赖的纯函数优先;需要 Session 的用 unittest.mock)** + +```python +"""当事人三角色客户同步 / 撞库合并""" +import unittest +from unittest.mock import MagicMock, patch + +from app.services.party_customer_sync import ( + merge_customer_fields, + sync_party_customers, + ROLE_MOTHER, + ROLE_FATHER, + ROLE_NEWBORN, +) + + +class TestMergeCustomerFields(unittest.TestCase): + def test_fill_empty_only_and_union_tags(self): + target = MagicMock( + customer_name="已有", + phone_no="111", + age=None, + sex="2", + remark="", + customer_tags="vip", + ) + source = MagicMock( + phone_no="222", + age=30, + sex="1", + remark="备注A", + customer_tags="a,vip", + ) + merge_customer_fields(target, source) + self.assertEqual(target.phone_no, "111") # 不覆盖 + self.assertEqual(target.age, 30) + self.assertEqual(target.sex, "1") # 目标为未知才补 + self.assertEqual(target.remark, "备注A") + tags = set((target.customer_tags or "").split(",")) + self.assertEqual(tags, {"vip", "a"}) + self.assertEqual(target.customer_name, "已有") + + +class TestSyncPartyCustomers(unittest.TestCase): + def test_no_name_clears_role_id(self): + db = MagicMock() + party = { + "motherName": "", + "customerId": "old-m", + "fatherName": "", + "fatherCustomerId": "old-f", + } + out = sync_party_customers(db, party, tenant_no="zkxw", user_id="u1") + self.assertEqual(out.get("customer_id") or out.get("customerId"), "") + self.assertEqual(out.get("father_customer_id") or out.get("fatherCustomerId"), "") + + def test_three_names_without_id_card_creates_three(self): + db = MagicMock() + created = [] + + def fake_create(db_, **kwargs): + obj = MagicMock() + obj.id = f"c{len(created)+1}" + created.append(kwargs) + return obj + + party = { + "motherName": "妈", + "fatherName": "爸", + "newbornName": "宝", + "newbornSex": "0", + } + with patch("app.services.party_customer_sync.customer_crud.create_customer", side_effect=fake_create): + 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=None): + out = sync_party_customers(db, party, tenant_no="zkxw", user_id="u1") + self.assertEqual(len(created), 3) + self.assertTrue(out.get("customer_id") or out.get("customerId")) + self.assertTrue(out.get("father_customer_id") or out.get("fatherCustomerId")) + self.assertTrue(out.get("newborn_customer_id") or out.get("newbornCustomerId")) +``` + +再补撞库合并用例:已有临时客户 A + 身份证命中 B → 调用 `repoint_customer_refs` + 软删 A,返回 B 的 id(可用 mock 断言调用)。 + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate +pytest tests/test_party_customer_sync.py -v +``` + +Expected: FAIL(模块或符号不存在) + +- [ ] **Step 3: 实现 `party_customer_sync.py`** + +实现要点(完整代码按 spec §4–§5): + +```python +# backend/app/services/party_customer_sync.py +ROLE_MOTHER = "mother" +ROLE_FATHER = "father" +ROLE_NEWBORN = "newborn" + +# 字段映射:角色 → (name_keys, phone_keys, id_card_keys, customer_id_keys, default_sex) +# sync_party_customers(db, party_dict, tenant_no, user_id) -> dict(snake 键写回) +# ensure_one_role(...): 无姓名清 ID;有姓名按证查找/新建/更新/合并 +# merge_customer_fields(target, source) +# repoint_customer_refs(db, from_id, to_id, tenant_no): 更新两边当事人三列 +# soft_delete via customer_crud 或直接 del_flag=1 +``` + +输出字典必须用 snake_case 键:`customer_id`、`father_customer_id`、`newborn_customer_id`,以便 `upsert_party_customer` / `_create_party` 直接吃。输入兼容 camelCase。 + +有姓名无证、且本角色尚无客户 ID:`create_customer`。 +有姓名有证未撞库且已有客户 ID:更新该客户(姓名、电话、身份证),不换 ID。 +撞库且 `A != B`:`merge_customer_fields(B, A)` → `repoint_customer_refs(A→B)` → 软删 A → 返回 B.id。 + +年龄:可选简单从 18 位身份证第 7–14 位算周岁;算不出则 `None`。 + +- [ ] **Step 4: 跑测通过** + +```bash +pytest tests/test_party_customer_sync.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/party_customer_sync.py backend/tests/test_party_customer_sync.py +git commit -m "feat: sync mother/father/newborn customers on protocol party save" +``` + +--- + +### Task 3: CRUD / API 读写新字段 + 接入保存路径 + +**Files:** +- Modify: `backend/app/crud/collection_protocol.py`(`_party_to_dict`、`upsert_party_customer` 的 `_STR_FIELDS`) +- Modify: `backend/app/crud/research_donation.py`(`_party_to_dict`、`_PARTY_STR_FIELDS`、双向转化复制 `father_customer_id`/`newborn_customer_id`) +- Modify: `backend/app/api/collection_protocol.py`(删除或废弃 `_ensure_customer_for_mother`,在 `_persist_protocol_sub_resources` 调用 `sync_party_customers`) +- Modify: `backend/app/crud/research_donation.py` 的 `create_donation` / `update_donation`(在 `_create_party` 前 sync) + +- [ ] **Step 1: 扩展 dict / 字段列表** + +`collection_protocol._party_to_dict` 增加: + +```python +"fatherCustomerId": p.father_customer_id or "", +"newbornCustomerId": p.newborn_customer_id or "", +``` + +`upsert_party_customer` 的 `_STR_FIELDS` 增加 `"father_customer_id"`, `"newborn_customer_id"`。 + +科研捐赠 `_party_to_dict` / `_PARTY_STR_FIELDS` 同样增加;`convert` 复制 party 时带上两列。 + +- [ ] **Step 2: 采集协议保存接入** + +在 `_persist_protocol_sub_resources`: + +```python +from app.services.party_customer_sync import sync_party_customers + +if party_customer: + _parse_datetime_fields(party_customer, ["newbornDeliveryTime"]) + party_customer = sync_party_customers( + db, party_customer, tenant_no=tenant_no, user_id=user_id + ) + cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer) +``` + +删除对 `_ensure_customer_for_mother` 的调用;可删函数本身以免双轨。 + +- [ ] **Step 3: 科研捐赠保存接入** + +在 `create_donation` / `update_donation` 中,归一化 `party` 后: + +```python +from app.services.party_customer_sync import sync_party_customers + +party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id) +_create_party(db, donation_id, tenant_no, user_id, party_raw, now) +``` + +(update 路径:软删旧 party 后同样先 sync 再 create。) + +- [ ] **Step 4: 跑同步单测 + 相关协议测试冒烟** + +```bash +pytest tests/test_party_customer_sync.py tests/test_collection_protocol_save_bundle.py -v --tb=short +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/crud/collection_protocol.py backend/app/crud/research_donation.py \ + backend/app/api/collection_protocol.py +git commit -m "feat: wire party customer sync into storage and donation saves" +``` + +--- + +### Task 4: 客户关联查询 API(TDD) + +**Files:** +- Create: `backend/app/services/customer_relations.py` +- Create: `backend/tests/test_customer_relations.py` +- Modify: `backend/app/api/basic_info.py` + +- [ ] **Step 1: 写聚合单测(mock 查询结果)** + +覆盖:同一客户两张协议 → `relatedGroups` 长度 2;角色识别 mother/father/newborn;`protocols` 与 groups 同源字段。 + +- [ ] **Step 2: 实现 `build_customer_relations(db, customer_id, tenant_no)`** + +逻辑: + +1. 查 `BizCustomer`(租户 + 未删),没有则返回 `None`。 +2. 查 `BizProtocolPartyCustomer`:`del_flag=0` 且三 ID 任一等于 customer_id;join 主表取 `protocol_no`/`protocol_status`/`register_date`/`create_time`。 +3. 查 `BizResearchDonationParty` 同样,join 捐赠主表取 `donation_no`/`donation_status`/`donation_date`。 +4. 合并后按主表 `create_time` 倒序。 +5. 每组 `members`:有姓名的角色都列入;`customerId` 可空但姓名用当事人字段;已删客户显示当事人姓名。 +6. `myRole` 按哪个 ID 列命中判定。 + +返回结构与 spec §7.2 一致。 + +- [ ] **Step 3: 注册路由** + +在 `basic_info.py`,放在 `GET /customers` 与 `PUT /customers/{id}` 之间注意路径冲突——`relations` 子路径须注册在会吞掉的动态路由之前,或使用: + +```python +@router.get("/customers/{customer_id}/relations") +async def get_customer_relations( + customer_id: str, + user: dict = Depends(require_menu_code("biz:customer:list")), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + data = build_customer_relations(db, customer_id, tenant_no) + if not data: + return error_response(404, "客户不存在") + return success_response(data) +``` + +- [ ] **Step 4: 跑测** + +```bash +pytest tests/test_customer_relations.py tests/test_party_customer_sync.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/customer_relations.py backend/tests/test_customer_relations.py \ + backend/app/api/basic_info.py +git commit -m "feat: add customer relations API for family and protocols" +``` + +--- + +### Task 5: 存量回填脚本 + +**Files:** +- Create: `backend/scripts/backfill_party_customer_ids.py` + +- [ ] **Step 1: 实现幂等脚本** + +```python +"""回填当事人父亲/子女/缺失母亲客户 ID。可重复执行。 + +用法(在 backend 目录、已配置 .env): + python -m scripts.backfill_party_customer_ids +""" +# 对每条未删 party:组装 dict → sync_party_customers → 写回三 ID(仅当有变更) +# 细胞存储用 upsert_party_customer;捐赠用更新现有 party 行字段后 commit +# 打印处理条数 / 跳过条数 +``` + +脚本内部调用与保存相同的 `sync_party_customers`,禁止另写一套匹配规则。 + +- [ ] **Step 2: Commit** + +```bash +git add backend/scripts/backfill_party_customer_ids.py +git commit -m "chore: add idempotent backfill for party customer ids" +``` + +--- + +### Task 6: 前端类型、API、查看弹窗 + +**Files:** +- Modify: `web-admin/src/types/api.d.ts` +- Modify: `web-admin/src/api/admin.ts` +- Modify: `web-admin/src/components/business/system-view-dialog/index.vue` +- Modify: `web-admin/src/views/basic-info/customer/index.vue` + +- [ ] **Step 1: 类型与 API** + +`api.d.ts` 的 `BizCustomer` 增加: + +```ts +interface RelationMember { + role: 'mother' | 'father' | 'newborn' + customerId: string + name: string + phone: string + idCardNo: string +} +interface RelationGroup { + bizType: 'cell_storage' | 'research_donation' + protocolId: string + protocolNo: string + protocolStatus: string + registerDate: string + myRole: 'mother' | 'father' | 'newborn' + members: RelationMember[] +} +interface ProtocolSummary { + bizType: 'cell_storage' | 'research_donation' + protocolId: string + protocolNo: string + protocolStatus: string + registerDate: string + myRole: 'mother' | 'father' | 'newborn' +} +interface CustomerRelations { + customer: CustomerItem + relatedGroups: RelationGroup[] + protocols: ProtocolSummary[] +} +``` + +`admin.ts`: + +```ts +export function fetchCustomerRelations(id: string) { + return request.get({ + url: `/web/admin/customers/${id}/relations` + }) +} +``` + +- [ ] **Step 2: SystemViewDialog 增加 append slot** + +在 sections 循环之后、滚动容器结束前: + +```vue +
+ +
+``` + +样式对齐 `.view-prepend`(若有)。 + +- [ ] **Step 3: 客户页详情** + +`handleView` 改为加载 relations: + +```ts +async function handleView(row: Api.BizCustomer.CustomerItem) { + viewRow.value = row + viewVisible.value = true + relationsLoading.value = true + try { + const res = await fetchCustomerRelations(row.id) + relations.value = res.data // 按项目 request 解包习惯调整 + viewRow.value = relations.value.customer + } finally { + relationsLoading.value = false + } +} +``` + +`SystemViewDialog` 增加 `#append`: + +1. **关联客户**:`v-for` `relatedGroups`;组头显示类型 Tag + 协议号链接 + 本人角色;成员表/列表,本人标「本人」,他人姓名可点 → 再调 `handleView({ id: member.customerId, ... })`。 +2. **关联协议**:`el-table` 或简洁列表;协议号 `@click`: + - `cell_storage` → 打开 `ProtocolViewDialog`(`:protocol-id` / 现有 props) + - `research_donation` → 打开 `DonationViewDialog` +3. 空态:「暂无关联协议」。 + +协议状态展示用现有 `status-tag-registry`(若已有协议/捐赠状态 label 函数则复用)。 + +Import: + +```ts +import ProtocolViewDialog from '@/views/cell-storage/protocol/ProtocolViewDialog.vue' +import DonationViewDialog from '@/views/research-donation/list/DonationViewDialog.vue' +``` + +按钮图标规则:若新增可点链接旁的按钮,须带 `el-icon`。 + +- [ ] **Step 4: 本地类型检查(可选冒烟)** + +```bash +cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -60 +``` + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/types/api.d.ts web-admin/src/api/admin.ts \ + web-admin/src/components/business/system-view-dialog/index.vue \ + web-admin/src/views/basic-info/customer/index.vue +git commit -m "feat(web-admin): show related family and protocols on customer detail" +``` + +--- + +### Task 7: 图谱更新 + 手工验收清单 + +**Files:** 无业务代码(或仅 graphify 产物) + +- [ ] **Step 1: 更新知识图谱** + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +``` + +- [ ] **Step 2: 手工验收(本地已跑迁移 + 回填后)** + +1. 新建细胞存储协议,填母/父/子女姓名(无证)→ 客户列表出现 3 人;三人详情互相看到同组。 +2. 给子女补身份证且库中已有同证客户 → 临时户软删,协议挂到已有客户。 +3. 同一母亲第二张协议 → 详情两组关联。 +4. 科研捐赠同样验证。 +5. 点协议号弹出对应只读弹窗。 +6. 回填脚本跑两遍,第二次无新增客户。 + +- [ ] **Step 3: Commit graphify(若有变更)** + +```bash +git add backend/graphify-out web-admin/graphify-out +git commit -m "chore(graphify): refresh after customer family protocol link" +``` + +--- + +## Spec coverage checklist + +| Spec 项 | Task | +|---------|------| +| 当事人加父亲/子女客户 ID | Task 1 | +| 保存当前单同步三人 | Task 2–3 | +| 补证合并软删 | Task 2 | +| 存量脚本 + 保存继续同步 | Task 3 + 5 | +| relations API | Task 4 | +| 详情关联客户 + 协议摘要 + 弹窗 | Task 6 | +| 细胞存储 + 科研捐赠 | Task 3 | +| 中试不动 | (无 Task) | + +## Self-review notes + +- 无 TBD /「类似 Task N」占位。 +- `sync_party_customers` 与回填共用,避免双轨规则。 +- 路由 `GET /customers/{id}/relations` 与现有 CRUD 并存。 +- 前端用 `#append` 保证基本信息在上、关联在下。