Files
group_fqcd_jr/tools/seed_customer_benefits.py
wangjianlong_0626 4766e3bd98 feat(benefit): 客户权益功能(T010)+ 修投顾迁移契约里写死 head 的脆弱断言
## 1. 新增客户权益(用户端)

`GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`):

- **层级**由 `fin_customer_profile.total_asset` **实时判定**
  (门槛来自 `knowledge/product/高净值客户服务规范.md`:
  金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户);
- **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"):
  金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测;
- 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。

### 新增表 `fin_customer_benefit`(1 张)

层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。

**基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表;
**未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义;
未改 `docs/00`。
复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。

### 两条设计取舍

1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。
   与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。
2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 ——
   否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。

### 数据来源与一处刻意省略

逐条照抄知识文档,不新增文档里没有的权益。**私行那条
「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** ——
文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。

## 2. 修投顾迁移契约里写死的断言

`tests/unit/test_advisor_migration_contract.py` 原先断言

```python
assert script.get_heads()[0] == "20260911_merge_adv_risk_heads"
```

那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`)
它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。

原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先**
(链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。
`len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。

## 3. 顺带发现的既有缺口(**不在本次改动范围**)

`app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**:
`docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`),
但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。
对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。

本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据,
且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。
T001–T009 如何补,需架构师定口径后另行处理。

## 4. 文档

- 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 +
  数据来源 + 权限 + 与仪表盘的关系 + 上述缺口
- `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为
  "T 段数据库零变更"的一部分)
- `AGENTS.md` 表数 89 → **90** 张业务表

## 验证

- `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed**
  (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示;
  鉴权先于读数据)
- 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → **0 错 / 252 文件**
- 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过
- `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突;
  端点编号无重复;RBAC 种子一致性通过
2026-09-12 17:24:37 +08:00

149 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客户权益目录种子:把《高净值客户服务规范》的权益转成表数据。
数据源:`knowledge/product/高净值客户服务规范.md` 第二章「各层级专属权益」。
**逐条照抄文档**,不改写、不新增文档里没有的权益。
执行:
python -X utf8 -m tools.seed_customer_benefits
幂等:按 `benefit_code` 先查后插;已存在的**不覆盖**(避免把人工调整冲掉)。
## 两处刻意的处理
1. **私行那条"7×24 小时私人银行专线:400-XXX-XXXX 转 8" 不写号码**。
文档里是占位符,而项目已有明确口径:对客号码的唯一来源是
`app/core/customer_service_rules.py` 的 `CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷,见 `docs/37` §6.3 的 A1)。
把占位符抄进库,等于又造了第二份假号码。故只保留权益名称与说明。
2. **权益按层级累积分组**:文档每层都写"含全部下级权益,新增以下"。
表里**只存该层新增的条目**,累积展开由 `CustomerBenefitService.tier_chain()` 完成
—— 否则同一条权益要在多个层级重复存,改一处漏三处。
"""
from __future__ import annotations
import asyncio
import sys
from datetime import UTC, datetime
from sqlalchemy import select
from app.infrastructure.db import SessionFactory
from app.model.benefit import CustomerBenefit
#: (层级, 类别, 名称, 说明);display_order 按此列表顺序自动编号。
#: 层级顺序 gold → platinum → diamond → private = 累积顺序。
BENEFITS: tuple[tuple[str, str, str, str], ...] = (
# ---- 金卡(50 万+)----
("gold", "financial", "专属理财经理服务", "由理财经理提供专属服务(1:N,N≤300)"),
("gold", "financial", "基金申购费率 5 折优惠", "高于普通客户的 1 折优惠"),
("gold", "financial", "银行理财专属高收益产品", "较公开产品收益高 10-20BP"),
("gold", "financial", "每月 1 次免费资产配置报告", "每月可获取一次资产配置报告"),
("gold", "financial", "优先认购热门基金产品", "热门基金产品优先认购"),
("gold", "non_financial", "生日祝福礼遇", "精美礼品一份"),
("gold", "non_financial", "节日关怀", "春节、中秋礼品卡"),
("gold", "non_financial", "APP 金卡专属标识", "客户端展示金卡专属标识"),
("gold", "non_financial", "财富中心 VIP 区域使用", "可使用财富中心 VIP 区域"),
# ---- 白金(200 万+,含全部金卡权益)----
("platinum", "financial", "1 对 1 高级理财经理服务", "由高级理财经理提供 1 对 1 服务(N≤150)"),
("platinum", "financial", "基金申购费率 3 折优惠", "较金卡的 5 折进一步优惠"),
("platinum", "financial", "每季度 1 次投资策略会/市场研判会", "每季度参与资格一次"),
("platinum", "financial", "专属理财产品", "白金客户专享,年化收益较普通产品高 20-30BP"),
("platinum", "financial", "私募产品优先认购权", "私募产品优先认购"),
("platinum", "financial", "基金投顾服务费 8 折优惠", "投顾服务费 8 折"),
("platinum", "non_financial", "每年 2 次高端客户沙龙", "品酒、艺术品鉴赏等"),
("platinum", "non_financial", "三甲医院专家门诊预约", "每年 2 次"),
("platinum", "non_financial", "机场贵宾厅服务", "每年 6 次"),
("platinum", "non_financial", "高尔夫球场预约优惠", "合作球场 8 折"),
("platinum", "non_financial", "子女留学规划咨询", "合作机构免费 1 次"),
# ---- 钻石(600 万+,含全部白金权益)----
("diamond", "financial", "资深客户经理 1 对 1 专属服务", "由资深客户经理提供(N≤80)"),
("diamond", "financial", "基金申购费率 2 折优惠", "较白金的 3 折进一步优惠"),
("diamond", "financial", "家族办公室初步服务对接", "家族办公室服务初步对接"),
("diamond", "financial", "全球资产配置咨询", "全球范围资产配置咨询"),
("diamond", "financial", "私募产品优先配置权", "含稀缺额度"),
("diamond", "financial", "定制化投资报告", "月度/季度定制报告"),
("diamond", "financial", "税务筹划初步咨询", "每年 1 次"),
("diamond", "non_financial", "高端健康管理", "年度全面体检套餐"),
("diamond", "non_financial", "每年 4 次高端客户活动", "米其林晚宴、私人音乐会等"),
("diamond", "non_financial", "全球紧急救援服务", "全球范围紧急救援"),
("diamond", "non_financial", "机场专车接送服务", "每年 8 次"),
("diamond", "non_financial", "高端酒店会员权益", "合作五星级酒店 VIP 待遇"),
("diamond", "non_financial", "子女实习/就业推荐", "合作企业资源对接"),
# ---- 私行(1000 万+,含全部钻石权益)----
("private", "financial", "私人银行家 1 对 1 专属服务", "由私人银行家提供(N≤40)"),
("private", "financial", "基金申购费率 1 折优惠", "最低费率档"),
("private", "financial", "家族信托设立与管理服务", "家族信托全流程服务"),
("private", "financial", "家族办公室全方位服务", "家族办公室全方位服务"),
("private", "financial", "全球资产配置方案", "含海外置业、移民咨询"),
("private", "financial", "专属投委会成员定期沟通", "与投委会成员定期沟通"),
("private", "financial", "私募股权/创投基金认购权", "私募股权与创投基金认购"),
("private", "financial", "企业融资顾问服务", "免费提供"),
("private", "financial", "定制化资产配置白皮书", "年度"),
("private", "financial", "税务筹划与遗产规划", "CFA/CTA 专家服务"),
("private", "financial", "艺术品投资咨询", "艺术品投资咨询"),
("private", "financial", "专属理财产品定制", "单户可定制产品方案"),
# ⚠️ 文档原文为「7×24 小时私人银行专线:400-XXX-XXXX 转 8」——
# 号码是占位符,此处**只保留权益名**,号码一律走 customer_service_rules.CONTACT_PHONE。
("private", "non_financial", "7×24 小时私人银行专线", "全天候私人银行专线(号码统一由客服热线配置提供)"),
("private", "non_financial", "私人银行家上门服务", "每月至少 1 次"),
("private", "non_financial", "全球顶尖医疗资源对接", "全球医疗资源对接"),
("private", "non_financial", "机场贵宾厅及专车接送不限次", "每年不限次"),
("private", "non_financial", "私人飞机/游艇租赁服务", "合作供应商优惠价"),
("private", "non_financial", "高端社交圈层活动", "南方私行俱乐部年会、海外游学"),
("private", "non_financial", "家族传承规划", "法律、税务、治理综合方案"),
("private", "non_financial", "公益慈善顾问服务", "慈善顾问服务"),
("private", "non_financial", "奢侈品鉴赏", "珠宝、名表、红酒私人顾问"),
)
def _code(tier: str, index: int) -> str:
return f"tier:{tier}:{index:02d}"
async def seed() -> int:
now = datetime.now(UTC).replace(tzinfo=None)
inserted = 0
async with SessionFactory() as session:
existing = set(await session.scalars(select(CustomerBenefit.benefit_code)))
order_by_tier: dict[str, int] = {}
for tier, category, name, description in BENEFITS:
order_by_tier[tier] = order_by_tier.get(tier, 0) + 1
code = _code(tier, order_by_tier[tier])
if code in existing:
continue
session.add(CustomerBenefit(
benefit_code=code,
customer_tier=tier,
category=category,
name=name,
description=description,
display_order=order_by_tier[tier],
status="active",
created_at=now,
updated_at=now,
))
inserted += 1
await session.commit()
return inserted
async def main() -> None:
inserted = await seed()
async with SessionFactory() as session:
rows = (await session.execute(
select(CustomerBenefit.customer_tier, CustomerBenefit.category)
)).all()
per_tier: dict[str, int] = {}
for tier, _category in rows:
per_tier[tier] = per_tier.get(tier, 0) + 1
print(f"新增 {inserted} 条;库中现有 {len(rows)} 条:")
for tier, _label in (("gold", "金卡"), ("platinum", "白金"), ("diamond", "钻石"), ("private", "私行")):
print(f" {tier:9} {_label} {per_tier.get(tier, 0)} 条")
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
asyncio.run(main())