Files
group_xinghuo_jinrong/tests/_ddl.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

332 lines
17 KiB
Python
Raw 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.
"""sqlite 测试 DDL 单一事实源(B8 · B4 评审 P3-12 收敛 / B5 评审 P3-4 时区)。
以 VARCHAR/TEXT/INTEGER/REAL 近似 MySQL 的 ENUM/SMALLINT/JSON/DECIMAL,
枚举防线不在测试 DB 层(由 service 白名单保证,见 test_profile_l3 口径注释)。
时间默认值统一 `(datetime('now','localtime'))`:sqlite CURRENT_TIMESTAMP 为 UTC,
本地 23:00~24:00 跑测试时与 MySQL localtime 口径错位一个日界(B5 评审 P3-4)。
列集为各测试所需列的并集;新表/新列只改此处,禁止再回测试文件手写。
"""
from sqlalchemy import create_engine, text
from sqlalchemy.pool import StaticPool
_TS = "TIMESTAMP DEFAULT (datetime('now','localtime'))"
SQLITE_TABLES: dict[str, str] = {
# ---- Core 模拟库(只读侧) ----
"core_customer": """
CREATE TABLE core_customer (
customer_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(128), age INTEGER,
occupation VARCHAR(64), open_date DATE, is_active TINYINT DEFAULT 1,
is_hnw TINYINT DEFAULT 0, service_tier VARCHAR(16) DEFAULT 'normal')
""",
"core_customer_risk": """
CREATE TABLE core_customer_risk (
customer_id VARCHAR(64), risk_code VARCHAR(8), evaluated_at TIMESTAMP,
expires_at TIMESTAMP, is_authoritative TINYINT DEFAULT 1,
source VARCHAR(32) DEFAULT 'risk_questionnaire',
investor_category VARCHAR(32) DEFAULT 'ordinary',
questionnaire_score INTEGER, max_loss_tolerance_pct REAL,
investment_goal VARCHAR(128), investment_horizon VARCHAR(16),
professional_approved_at TIMESTAMP)
""",
"core_customer_advisor": """
CREATE TABLE core_customer_advisor (
advisor_id VARCHAR(64), customer_id VARCHAR(64), rel_status VARCHAR(16))
""",
"core_suitability_rule": """
CREATE TABLE core_suitability_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_risk_code VARCHAR(8), product_risk_code VARCHAR(8),
match_result VARCHAR(32), rule_ref VARCHAR(32) DEFAULT 'JR-AST-012')
""",
"core_product": """
CREATE TABLE core_product (
product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128),
min_risk_code VARCHAR(8), product_type VARCHAR(32),
min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER,
requires_disclosure TINYINT DEFAULT 0,
can_subscribe TINYINT DEFAULT 1, can_redeem TINYINT DEFAULT 1,
min_hold_qty DECIMAL DEFAULT 0, min_redeem_qty DECIMAL DEFAULT 0,
min_hold_action VARCHAR(32) DEFAULT 'force_transfer',
subscribe_fee_rate DECIMAL DEFAULT 0,
fund_company VARCHAR(64), ta_code VARCHAR(32),
share_class VARCHAR(16), allow_ac_convert TINYINT DEFAULT 0,
rounding_mode VARCHAR(16) DEFAULT 'half_up',
share_digits TINYINT DEFAULT 2)
""",
# T-0(R1 门禁):以 scripts/core/01-ddl.sql:132-145 为权威逐列对齐 ——
# id PK / customer_id / product_id / qty / cost_amount / market_value /
# pnl_pct / as_of + UNIQUE(customer_id, product_id)。
# 原列名 quantity 已废弃(MySQL 恒为 qty,列名失配是 convert 首个阻断项)。
# 不建外键:sqlite 测试库既有表均无 FK,保持一致(避免插入顺序约束)。
"core_holding": """
CREATE TABLE core_holding (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL,
market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL,
UNIQUE (customer_id, product_id))
""",
"core_trade": """
CREATE TABLE core_trade (
trade_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64), product_id VARCHAR(64),
trade_type VARCHAR(16), amount DECIMAL, qty DECIMAL,
convert_group_id VARCHAR(64), trade_status VARCHAR(16),
traded_at TIMESTAMP)
""",
# ---- convert 新增 4 表(T-1):MySQL 侧落点为
# scripts/core/01-ddl.sql(前 3 张)与 docs/项目框架设计/表设计/02-mysql-agent专用.sql
# (risk_convert_detail)。列集为测试所需列的并集,新列只改此处。
"core_fee_rule": """
CREATE TABLE core_fee_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
fee_type VARCHAR(16) NOT NULL, min_hold_days INTEGER, max_hold_days INTEGER,
min_amount DECIMAL, max_amount DECIMAL, rate DECIMAL NOT NULL,
to_fund_ratio DECIMAL NOT NULL DEFAULT 1.0)
""",
"core_share_lot": f"""
CREATE TABLE core_share_lot (
lot_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64) NOT NULL,
product_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, remain_qty DECIMAL NOT NULL,
nav DECIMAL NOT NULL, confirmed_at TIMESTAMP NOT NULL,
source_trade_id VARCHAR(64), created_at {_TS})
""",
"core_convert_lot_detail": f"""
CREATE TABLE core_convert_lot_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL,
lot_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, hold_days INTEGER NOT NULL,
amount DECIMAL NOT NULL, fee_rate DECIMAL NOT NULL, fee_amount DECIMAL NOT NULL,
nav DECIMAL NOT NULL, nav_date DATE NOT NULL, created_at {_TS})
""",
# T+1 受理/确认分离模型新增 3 表(T-1 · 开发计划 v2.0):
# core_convert_request(6 态受理单权威状态机)
# core_trade_calendar(交易日历,Q11)
# core_share_rule(D27 产品舍入规则)
# 列集为测试所需列的并集;MySQL 侧落点 scripts/core/01-ddl.sql,双份一致性
# 由 REQUIRED_CONVERT_TABLES + test_db.py 列清单断言守护(R-g)。
"core_convert_request": f"""
CREATE TABLE core_convert_request (
convert_group_id VARCHAR(64) PRIMARY KEY,
client_request_id VARCHAR(64),
customer_id VARCHAR(64) NOT NULL,
from_product_id VARCHAR(64) NOT NULL,
to_product_id VARCHAR(64) NOT NULL,
qty DECIMAL NOT NULL,
actual_qty DECIMAL,
status VARCHAR(16) NOT NULL DEFAULT 'accepted',
cancel_before TIMESTAMP,
requested_at TIMESTAMP NOT NULL,
confirmed_at TIMESTAMP,
coupon DECIMAL,
remark VARCHAR(128),
created_at {_TS}, updated_at {_TS},
UNIQUE (client_request_id))
""",
"core_trade_calendar": """
CREATE TABLE core_trade_calendar (
cal_date DATE NOT NULL PRIMARY KEY,
is_open TINYINT DEFAULT 1, remark VARCHAR(64))
""",
"core_share_rule": """
CREATE TABLE core_share_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id VARCHAR(64) NOT NULL,
business_type VARCHAR(16) NOT NULL,
share_digits TINYINT DEFAULT 2,
rounding_mode VARCHAR(16) DEFAULT 'half_up',
UNIQUE (product_id, business_type))
""",
"risk_convert_detail": f"""
CREATE TABLE risk_convert_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
out_trade_id VARCHAR(64), in_trade_id VARCHAR(64), related_trade_id VARCHAR(64),
nav DECIMAL, nav_date DATE, fee_amount DECIMAL,
hold_days_min INTEGER, hold_days_max INTEGER,
estimated TINYINT NOT NULL DEFAULT 1, nav_stale TINYINT NOT NULL DEFAULT 0,
client_request_id VARCHAR(64), cancelled_at TIMESTAMP,
created_at {_TS},
UNIQUE (convert_group_id), UNIQUE (client_request_id))
""",
# T-0(R-f):core_product_nav 与 MySQL scripts/core/01-ddl.sql:186-194 对齐
# (get_latest_nav 及新增 get_as_of 的读取目标;此前 sqlite 侧整表缺失)。
"core_product_nav": """
CREATE TABLE core_product_nav (
id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL,
UNIQUE (product_id, nav_date))
""",
# ---- agent 库 ----
"audit_log": f"""
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), event_type VARCHAR(64),
agent_type VARCHAR(16), actor_id VARCHAR(64), customer_id VARCHAR(64),
rule_id VARCHAR(64), input_summary TEXT, decision VARCHAR(64), risk_score INTEGER,
handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512),
created_at {_TS})
""",
"input_guard_log": f"""
CREATE TABLE input_guard_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), session_id VARCHAR(64),
agent_type VARCHAR(16), actor_id VARCHAR(64), guard_type VARCHAR(24),
raw_excerpt VARCHAR(1024), action VARCHAR(16),
created_at {_TS})
""",
"agent_session": f"""
CREATE TABLE agent_session (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64) UNIQUE,
trace_id VARCHAR(64), agent_type VARCHAR(16), actor_id VARCHAR(64),
actor_role VARCHAR(32), customer_id VARCHAR(64), advisor_id VARCHAR(64),
title VARCHAR(256), status VARCHAR(16) DEFAULT 'active',
created_at {_TS}, closed_at TIMESTAMP)
""",
"agent_message": f"""
CREATE TABLE agent_message (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64),
trace_id VARCHAR(64), seq_no INTEGER, role VARCHAR(16),
content TEXT, has_disclaimer TINYINT DEFAULT 0, token_est INTEGER,
created_at {_TS})
""",
"agent_tool_call": f"""
CREATE TABLE agent_tool_call (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64),
trace_id VARCHAR(64), message_id INTEGER, tool_name VARCHAR(128),
tool_input TEXT, tool_output TEXT, status VARCHAR(16),
error_code VARCHAR(64), latency_ms INTEGER,
created_at {_TS})
""",
"risk_alert": f"""
CREATE TABLE risk_alert (
alert_id VARCHAR(64) PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64),
trade_id VARCHAR(64), alert_type VARCHAR(16), triggered_rules TEXT,
risk_score INTEGER, status VARCHAR(24) DEFAULT 'pending_review', payload TEXT,
handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512),
created_at {_TS}, handled_at TIMESTAMP)
""",
"risk_suitability_log": f"""
CREATE TABLE risk_suitability_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64) NOT NULL,
customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
product_name VARCHAR(128),
customer_risk_level VARCHAR(8) NOT NULL, product_risk_level VARCHAR(8) NOT NULL,
investor_category VARCHAR(32) DEFAULT 'ordinary',
match_result VARCHAR(32) NOT NULL, mismatch_type VARCHAR(32) DEFAULT 'none',
is_matched TINYINT, is_blocked TINYINT DEFAULT 0,
requires_disclosure TINYINT DEFAULT 0, needs_branch_confirm TINYINT DEFAULT 0,
risk_was_expired TINYINT DEFAULT 0,
block_reason VARCHAR(512), block_response_code VARCHAR(32),
check_source VARCHAR(16) DEFAULT 'r02_trade', actor_id VARCHAR(64),
request_ref VARCHAR(64), profile_l1_version VARCHAR(32),
rule_refs TEXT, created_at {_TS})
""",
"customer_profile_l3": f"""
CREATE TABLE customer_profile_l3 (
customer_id VARCHAR(64) PRIMARY KEY, monitor_tier VARCHAR(16) NOT NULL,
risk_score INTEGER, score_dimensions TEXT, monitor_tags TEXT,
last_alert_id VARCHAR(64), computed_at TIMESTAMP NOT NULL,
updated_at {_TS})
""",
"risk_aml_list": f"""
CREATE TABLE risk_aml_list (
id INTEGER PRIMARY KEY AUTOINCREMENT, list_id VARCHAR(64), list_type VARCHAR(16),
full_name VARCHAR(128), id_no VARCHAR(32), bank_card_no VARCHAR(32),
match_threshold REAL, source VARCHAR(64), list_version VARCHAR(16),
effective_date DATE, is_active TINYINT DEFAULT 1, created_at {_TS})
""",
}
# ---------- T-0 建库自校验(R1 门禁)----------
# 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(逐列对照,见上表注释):
# core_holding id BIGINT PK / customer_id / product_id / qty / cost_amount /
# market_value / pnl_pct / as_of / UNIQUE KEY uk_cust_product
# core_product_nav id BIGINT PK / product_id / nav / daily_chg_pct / nav_date /
# UNIQUE KEY uk_product_date
# 失配在建库那一刻即 AssertionError,不留到集成测试期。
EXPECTED_CORE_HOLDING_COLUMNS = {
"id", "customer_id", "product_id", "qty",
"cost_amount", "market_value", "pnl_pct", "as_of",
}
REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"}
# T-1:convert 新增 4 表同样纳入存在性门禁。落点:前 3 张在 scripts/core/01-ddl.sql,
# risk_convert_detail 在 docs/项目框架设计/表设计/02-mysql-agent专用.sql。
REQUIRED_CONVERT_TABLES = {
"core_fee_rule", "core_share_lot", "core_convert_lot_detail", "risk_convert_detail",
# T+1 模型新增 3 表(T-1)
"core_convert_request", "core_trade_calendar", "core_share_rule",
}
def _assert_ddl_aligned(engine) -> None:
"""建库后自校验:缺列/缺表立即 AssertionError(R1 门禁)。
触发场景(真实踩过的坑):sqlite 列名曾为 quantity,而 MySQL 是 qty ——
单测全绿但 convert 首次 UPDATE core_holding.qty 才炸;本校验把失败点前移。
"""
with engine.connect() as conn:
tables = {
row[0]
for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
}
missing_tables = REQUIRED_CORE_TABLES - tables
assert not missing_tables, (
f"sqlite DDL 缺表:{sorted(missing_tables)}(以 scripts/core/01-ddl.sql 为准)"
)
missing_convert = REQUIRED_CONVERT_TABLES - tables
assert not missing_convert, (
f"sqlite DDL 缺 convert 表:{sorted(missing_convert)}"
"(以 scripts/core/01-ddl.sql 与 docs/项目框架设计/表设计/02-mysql-agent专用.sql 为准)"
)
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_holding)"))}
missing_columns = EXPECTED_CORE_HOLDING_COLUMNS - columns
assert not missing_columns, (
f"core_holding 缺列:{sorted(missing_columns)}(以 scripts/core/01-ddl.sql 为准)"
)
def create_sqlite_engine():
"""内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。
建表后经 `_assert_ddl_aligned` 自校验(T-0 门禁):结构失配在建库即失败,
而不是留到集成测试期。
"""
engine = create_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
with engine.begin() as conn:
for ddl in SQLITE_TABLES.values():
conn.execute(text(ddl))
_assert_ddl_aligned(engine)
return engine
# C×R 适当性矩阵测试种子(与 scripts/core/02-seed-base.sql 25 行逐格一致;
# AL-05 换核后 check_suitability 以矩阵表为 L0 权威,缺失即全量 forbidden)
SUITABILITY_MATRIX_SEED: list[tuple[str, str, str]] = [
("C1", "R1", "allowed"), ("C1", "R2", "allowed"), ("C1", "R3", "forbidden"),
("C1", "R4", "forbidden"), ("C1", "R5", "forbidden"),
("C2", "R1", "allowed"), ("C2", "R2", "allowed"), ("C2", "R3", "allowed"),
("C2", "R4", "forbidden"), ("C2", "R5", "forbidden"),
("C3", "R1", "allowed"), ("C3", "R2", "allowed"), ("C3", "R3", "allowed"),
("C3", "R4", "allowed_with_disclosure"), ("C3", "R5", "forbidden"),
("C4", "R1", "allowed"), ("C4", "R2", "allowed"), ("C4", "R3", "allowed"),
("C4", "R4", "allowed"), ("C4", "R5", "allowed_with_disclosure"),
("C5", "R1", "allowed"), ("C5", "R2", "allowed"), ("C5", "R3", "allowed"),
("C5", "R4", "allowed"), ("C5", "R5", "allowed"),
]
def seed_suitability_matrix(engine) -> None:
"""给 sqlite 测试库灌 25 行 C×R 矩阵(各涉及适当性的测试 fixture 调用)。"""
with engine.begin() as conn:
for c, r, m in SUITABILITY_MATRIX_SEED:
conn.execute(
text(
"INSERT INTO core_suitability_rule "
"(customer_risk_code, product_risk_code, match_result) "
"VALUES (:c, :r, :m)"
),
{"c": c, "r": r, "m": m},
)