Просмотр исходного кода

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 <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
69e76b85ab
1 измененных файлов с 559 добавлено и 0 удалено
  1. 559 0
      docs/superpowers/plans/2026-10-07-customer-family-protocol-link.md

+ 559 - 0
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<Api.BizCustomer.CustomerRelations>({
+    url: `/web/admin/customers/${id}/relations`
+  })
+}
+```
+
+- [ ] **Step 2: SystemViewDialog 增加 append slot**
+
+在 sections 循环之后、滚动容器结束前:
+
+```vue
+<div v-if="$slots.append" class="view-append">
+  <slot name="append" />
+</div>
+```
+
+样式对齐 `.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` 保证基本信息在上、关联在下。