Files
zkxw/docs/superpowers/plans/2026-10-08-customer-profile-expand-sync.md
T
zlwuandCursor db31990ebe feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强
汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:20:37 +08:00

16 KiB
Raw Blame History

客户主档扩展 + 协议单向覆盖同步 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

-- 客户主档:通用个人档案字段
-- 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 顺序一致)增加:

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 库)
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 后增加:

"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 增加可选参数:

birth_ym: Optional[str] = None,
ethnic: Optional[str] = None,
email: Optional[str] = None,
address: Optional[str] = None,

构造 BizCustomer 时:

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 增加:

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 追加:

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: 跑测确认失败
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 与角色映射

示例实现骨架:

_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 项增加:

"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: 全量跑该文件
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

birthYm: string
ethnic: string
email: string
address: string
  • Step 2: 表单增加四项

在 formData / resetForm / handleEdit / handleSubmit payload 增加 birthYm、ethnic、email、address。

模板(form-grid-2 内,电话后或标签前)示意:

<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 基本信息增加
{ 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) ... 判断):

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:选人时把主档填入表单空位——实现为:表单该字段为空时才用主档填充;姓名/电话/身份证可保持现有「选人即带出」行为(与现状一致)。

空位示例:

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: 跑后端相关测试
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: 更新知识图谱
./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