汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。 Co-authored-by: Cursor <cursoragent@cursor.com>
16 KiB
客户主档扩展 + 协议单向覆盖同步 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 手工验收
- 协议填全母亲扩展字段保存 → 当事人有快照 + 客户主档一致。
- 客户管理改电话 → 旧协议查看仍是当事人旧电话。
- 再保存协议(新电话)→ 客户主档电话被覆盖。
- 协议某字段清空再保存 → 客户该字段不被清空。
- 客户管理独立维护四字段。
- 父亲同步不误清空客户已有邮箱/住址。
- 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 |