投顾演示数据准备脚本:配好测评与归属,方案那步如实报告卡在证据来源
投顾线合并后 21 张 advisor_* 表全空,导致 published 返回 []、投顾 customer_ids 为空、
客户画像因缺测评返回 403。本脚本按依赖顺序补能自己造的那几样:
1. 产品目录:调 tools/import_hq_test_products.py 取真实行情(实测 19 个 ETF/LOF);
2. 客户风险测评:给 9001 补一条 C5、有效期一年(answers 里注明是演示数据);
3. 客户-投顾归属:9001 → 9020(assigned_at 往前留 5 秒,避开 DATETIME(0) 舍入陷阱)。
**方案那步不配** —— 缺的是证据来源不是技术:authoritative_tradable_products 是
fail closed 的(Missing or unverified evidence excludes a product),而造
advisor_product_suitability_reference 必须带 source_url 与 document_sha256,也就是真实的
销售适当性披露 / 基金合同文件(import_product_governance_reference.py 的两个 CSV,
不在仓库里,属环境数据)。脚本**不伪造这两个字段**(证据链红线),只检查并打印真正的解法。
实测:cust_t 的 /users/me/memory-profile 由 403 变 200(测评生效);admin 视角看 9020 的
customer_ids = ["9001"](归属生效);advisor 的 published 仍为 [](符合预期,尚无方案)。
⚠️ 顺带发现一处口径不一致:画像 fin_customer_profile.investor_type 是 C2,而新补的测评是
C5 —— 前者决定画像展示、后者决定适当性裁决,两者会同时出现在界面上。演示前需要统一
(要么把测评改成 C2,要么把画像也改成 C5)。
This commit is contained in:
@@ -152,11 +152,12 @@ function renderProbes(roles, userId) {
|
||||
{ label: '角色清单', path: '/api/v1/admin/roles', role: 'admin' },
|
||||
{ label: '我的身份(管理视角)',
|
||||
path: `/api/v1/admin/users/${userId}/roles`, role: 'admin' },
|
||||
// ⚠️ 这个接口的语义是"**我(客户)自己**已发布的方案":服务端按
|
||||
// `customer_id == 调用者 user_id` 过滤(权限码 `product-recommendation:read:self`
|
||||
// 的 `:self` 正对应这一点),所以**投顾调它必然为空**。标成 customer 才不会被误用。
|
||||
{ label: '我已发布的投顾方案(客户视角)',
|
||||
path: '/api/v1/advisor/recommendations/published', role: 'customer' },
|
||||
// 这个接口按 `customer_id == 调用者 user_id` 过滤 —— 即"**我自己的**方案"
|
||||
// (`product-recommendation:read:self` 的 `:self` 正对应这一点),与 `generate`
|
||||
// 落库时写的 `customer_id=context.user_id` 是自洽的一对。
|
||||
// 所以**投顾能看到自己生成并发布的方案**;空只说明还没有发布过任何方案。
|
||||
{ label: '我发布的投顾方案', path: '/api/v1/advisor/recommendations/published',
|
||||
role: 'advisor' },
|
||||
];
|
||||
const box = document.getElementById('probes');
|
||||
box.innerHTML = '';
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""投顾演示数据准备:能配的配好,配不了的**说清卡在哪**。
|
||||
|
||||
## 背景
|
||||
|
||||
投顾那条线合并进来后,21 张 `advisor_*` 表全是空的,于是:
|
||||
|
||||
- `GET /api/v1/advisor/recommendations/published` 返回 `[]`
|
||||
- 投顾登录后 `customer_ids` 为空(看不到任何客户)
|
||||
- 客户画像的前置"完成开户风险测评问卷"未满足(9001 没有测评记录)
|
||||
|
||||
## 本脚本做什么
|
||||
|
||||
按依赖顺序准备**能自己造**的那几样:
|
||||
|
||||
1. **产品目录**:调 `tools/import_hq_test_products.py`(从行情源拉真实 ETF/LOF)。
|
||||
这一步是**真实数据**,不是伪造。
|
||||
2. **客户风险测评**(`fin_risk_assessment`):给演示客户 9001 补一条
|
||||
**C5 激进型**、有效期一年。**这是演示数据**,脚本与 `answers` 字段里都写明。
|
||||
3. **客户-投顾归属**(`sys_customer_assignment`):把 9001 指派给投顾 9020。
|
||||
这决定投顾的 `customer_ids`,也决定 `data_scope` 下能看到谁。
|
||||
|
||||
## 本脚本**不**做什么,以及为什么
|
||||
|
||||
**"生成投顾方案"这一步配不了** —— 缺的是**证据来源**,不是技术。
|
||||
|
||||
`authoritative_tradable_products` 的文档写得很清楚:
|
||||
|
||||
Missing or unverified evidence excludes a product (fail closed).
|
||||
|
||||
而它要求的证据(`advisor_product_suitability_reference`)必须带上 **`source_url` 与
|
||||
`document_sha256`** —— 即"这份适当性等级是从哪份文件来的"。要造这些行,就得有**真实的
|
||||
销售适当性披露 / 基金合同文件**,也就是 `tools/import_product_governance_reference.py`
|
||||
的 `--suitability` / `--contracts` 两个 CSV(它们**不在仓库里**,属环境数据)。
|
||||
|
||||
**本脚本不会去编 `source_url` / `document_sha256`**:那是金融项目里的证据链红线,
|
||||
这道门挡住"没有依据的适当性等级"正是它该做的事。所以这里只**检查并报告**,
|
||||
把真正的解法(拿到披露文件后跑哪条命令)打印出来。
|
||||
|
||||
## 用法
|
||||
|
||||
python tools/seed_advisor_demo.py --dry-run # 只看会写什么
|
||||
python tools/seed_advisor_demo.py # 执行
|
||||
|
||||
# 方案那一步需要先准备好证据文件:
|
||||
python tools/import_product_governance_reference.py --suitability <披露.csv> --contracts <合同.csv>
|
||||
python tools/import_product_asset_classifications.py
|
||||
python tools/sync_advisor_market_data.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||||
|
||||
#: 演示客户与投顾(`sys_user`)。
|
||||
DEMO_CUSTOMER_ID = 9001
|
||||
DEMO_ADVISOR_ID = 9020
|
||||
|
||||
#: 演示测评的有效期。
|
||||
ASSESSMENT_VALID_DAYS = 365
|
||||
|
||||
#: `sys_customer_assignment.id` 没有自增(`information_schema` 的 EXTRA 为空),必须自己给。
|
||||
#: 用 9901 起,避开业务号段。
|
||||
ASSIGNMENT_ID = 9901
|
||||
|
||||
#: 与 `identity_repository` 的授权校验同源的坑:`assigned_at` 若用"当前时间"写,
|
||||
#: MySQL 的 DATETIME(0) 会把它四舍五入到秒、可能落进未来,于是 `assigned_at <= now`
|
||||
#: 判定"尚未生效",归属与角色都拿不到,且**不报错**。统一往前留 5 秒。
|
||||
BACKDATE_SECONDS = 5
|
||||
|
||||
|
||||
def _json(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def run_import(script: str, *, dry_run: bool) -> int:
|
||||
"""跑投顾线自带的导入脚本(子进程,stdout/stderr 直接继承)。
|
||||
|
||||
刻意**不捕获输出**:一是保留它自己的日志格式,二是避免在受限沙箱里因为管道
|
||||
被拒(`EPERM`)而把一个能跑的命令变成失败。
|
||||
"""
|
||||
args = [sys.executable, str(Path("tools") / script)]
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
print(f" $ {' '.join(args[1:])}")
|
||||
return subprocess.run(args, check=False).returncode # noqa: S603 - 参数均为本模块常量
|
||||
|
||||
|
||||
async def seed_assessment(*, dry_run: bool) -> None:
|
||||
"""给演示客户补一条有效期内的风险测评。"""
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
assess = {
|
||||
"customer_id": DEMO_CUSTOMER_ID,
|
||||
"questionnaire_version": "demo-v1",
|
||||
"total_score": 50,
|
||||
"investor_type": "C5",
|
||||
"assessed_at": now,
|
||||
"valid_until": now + timedelta(days=ASSESSMENT_VALID_DAYS),
|
||||
"created_at": now,
|
||||
}
|
||||
answers = {
|
||||
"_note": "演示数据:由 tools/seed_advisor_demo.py 生成,不是真实测评结果",
|
||||
"score": 50,
|
||||
}
|
||||
async with SessionFactory() as session, session.begin():
|
||||
existing = await session.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM fin_risk_assessment "
|
||||
"WHERE customer_id = :cid AND questionnaire_version = :v"
|
||||
),
|
||||
{"cid": DEMO_CUSTOMER_ID, "v": assess["questionnaire_version"]},
|
||||
)
|
||||
if existing:
|
||||
print(f" 测评:已存在(customer_id={DEMO_CUSTOMER_ID}),跳过")
|
||||
return
|
||||
if dry_run:
|
||||
print(f" 测评:将插入 C5 / 有效期至 {assess['valid_until']:%Y-%m-%d}")
|
||||
return
|
||||
# `id` 无自增,由库内最大值推进。
|
||||
next_id = int(
|
||||
await session.scalar(
|
||||
text("SELECT COALESCE(MAX(id), 0) + 1 FROM fin_risk_assessment")
|
||||
)
|
||||
or 1
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO fin_risk_assessment
|
||||
(id, customer_id, questionnaire_version, answers, total_score,
|
||||
investor_type, assessed_at, valid_until, created_at)
|
||||
VALUES
|
||||
(:id, :customer_id, :questionnaire_version, :answers, :total_score,
|
||||
:investor_type, :assessed_at, :valid_until, :created_at)
|
||||
"""
|
||||
),
|
||||
{"id": next_id, **assess, "answers": _json(answers)},
|
||||
)
|
||||
print(f" 测评:已插入 id={next_id}(C5,有效期 {ASSESSMENT_VALID_DAYS} 天)")
|
||||
|
||||
|
||||
async def seed_assignment(*, dry_run: bool) -> None:
|
||||
"""把演示客户指派给演示投顾。"""
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
assigned_at = now - timedelta(seconds=BACKDATE_SECONDS)
|
||||
async with SessionFactory() as session, session.begin():
|
||||
exists = await session.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM sys_customer_assignment "
|
||||
"WHERE customer_id = :cid AND employee_role = 'advisor' "
|
||||
"AND unassigned_at IS NULL"
|
||||
),
|
||||
{"cid": DEMO_CUSTOMER_ID},
|
||||
)
|
||||
if exists:
|
||||
print(f" 归属:已存在(客户 {DEMO_CUSTOMER_ID} 已指派给投顾),跳过")
|
||||
return
|
||||
if dry_run:
|
||||
print(f" 归属:将把客户 {DEMO_CUSTOMER_ID} 指派给投顾 {DEMO_ADVISOR_ID}")
|
||||
return
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_customer_assignment
|
||||
(id, customer_id, employee_id, employee_role, assigned_at, unassigned_at)
|
||||
VALUES
|
||||
(:id, :customer_id, :employee_id, 'advisor', :assigned_at, NULL)
|
||||
"""
|
||||
),
|
||||
{"id": ASSIGNMENT_ID, "customer_id": DEMO_CUSTOMER_ID,
|
||||
"employee_id": DEMO_ADVISOR_ID, "assigned_at": assigned_at},
|
||||
)
|
||||
print(
|
||||
f" 归属:客户 {DEMO_CUSTOMER_ID} → 投顾 {DEMO_ADVISOR_ID}"
|
||||
f"(assigned_at 往前留 {BACKDATE_SECONDS} 秒)"
|
||||
)
|
||||
|
||||
|
||||
async def evidence_gate_report() -> bool:
|
||||
"""检查方案那一步的证据门是否已满足;True 表示可以继续。"""
|
||||
async with SessionFactory() as session:
|
||||
products = int(await session.scalar(text("SELECT COUNT(*) FROM fin_product")) or 0)
|
||||
suitability = int(
|
||||
await session.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM advisor_product_suitability_reference "
|
||||
"WHERE review_status='verified' AND source_url <> '' AND document_sha256 <> ''"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
goals = int(
|
||||
await session.scalar(text("SELECT COUNT(*) FROM advisor_investment_goal")) or 0
|
||||
)
|
||||
|
||||
print("\n方案那一步的前置检查:")
|
||||
print(f" 在售产品目录 fin_product : {products}")
|
||||
print(f" 带来源证据的适当性参考(verified + 来源非空) : {suitability}")
|
||||
print(f" 投资目标 advisor_investment_goal : {goals}")
|
||||
|
||||
if suitability == 0:
|
||||
print(
|
||||
"\n[结论] '生成投顾方案' 这一步**现在配不了**,缺的不是技术而是**证据来源**:\n"
|
||||
" authoritative_tradable_products 是 fail closed 的,没有 verified 的\n"
|
||||
" 适当性参考就会排除全部产品;而造那些行必须带上 source_url 与\n"
|
||||
" document_sha256 —— 也就是真实的销售适当性披露 / 基金合同文件。\n"
|
||||
" 本脚本**不会伪造**这两个字段(证据链红线)。\n"
|
||||
"\n 拿到文件后按这个顺序跑:\n"
|
||||
" python tools/import_product_governance_reference.py \\\n"
|
||||
" --suitability <销售适当性披露.csv> --contracts <基金合同.csv>\n"
|
||||
" python tools/import_product_asset_classifications.py\n"
|
||||
" python tools/sync_advisor_market_data.py\n"
|
||||
" 之后再跑本脚本,方案那条链路就能走通。"
|
||||
)
|
||||
return False
|
||||
if goals == 0:
|
||||
print(
|
||||
"\n[结论] 证据门已满足,但还没有**投资目标**(advisor_investment_goal)。\n"
|
||||
" generate 需要 goal.investment_horizon_months,先通过投顾的\n"
|
||||
" 投资目标入口收集一份,再回来生成方案。"
|
||||
)
|
||||
return False
|
||||
print("\n[结论] 证据门与投资目标都已就绪,可以生成投顾方案了。")
|
||||
return True
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="投顾演示数据准备")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看会写什么")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("[1/4] 产品目录(调用投顾自带的导入脚本,取真实行情数据)")
|
||||
code = run_import("import_hq_test_products.py", dry_run=args.dry_run)
|
||||
if code != 0:
|
||||
print(f" [警告] 导入脚本返回 {code},产品目录可能没准备好(继续后续步骤)")
|
||||
|
||||
print("\n[2/4] 客户风险测评")
|
||||
await seed_assessment(dry_run=args.dry_run)
|
||||
|
||||
print("\n[3/4] 客户-投顾归属")
|
||||
await seed_assignment(dry_run=args.dry_run)
|
||||
|
||||
print("\n[4/4] 检查'生成投顾方案'的前置是否满足")
|
||||
if args.dry_run:
|
||||
print(" (dry-run 跳过:该检查依赖真实数据)")
|
||||
return 0
|
||||
ready = await evidence_gate_report()
|
||||
print("\n完成。" + ("" if ready else " 方案那一步见上面的说明。"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user