T-15 D28 部分成交专项:
- sqlite +1 终态单幂等 skipped 口径裁定(ConfirmConflict 仅限读后状态才变窗口)
- 真库 confirm +3 组 19 项(E2 占用释放双向 / G2 actual_qty=0 → rejected / H2 撤单后确认)→ 102/102
- CONVERT_STRESS Barrier(2) 窄窗争批:恰 1 confirmed + 1 rejected
- 突变实证防御纵深:④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任一道仍收敛 rejected
T-16 D29 资金流中转:
- _insert_cash_flows 全仓唯一写入点(out/redeem 费前 + in/subscribe 净转入,remark=convert:{gid} 同事务)
- test_confirm_writes_no_cash_flow 反转为恰 2 条 + 金额对账(T-7 登记闭环)
- 真库 apply A2 组 + B 组 0 残留 → 30/30;confirm 反转断言 → 103/103
- FK 坑(core_cash_flow.fk_cf_customer 挡客户删除):5 处清理段补删
T-17 D30 share_class + A/C 互转:
- _validate_products 纯新增拦截分支:两端 share_class 非空且不同才查 allow_ac_convert(任一端=1 两向放行)
- 口径裁定 2 条(R-12 字面偏差,待用户追认):NULL 不参与 A/C 判定 / 同类走一般转换规则
- 联网核实(用户铁律):A/C 互转需管理人开通(摩根 2025-11 公告)= 开关真实载体;持有期重新起算与本仓一致
- 种子 ⑥ 段(双开对/双关对照/同类对照);sqlite +4 → 842 passed / 8 skipped;真库 accept H 组 → 43/43
- 突变:分支失效 → closed 红,还原绿
文档:开发计划 T-15~T-17 DoD 全勾 + 执行记录 · 基线 842 · TODO/MEMORY/AGENTS/交接文档 v4.5 终态(第 5 步收官 → 下一步第 6 步集成测试)
591 lines
28 KiB
Python
591 lines
28 KiB
Python
"""T-6 真 MySQL 验证脚本:`accept_convert` 受理事务实跑 + DoD 断言。
|
||
|
||
**为什么 sqlite 单测全绿还不够(T-6 视角)**
|
||
|
||
受理段本身「不扣份额、不折算、不写流水」,所以「有没有漏写」在 sqlite 上看不出来;
|
||
但 T+1 模型新增的持久化面里,以下事项**只有真 MySQL(InnoDB)才成立**:
|
||
|
||
1. `core_convert_request.status` 是 **ENUM 6 态**:非法状态必须被库拒绝
|
||
(sqlite 无 ENUM,写什么都能进)。
|
||
2. `uk_idem`(`client_request_id`)**唯一索引真库生效**:幂等「同键只出一单」
|
||
最终靠它兜底,sqlite 只能证明代码分支走过。
|
||
3. `requested_at` / `cancel_before` 是 **DATETIME(3)**:受理日 00:00 与截点 15:00
|
||
必须零漂移 —— 受理日顺延(R-5)判定完全依赖这两列。
|
||
4. `qty` 是 **DECIMAL(18,4)**:受理份额落库零漂移。
|
||
5. 受理**不产生** `core_trade` / `core_convert_lot_detail`,且 `core_share_lot`
|
||
一分不动(验收 21)—— 必须在真库上重放确认「T 日只落单」。
|
||
6. 在途占用(R-3)跨行聚合在真库上正确(`status IN ('accepted','nav_pending')`
|
||
的 ENUM 比较语义)。
|
||
|
||
用法:
|
||
python scripts/dev/verify_convert_accept.py # 建隔离数据 → 跑断言 → 清理
|
||
|
||
约定(与 `verify_convert_service.py` 一致):
|
||
- 隔离前缀 **ACV**(客户 / 产品 / group_id / client_request_id),跑完**两个库**全清;
|
||
- 建/清数据走 `role="admin"`(需 DELETE);业务链路走默认 ro/rw 账号;
|
||
- 交易日历沿用真库 `core_trade_calendar`(T-1 已灌 503 交易日),**期望值全部动态推算**
|
||
—— 日历内容变了脚本不该误报;
|
||
- 退出码:0=全一致 / 1=有断言不一致 / 2=环境不可用(连不上库或日历缺数据)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import itertools
|
||
import sys
|
||
from datetime import date, datetime, time, timedelta
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import text
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from app.config.settings import settings # noqa: E402
|
||
from app.repository.convert_repository import ConvertRepository # noqa: E402
|
||
from app.repository.convert_request_repository import ( # noqa: E402
|
||
ConvertRequestRepository,
|
||
)
|
||
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
|
||
from app.repository.risk_repository import RiskRepository # noqa: E402
|
||
from app.service.convert.convert_service import accept_convert # noqa: E402
|
||
from app.service.convert.errors import CrossEntityNotSupported, InsufficientShares # noqa: E402
|
||
from app.service.convert.trading_calendar import ( # noqa: E402
|
||
next_biz_day,
|
||
parse_cutoff,
|
||
)
|
||
from app.utils.db import dispose_engines, get_engine # noqa: E402
|
||
|
||
CUSTOMER = "CUST-ACV" # C5 客户(最高承受力)→ 任何产品均放行
|
||
CUSTOMER_LOW = "CUST-ACVL" # C1 客户 → 转入 R4 必被适当性阻断
|
||
PROD_OUT = "PROD-ACVO"
|
||
PROD_IN = "PROD-ACVI"
|
||
COMPANY = "ACV模拟基金"
|
||
TA = "TA-ACV-001"
|
||
QTY_TOTAL = Decimal("150") # 100 + 50 两批
|
||
|
||
_passed = 0
|
||
_failed = 0
|
||
|
||
|
||
def check(name: str, actual, expected) -> None:
|
||
"""逐条断言并打印(与 `verify_convert_service.py` 同款输出)。"""
|
||
global _passed, _failed
|
||
ok = actual == expected
|
||
if ok:
|
||
_passed += 1
|
||
else:
|
||
_failed += 1
|
||
flag = "✅" if ok else "❌"
|
||
print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}"))
|
||
|
||
|
||
def dec(value, places: str = "0.01") -> Decimal:
|
||
"""真库读回是 `Decimal`(MySQL DECIMAL)→ 统一量化后比较,避开末位噪声。"""
|
||
return Decimal(str(value)).quantize(Decimal(places), rounding=ROUND_HALF_UP)
|
||
|
||
|
||
def q1(engine, sql: str, **params):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params).scalar()
|
||
|
||
|
||
def _rows(engine, sql: str, **params) -> list[dict]:
|
||
with engine.connect() as conn:
|
||
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
|
||
|
||
|
||
# ── 数据准备 / 清理 ─────────────────────────────────────────────────
|
||
def seed(engine, submit_day: date) -> None:
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, open_date, age, is_active) "
|
||
"VALUES (:c, 'ACV真库验证', :d, 40, 1)"
|
||
),
|
||
{"c": CUSTOMER, "d": submit_day - timedelta(days=400)},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, open_date, age, is_active) "
|
||
"VALUES (:c, 'ACV低风险客户', :d, 40, 1)"
|
||
),
|
||
{"c": CUSTOMER_LOW, "d": submit_day - timedelta(days=400)},
|
||
)
|
||
for cid, code in ((CUSTOMER, "C5"), (CUSTOMER_LOW, "C1")):
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk "
|
||
"(customer_id, risk_code, evaluated_at, expires_at) "
|
||
"VALUES (:c, :r, :t, :exp)"
|
||
),
|
||
{
|
||
"c": cid,
|
||
"r": code,
|
||
"t": submit_day - timedelta(days=30),
|
||
"exp": submit_day + timedelta(days=300),
|
||
},
|
||
)
|
||
# 转出 R2 / 转入 R4(C5 放行、C1 阻断 —— 同一对产品覆盖两条路径)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
|
||
"product_type, can_subscribe, can_redeem, subscribe_fee_rate, "
|
||
"min_redeem_qty, min_hold_qty, min_hold_action, fund_company, ta_code) "
|
||
"VALUES (:p, 'ACV转出债基', 'R2', 'bond', 1, 1, 0.0030, 0, 0, "
|
||
"'force_transfer', :co, :ta)"
|
||
),
|
||
{"p": PROD_OUT, "co": COMPANY, "ta": TA},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
|
||
"product_type, can_subscribe, can_redeem, subscribe_fee_rate, "
|
||
"fund_company, ta_code) "
|
||
"VALUES (:p, 'ACV转入股基', 'R4', 'stock', 1, 1, 0.0080, :co, :ta)"
|
||
),
|
||
{"p": PROD_IN, "co": COMPANY, "ta": TA},
|
||
)
|
||
for pid in (PROD_OUT,):
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||
"VALUES (:p, 1.0300, 0, :d)"
|
||
),
|
||
{"p": pid, "d": submit_day},
|
||
)
|
||
for cid, tag in ((CUSTOMER, "ACV"), (CUSTOMER_LOW, "ACVL")):
|
||
for idx, (qty, nav, confirmed) in enumerate(
|
||
[
|
||
("100", "1.0300", datetime(2026, 1, 5, 10, 0, 0)),
|
||
("50", "1.0000", datetime(2026, 2, 5, 10, 0, 0)),
|
||
]
|
||
):
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
|
||
"remain_qty, nav, confirmed_at) "
|
||
"VALUES (:l, :c, :p, :q, :q, :nav, :cat)"
|
||
),
|
||
{
|
||
"l": f"LOT-{tag}-{idx}",
|
||
"c": cid,
|
||
"p": PROD_OUT,
|
||
"q": qty,
|
||
"nav": nav,
|
||
"cat": confirmed,
|
||
},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
|
||
"market_value, pnl_pct, as_of) VALUES (:c, :p, 150, 150.00, 154.50, 0, :d)"
|
||
),
|
||
{"c": cid, "p": PROD_OUT, "d": submit_day},
|
||
)
|
||
|
||
|
||
def cleanup_core(engine) -> None:
|
||
with engine.begin() as conn:
|
||
for sql, params in [
|
||
("DELETE FROM core_convert_request WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
("DELETE FROM core_trade WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
("DELETE FROM core_share_lot WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
("DELETE FROM core_holding WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
("DELETE FROM core_customer_risk WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-ACV%'", {}),
|
||
("DELETE FROM core_product WHERE product_id LIKE 'PROD-ACV%'", {}),
|
||
("DELETE FROM core_customer WHERE customer_id IN (:c1, :c2)",
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
|
||
]:
|
||
conn.execute(text(sql), params)
|
||
|
||
|
||
def cleanup_agent(engine) -> None:
|
||
with engine.begin() as conn:
|
||
# 双保险:gid 走注入的 id_factory(CNV-ACV-*);client_request_id 前缀兜底 ——
|
||
# 早期版本 gid 用生产 `_new_id`(CNV-<日期>-<hex>),前缀匹配不到会留残行,
|
||
# 而 `risk_convert_detail.uk_idem`(client_request_id)一残留就让下一轮全红。
|
||
conn.execute(
|
||
text("DELETE FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-ACV%'")
|
||
)
|
||
conn.execute(
|
||
text("DELETE FROM risk_convert_detail WHERE client_request_id LIKE 'ACV-REQ-%'")
|
||
)
|
||
conn.execute(
|
||
text("DELETE FROM risk_alert WHERE customer_id IN (:c1, :c2)"),
|
||
{"c1": CUSTOMER, "c2": CUSTOMER_LOW},
|
||
)
|
||
conn.execute(
|
||
text("DELETE FROM audit_log WHERE input_summary LIKE '%CNV-ACV%'")
|
||
)
|
||
|
||
|
||
# ── 链路辅助 ────────────────────────────────────────────────────────
|
||
def _services() -> dict:
|
||
"""受理段四仓储**全部走默认账号**(core ro/rw + agent rw)。
|
||
|
||
⚠️ 不能像 sqlite 单测那样把同一个引擎塞给四个仓储:真库下
|
||
`core_convert_request` 在 **core 库**、`risk_convert_detail` 在 **agent 库**,
|
||
且 Core 写侧须用 `xh_core_rw`(D20 账号分离)。传同一引擎会写错库。
|
||
"""
|
||
return dict(
|
||
core_ro=CoreReadOnlyRepository(),
|
||
risk_repo=RiskRepository(),
|
||
convert_repo=ConvertRepository(),
|
||
request_repo=ConvertRequestRepository(),
|
||
)
|
||
|
||
|
||
_SEQ = itertools.count(1)
|
||
|
||
|
||
def _id_factory(prefix: str, now: datetime) -> str:
|
||
"""固定前缀 `CNV-ACV-nnn`(生产 `_new_id` 会带随机 hex,脚本无法按前缀清理)。"""
|
||
return f"CNV-ACV-{next(_SEQ):03d}"
|
||
|
||
|
||
def _accept(req: dict, now: datetime) -> dict:
|
||
"""受理链路(注入固定前缀 id 工厂,让脚本能按 `CNV-ACV%` 精准清理)。"""
|
||
return accept_convert(req, now=now, id_factory=_id_factory, **_services())
|
||
|
||
|
||
def _base_req(**over) -> dict:
|
||
req = {
|
||
"customer_id": CUSTOMER,
|
||
"from_product_id": PROD_OUT,
|
||
"to_product_id": PROD_IN,
|
||
"qty": Decimal("100"),
|
||
}
|
||
req.update(over)
|
||
return req
|
||
|
||
|
||
def _pick_days(core_admin, is_open) -> tuple[date, date]:
|
||
"""取**日历中位**的开市日作基准受理日 D,及其下一交易日。
|
||
|
||
⚠️ 不能用「最新开市日」:日历种子到 2027 年底为止,取最后一天会让「过截点顺延」
|
||
用例撞上 `next_biz_day` 的数据边界(首轮实跑即因此暴露了 `confirm_eta` 的缺陷)。
|
||
取中位日可保证前后都有交易日,脚本断言不受日历边界干扰。
|
||
"""
|
||
rows = _rows(
|
||
core_admin,
|
||
"SELECT cal_date FROM core_trade_calendar WHERE is_open = 1 ORDER BY cal_date ASC",
|
||
)
|
||
if len(rows) < 10:
|
||
raise RuntimeError("core_trade_calendar 开市日不足,请先跑 10-seed-trade-calendar.sql")
|
||
day = rows[len(rows) // 2]["cal_date"]
|
||
if not isinstance(day, date):
|
||
day = date.fromisoformat(str(day)[:10])
|
||
return day, next_biz_day(day, is_open)
|
||
|
||
|
||
def _find_closed_day(start: date, is_open) -> date:
|
||
"""从 `start` 往前找一个休市日(含日历缺行 —— `is_open` 对缺行返回 False)。"""
|
||
cursor = start
|
||
for _ in range(15):
|
||
cursor -= timedelta(days=1)
|
||
if not is_open(cursor):
|
||
return cursor
|
||
raise RuntimeError("前 15 天内未找到休市日,日历数据异常")
|
||
|
||
|
||
# ── 主流程 ──────────────────────────────────────────────────────────
|
||
def main() -> int:
|
||
core_admin = get_engine(settings.mysql_core_database, "admin")
|
||
agent_admin = get_engine(settings.mysql_database, "admin")
|
||
core_ro = CoreReadOnlyRepository()
|
||
cutoff = parse_cutoff(settings.convert_cutoff_time)
|
||
|
||
try:
|
||
day, next_day = _pick_days(core_admin, core_ro.is_open)
|
||
except Exception as exc: # noqa: BLE001
|
||
print(f"❌ 环境不可用:{exc}")
|
||
dispose_engines()
|
||
return 2
|
||
|
||
submit_at = datetime.combine(day, time(10, 0))
|
||
print(f"基准受理日 D={day}(下一交易日 {next_day}),截点 {cutoff}")
|
||
|
||
try:
|
||
cleanup_core(core_admin)
|
||
cleanup_agent(agent_admin)
|
||
seed(core_admin, day)
|
||
|
||
# ── A. 受理成功 + 无副作用(验收 21)─────────────────────────
|
||
print("\n【A】受理成功:只落受理单,不扣份额 / 不写流水")
|
||
before_lots = dec(
|
||
q1(core_admin,
|
||
"SELECT COALESCE(SUM(remain_qty), 0) FROM core_share_lot "
|
||
"WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)
|
||
)
|
||
res = _accept(_base_req(client_request_id="ACV-REQ-1"), submit_at)
|
||
|
||
check("受理返回 accepted", res["status"], "accepted")
|
||
check("受理返回 accepted 标记", res["accepted"], True)
|
||
check("受理返回非 blocked", res["blocked"], False)
|
||
check("受理份额", dec(res["qty"]), Decimal("100.00"))
|
||
check("预计确认日 = 下一交易日", res["confirm_date"], str(next_day))
|
||
check("预计可用日 = 下下个交易日",
|
||
res["available_date"], str(next_biz_day(next_day, core_ro.is_open)))
|
||
|
||
row = _rows(
|
||
core_admin,
|
||
"SELECT * FROM core_convert_request WHERE convert_group_id = :g",
|
||
g=res["convert_group_id"],
|
||
)[0]
|
||
check("受理单状态 accepted", row["status"], "accepted")
|
||
check("actual_qty 为空(确认后才有)", row["actual_qty"], None)
|
||
check("qty 落 DECIMAL(18,4) 零漂移", dec(row["qty"], "0.0001"), Decimal("100.0000"))
|
||
check("requested_at = 受理日 00:00", row["requested_at"], datetime.combine(day, time.min))
|
||
check("cancel_before = 受理日截点", row["cancel_before"], datetime.combine(day, cutoff))
|
||
|
||
after_lots = dec(
|
||
q1(core_admin,
|
||
"SELECT COALESCE(SUM(remain_qty), 0) FROM core_share_lot "
|
||
"WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)
|
||
)
|
||
check("份额一分未动(验收 21)", after_lots, before_lots)
|
||
check("无 core_trade 流水",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
|
||
g=res["convert_group_id"]), 0)
|
||
check("无 core_convert_lot_detail",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_convert_lot_detail "
|
||
"WHERE convert_group_id = :g", g=res["convert_group_id"]), 0)
|
||
|
||
mirror = _rows(
|
||
agent_admin,
|
||
"SELECT status, estimated FROM risk_convert_detail WHERE convert_group_id = :g",
|
||
g=res["convert_group_id"],
|
||
)
|
||
check("镜像落 pending", mirror[0]["status"] if mirror else None, "pending")
|
||
check("镜像 estimated=0(真实请求)", int(mirror[0]["estimated"]) if mirror else None, 0)
|
||
check("审计 accepted 1 条",
|
||
q1(agent_admin,
|
||
"SELECT COUNT(*) FROM audit_log WHERE event_type = 'convert_request' "
|
||
"AND decision = 'accepted' AND input_summary LIKE :p",
|
||
p=f"%{res['convert_group_id']}%"), 1)
|
||
|
||
# ── B. 幂等(验收 12)────────────────────────────────────────
|
||
print("\n【B】幂等:同 client_request_id 只出一单(uk_idem 真库生效)")
|
||
dup = _accept(_base_req(client_request_id="ACV-REQ-1"), submit_at)
|
||
check("重复提交返回同一 group_id", dup["convert_group_id"], res["convert_group_id"])
|
||
check("重复提交标记 idempotent", dup["idempotent"], True)
|
||
check("受理单仍 1 行",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_convert_request WHERE customer_id = :c",
|
||
c=CUSTOMER), 1)
|
||
check("uk_idem 唯一索引存在",
|
||
q1(core_admin,
|
||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||
"WHERE table_schema = :db AND table_name = 'core_convert_request' "
|
||
"AND index_name = 'uk_idem'",
|
||
db=settings.mysql_core_database), 1)
|
||
|
||
dup_err = None
|
||
try:
|
||
with core_admin.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_convert_request (convert_group_id, client_request_id, "
|
||
"customer_id, from_product_id, to_product_id, qty, status, requested_at) "
|
||
"VALUES ('CNV-ACV-DUP', 'ACV-REQ-1', :c, :o, :i, 1, 'accepted', NOW(3))"
|
||
),
|
||
{"c": CUSTOMER, "o": PROD_OUT, "i": PROD_IN},
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
dup_err = type(exc).__name__
|
||
check("真库重复键报 IntegrityError", "IntegrityError" in (dup_err or ""), True)
|
||
|
||
# ── C. 在途占用(验收 22,R-3)────────────────────────────────
|
||
print("\n【C】在途占用:可用份额 = 物理余量 − 在途")
|
||
check("在途占用 = 100",
|
||
dec(core_ro.sum_inflight_qty(CUSTOMER, PROD_OUT)), Decimal("100.00"))
|
||
check("可用份额 = 50(150 − 100)",
|
||
dec(core_ro.sum_remain_qty(CUSTOMER, PROD_OUT) - core_ro.sum_inflight_qty(
|
||
CUSTOMER, PROD_OUT)), Decimal("50.00"))
|
||
|
||
over_err = None
|
||
try:
|
||
_accept(_base_req(qty=Decimal("60")), submit_at)
|
||
except InsufficientShares as exc:
|
||
over_err = str(exc)
|
||
check("超量申请被拒(InsufficientShares)", bool(over_err), True)
|
||
check("被拒后未新增受理单",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_convert_request WHERE customer_id = :c",
|
||
c=CUSTOMER), 1)
|
||
|
||
# ── D. 受理日顺延(验收 28,R-5)─────────────────────────────
|
||
print("\n【D】受理日顺延:非交易日 / 过截点 → 下一交易日")
|
||
closed = _find_closed_day(day, core_ro.is_open)
|
||
expect_from_closed = next_biz_day(closed, core_ro.is_open)
|
||
r_closed = _accept(
|
||
_base_req(qty=Decimal("1"), client_request_id="ACV-REQ-CLOSED"),
|
||
datetime.combine(closed, time(10, 0)),
|
||
)
|
||
row_closed = _rows(
|
||
core_admin,
|
||
"SELECT requested_at FROM core_convert_request WHERE convert_group_id = :g",
|
||
g=r_closed["convert_group_id"],
|
||
)[0]
|
||
check(f"非交易日 {closed} → 顺延 {expect_from_closed}",
|
||
row_closed["requested_at"], datetime.combine(expect_from_closed, time.min))
|
||
|
||
r_late = _accept(
|
||
_base_req(qty=Decimal("1"), client_request_id="ACV-REQ-LATE"),
|
||
datetime.combine(day, cutoff),
|
||
)
|
||
row_late = _rows(
|
||
core_admin,
|
||
"SELECT requested_at FROM core_convert_request WHERE convert_group_id = :g",
|
||
g=r_late["convert_group_id"],
|
||
)[0]
|
||
check(f"恰好 {cutoff} 视为超时 → 顺延 {next_day}",
|
||
row_late["requested_at"], datetime.combine(next_day, time.min))
|
||
|
||
# ── E. 适当性阻断(验收 4)───────────────────────────────────
|
||
print("\n【E】适当性阻断:C1 客户买 R4 → blocked 且受理单 0 行")
|
||
low_cnt_before = q1(
|
||
core_admin,
|
||
"SELECT COUNT(*) FROM core_convert_request WHERE customer_id = :c",
|
||
c=CUSTOMER_LOW,
|
||
)
|
||
blocked = _accept(
|
||
_base_req(customer_id=CUSTOMER_LOW, qty=Decimal("100")), submit_at
|
||
)
|
||
check("返回 blocked", blocked["blocked"], True)
|
||
check("blocked 无受理单",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_convert_request WHERE customer_id = :c",
|
||
c=CUSTOMER_LOW), low_cnt_before)
|
||
check("blocked 落预警单",
|
||
q1(agent_admin, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c",
|
||
c=CUSTOMER_LOW) >= 1, True)
|
||
|
||
# ── F. 强制全转(验收 13 前半)───────────────────────────────
|
||
print("\n【F】强制全转:min_hold_qty=100,申请 148 → 记实际全转 150")
|
||
with core_admin.begin() as conn:
|
||
conn.execute(
|
||
text("UPDATE core_product SET min_hold_qty = 100 WHERE product_id = :p"),
|
||
{"p": PROD_OUT},
|
||
)
|
||
# 先把在途单撤掉(否则占用导致可用不足)
|
||
with core_admin.begin() as conn:
|
||
conn.execute(
|
||
text("UPDATE core_convert_request SET status = 'cancelled' "
|
||
"WHERE customer_id = :c AND status IN ('accepted', 'nav_pending')"),
|
||
{"c": CUSTOMER},
|
||
)
|
||
forced = _accept(
|
||
_base_req(qty=Decimal("148"), client_request_id="ACV-REQ-FORCED"), submit_at
|
||
)
|
||
check("强制全转标记", forced["forced_full_transfer"], True)
|
||
check("申请量回显 148", dec(forced["requested_qty"]), Decimal("148.00"))
|
||
check("受理 qty 记实际全转 150", dec(forced["qty"]), Decimal("150.00"))
|
||
|
||
# ── G. 真库专属约束 ─────────────────────────────────────────
|
||
print("\n【G】真库专属:ENUM 严校验 / 索引与精度")
|
||
enum_err = None
|
||
try:
|
||
with core_admin.begin() as conn:
|
||
conn.execute(
|
||
text("UPDATE core_convert_request SET status = 'bogus' "
|
||
"WHERE convert_group_id = :g"),
|
||
{"g": forced["convert_group_id"]},
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
enum_err = type(exc).__name__
|
||
check("非法 status 被 ENUM 拒绝", bool(enum_err), True)
|
||
check("拒绝后状态未被污染",
|
||
q1(core_admin, "SELECT status FROM core_convert_request WHERE convert_group_id = :g",
|
||
g=forced["convert_group_id"]), "accepted")
|
||
check("requested_at 列精度为 DATETIME(3)",
|
||
q1(core_admin,
|
||
"SELECT datetime_precision FROM information_schema.columns "
|
||
"WHERE table_schema = :db AND table_name = 'core_convert_request' "
|
||
"AND column_name = 'requested_at'",
|
||
db=settings.mysql_core_database), 3)
|
||
|
||
# ── H. D30 同基金 A/C 份额互转(T-17 / R-12 · 真库种子产品)──
|
||
print("\n【H】A/C 互转:双开放行 / 双关 400 / 同类别走一般规则")
|
||
# 种子就位(09-seed-org.sql ⑥ 段的幂等重申;rowcount 同时证明产品存在)
|
||
with core_admin.begin() as conn:
|
||
for pid, sc, ac in (
|
||
("PROD-110022", "A", 1), ("PROD-005827", "C", 1),
|
||
("PROD-110023", "A", 0), ("PROD-003095", "C", 0),
|
||
("PROD-510300", "C", 0),
|
||
):
|
||
n = conn.execute(
|
||
text("UPDATE core_product SET share_class = :sc, allow_ac_convert = :ac "
|
||
"WHERE product_id = :p"),
|
||
{"sc": sc, "ac": ac, "p": pid},
|
||
).rowcount
|
||
if n != 1:
|
||
raise RuntimeError(f"种子产品缺失:{pid}(先灌 scripts/core/reset.ps1)")
|
||
# A 端持仓 1200 份(避开 PROD-110022 min_hold=1000 的强制全转干扰)
|
||
conn.execute(
|
||
text("DELETE FROM core_product_nav WHERE product_id = 'PROD-110022' AND nav_date = :d"),
|
||
{"d": day},
|
||
)
|
||
conn.execute(
|
||
text("INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||
"VALUES ('PROD-110022', 1.0300, 0, :d)"),
|
||
{"d": day},
|
||
)
|
||
conn.execute(
|
||
text("INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
|
||
"remain_qty, nav, confirmed_at) "
|
||
"VALUES ('LOT-ACV-SEED', :c, 'PROD-110022', 1200, 1200, 1.0300, :cat)"),
|
||
{"c": CUSTOMER, "cat": datetime(2026, 1, 5, 10, 0, 0)},
|
||
)
|
||
conn.execute(
|
||
text("INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
|
||
"market_value, pnl_pct, as_of) "
|
||
"VALUES (:c, 'PROD-110022', 1200, 1200.00, 1236.00, 0, :d)"),
|
||
{"c": CUSTOMER, "d": day},
|
||
)
|
||
|
||
# H1:'A'→'C' 双端开通 → 放行
|
||
ac_open = _accept(_base_req(
|
||
from_product_id="PROD-110022", to_product_id="PROD-005827",
|
||
qty=Decimal("100"), client_request_id="ACV-REQ-AC-OPEN"), submit_at)
|
||
check("H1 'A'→'C' 双开通受理 accepted", ac_open["status"], "accepted")
|
||
|
||
# H2:'A'→'C' 双端未开通 → 400 语义沿用 CROSS_ENTITY_NOT_SUPPORTED(换 A/C 文案)
|
||
ac_err = None
|
||
try:
|
||
_accept(_base_req(
|
||
from_product_id="PROD-110023", to_product_id="PROD-003095",
|
||
qty=Decimal("100"), client_request_id="ACV-REQ-AC-CLOSED"), submit_at)
|
||
except CrossEntityNotSupported as exc:
|
||
ac_err = exc
|
||
check("H2 双关被拒", bool(ac_err), True)
|
||
check("H2 错误码沿用 CROSS_ENTITY_NOT_SUPPORTED",
|
||
ac_err.error_code if ac_err else None, "CROSS_ENTITY_NOT_SUPPORTED")
|
||
check("H2 提示指明 A/C 互转", "A/C" in (ac_err.message if ac_err else ""), True)
|
||
check("H2 被拒不落单",
|
||
q1(core_admin, "SELECT COUNT(*) FROM core_convert_request "
|
||
"WHERE customer_id = :c AND to_product_id = 'PROD-003095'", c=CUSTOMER), 0)
|
||
|
||
# H3:'A'→'A' 同类别 → 不触发 A/C 判定,走一般转换规则放行
|
||
# (申请 100 = 种子 min_redeem_qty;转后余 1000 恰不触发 min_hold 强制全转)
|
||
ac_same = _accept(_base_req(
|
||
from_product_id="PROD-110022", to_product_id="PROD-110023",
|
||
qty=Decimal("100"), client_request_id="ACV-REQ-AC-SAME"), submit_at)
|
||
check("H3 同类别 'A'→'A' 走一般规则 accepted", ac_same["status"], "accepted")
|
||
finally:
|
||
cleanup_core(core_admin)
|
||
cleanup_agent(agent_admin)
|
||
dispose_engines()
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f"真库验证(T-6 accept_convert):{_passed} 项一致 / {_failed} 项不一致")
|
||
return 1 if _failed else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|