阶段二失败(或阶段 1.5 引擎失败)后,仅凭 Core 侧数据把「详情 + 预警」两件事补回来
(PRD §7.1 / 架构 §5.4)。
落地(改 3 + 新增 2 脚本 + 测试 2 文件)
- convert_service:新增公开 compensate_convert(group_id, ...) —— 补偿的服务端单点入口
· 锁键 convert:rerun:{gid},与 convert_fund 幂等重试路径同一个键
· 详情侧:非 completed 才补写,复用 _finalize_from_core(不另写第二份阶段二)
· 预警侧:幂等锚点 = 转出端 out_trade_id,复用 find_alerts_by_trade;
命中即 skipped,否则跑 process_convert_event
- risk_repository:has_engine_error_audit 加 decision 参数(默认值不变)
· convert 线阶段 1.5 失败审计用 engine_error,普通交易用 risk_engine_error,不是同一个码
- rebuild_alerts.py:新增 --convert-group(与 location 参数 trade_ids 互斥),薄封装
- 新增 scripts/agent/cleanup_pending_convert.py(架构 §2 与开发计划 §9 指定路径):
超 convert_compensate_sla_hours 的 pending 占位 → status='expired'(标记不硬删,S2)
- 新增 scripts/dev/verify_convert_compensate.py:真库验证脚本(MySQL 8.0.46)
- 测试 +12:test_convert_service +4(补写 / 幂等 / missing / locked)、
test_demo_scripts +8(--convert-group 分派与接线 + cleanup 脚本)
state 四态与 CLI 退出码
- rebuilt(0) / skipped(0 幂等) / missing(1 零写入) / locked(3)
- 退出码 2 保留给 argparse 用法错误,故 locked 取 3
真库专属证据(sqlite 单测给不了的,本任务核心增量)
- status='expired' 在 MySQL ENUM 上被接受(sqlite 该列是 VARCHAR,写什么都收)
- created_at < cutoff 在 DATETIME(3) 上的时间边界正确(超时进候选 / 未超时不进 / 复跑幂等)
- input_summary 是真 JSON 列,而 has_engine_error_audit 用 LIKE 判定:脚本先断言
information_schema 的 DATA_TYPE='json' 再验命中,并反向断言决策码不匹配则不命中
顺带收口(用户指示)
- core_ro.concentration_profile 补 h.qty > 0,与 list_holdings 真正同口径
· ratio 不变(归零行市值为 0),但 rows 不再多出已清仓产品、不虚占截断判定位
· 新用例含跨出口一致性断言;突变验证:去掉 qty > 0 → 精准 1 条红
验证
- pytest -q → 731 passed / 3 skipped(基线 719 加 12,零回归)
- 突变验证 3 组精准命中:去掉幂等锚点(1 红)/ 去掉 status 过滤(2 红)/ 补偿无视锁(1 红)
- 真库 verify_convert_compensate.py 34/34,隔离数据零残留
- 全套 7 个真库脚本复跑零回归:seed 全 PASS / apply 24 / service 35 / engine 31 / lots 20 / tools 14 / compensate 34
470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""T-12 真 MySQL 验证脚本:补偿脚本在真库/真账号下实跑 + DoD 逐条断言。
|
||
|
||
**为什么 sqlite 单测全绿还不够(T-12 视角)**
|
||
|
||
1. **`status='expired'` 的真库 ENUM 值域**:`risk_convert_detail.status` 在 MySQL 是
|
||
`ENUM('pending','completed','failed','cancelled','expired')`,写错值会**直接报错**;
|
||
而 sqlite 测试库该列是 `VARCHAR(16)`,**任何字符串都收**。故「cleanup 能写进 expired」
|
||
这件事必须在真库证明一次。
|
||
2. **`input_summary` 是真 JSON 列**:`has_engine_error_audit` 用 `input_summary LIKE '%gid%'`
|
||
做幂等/警示判定。sqlite 里它是 TEXT、必定能 LIKE;MySQL 的 JSON 列能否直接 LIKE
|
||
属方言行为,必须实跑确认(本脚本 E 组专测)。
|
||
3. **DATETIME(3) 的时间边界**:`list_expired_candidates` 的 `created_at < cutoff` 在
|
||
MySQL 毫秒精度列上是否按预期分流(超时进候选、未超时不进),sqlite 的字符串时间
|
||
给不了这个保证。
|
||
4. **端到端**:阶段二失败 → 补偿 → `completed` + 出单 + 主审计,走真事务、真账号。
|
||
|
||
**断言分组**
|
||
A 待补偿态(阶段二 + 阶段 1.5 双失败)在真库如实落痕
|
||
B 超时 `pending` 占位 → `expired`(含 ENUM 值域 / 时间边界 / 幂等 / 标记不硬删)
|
||
C `compensate_convert` 端到端:详情 `completed` + 1 张单 + 主审计
|
||
D 重复补偿 → `skipped`(不产生第二张单、不重复主审计)
|
||
E 真 **JSON 列**上 `input_summary LIKE` 生效(`has_engine_error_audit`)
|
||
F 清理后残留为零(自检)
|
||
|
||
用法:
|
||
python scripts/dev/verify_convert_compensate.py
|
||
|
||
约定(与 T-6/T-7/T-8/T-11 脚本一致):
|
||
- 用 **T12C 前缀**隔离数据(客户/产品/批次/group_id),跑完**两个库**(core + agent)全清;
|
||
- 建/清数据走 `role="admin"`(需 DELETE);业务本身走 `convert_fund` 默认账号;
|
||
- 固定 `trace_id = "T12C-VERIFY-TRACE"`,便于精准清理 `audit_log`;
|
||
- 退出码 1 = 有断言不一致。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
import uuid
|
||
from datetime import date, datetime, timedelta
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
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.core_ro import CoreReadOnlyRepository # noqa: E402
|
||
from app.repository.risk_repository import RiskRepository # noqa: E402
|
||
from app.service.convert.convert_service import ( # noqa: E402
|
||
compensate_convert,
|
||
convert_fund,
|
||
)
|
||
from app.service.risk.rules import RiskThresholds # noqa: E402
|
||
from app.utils.db import dispose_engines, get_engine # noqa: E402
|
||
from app.utils.trace import new_trace # noqa: E402
|
||
|
||
CUSTOMER = "CUST-T12C"
|
||
PROD_OUT = "PROD-T12CO"
|
||
PROD_IN = "PROD-T12CI"
|
||
COMPANY = "华夏模拟基金"
|
||
TA = "TA-CN-001"
|
||
TRADE_AT = datetime(2026, 9, 4, 10, 0, 0)
|
||
TRADE_DATE = date(2026, 9, 4)
|
||
NOW = TRADE_AT
|
||
IN_NAV = Decimal("0.9500")
|
||
OUT_RATE = Decimal("0.0030")
|
||
IN_RATE = Decimal("0.0080")
|
||
FEE_TIERS = [
|
||
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
|
||
(180, 365, "0.0025"), (365, None, "0.0000"),
|
||
]
|
||
CID_REQ = "T12C-COMP-0001"
|
||
TRACE = "T12C-VERIFY-TRACE"
|
||
|
||
#: 大额阈值调到 100000 —— 转出端约 412000,确保补偿时引擎**真出一张单**(可断言)
|
||
TH = RiskThresholds(
|
||
large_amount=Decimal("100000"),
|
||
daily_total=Decimal("1000000"),
|
||
freq_count=3,
|
||
probe_window_minutes=5,
|
||
probe_count=3,
|
||
probe_amount=Decimal("400000"),
|
||
small_amount=Decimal("10000"),
|
||
small_count=3,
|
||
concentration_threshold=1.01, # 持仓画像不参与(避免 RISK-006 干扰)
|
||
)
|
||
|
||
_passed = 0
|
||
_failed = 0
|
||
|
||
#: 建/清数据用 admin 引擎(需 DELETE,R-e);在 main 中初始化
|
||
core_engine = None
|
||
agent_engine = None
|
||
|
||
|
||
def check(name: str, actual, expected) -> None:
|
||
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 _t12c_id(prefix: str, now: datetime) -> str:
|
||
"""固定前缀 id 工厂:默认 `CNV-<日期>-<uuid>` 与清理口径 `LIKE 'CNV-T12C%'` 不匹配,
|
||
重跑会留撞唯一键的残留(T-7 脚本踩过同款坑)。"""
|
||
return f"{prefix}-T12C-{uuid.uuid4().hex[:8].upper()}"
|
||
|
||
|
||
def q1(engine, sql: str, **params):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params).scalar_one_or_none()
|
||
|
||
|
||
def rows(engine, sql: str, **params):
|
||
with engine.connect() as conn:
|
||
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
|
||
|
||
|
||
# ── 数据准备 / 清理 ─────────────────────────────────────────────────
|
||
def cleanup(core_engine, agent_engine) -> None:
|
||
"""按 T12C 前缀清理两个库(含幂等重跑前置清理)。"""
|
||
with core_engine.begin() as conn:
|
||
for sql, params in [
|
||
("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-T12C%'", {}),
|
||
("DELETE FROM core_trade WHERE customer_id = :c", {"c": CUSTOMER}),
|
||
("DELETE FROM core_share_lot WHERE customer_id = :c", {"c": CUSTOMER}),
|
||
("DELETE FROM core_holding WHERE customer_id = :c", {"c": CUSTOMER}),
|
||
("DELETE FROM core_customer_risk WHERE customer_id = :c", {"c": CUSTOMER}),
|
||
("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T12C%'", {}),
|
||
("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T12C%'", {}),
|
||
("DELETE FROM core_product WHERE product_id LIKE 'PROD-T12C%'", {}),
|
||
("DELETE FROM core_customer WHERE customer_id = :c", {"c": CUSTOMER}),
|
||
]:
|
||
conn.execute(text(sql), params)
|
||
with agent_engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"DELETE FROM risk_convert_detail "
|
||
"WHERE convert_group_id LIKE 'CNV-T12C%' OR client_request_id LIKE 'T12C-%'"
|
||
)
|
||
)
|
||
conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER})
|
||
conn.execute(
|
||
text("DELETE FROM customer_profile_l3 WHERE customer_id = :c"), {"c": CUSTOMER}
|
||
)
|
||
conn.execute(text("DELETE FROM audit_log WHERE trace_id LIKE 'T12C-VERIFY%'"))
|
||
|
||
|
||
def seed(core_engine) -> None:
|
||
with core_engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, open_date) "
|
||
"VALUES (:c, 'T12补偿真库验证', :d)"
|
||
),
|
||
{"c": CUSTOMER, "d": TRADE_DATE},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
|
||
"VALUES (:c, 'C3', :t, :exp)"
|
||
),
|
||
{"c": CUSTOMER, "t": TRADE_AT - timedelta(days=30),
|
||
"exp": TRADE_AT + timedelta(days=300)},
|
||
)
|
||
for pid, name, ptype, rate in [
|
||
(PROD_OUT, "T12转出基金", "bond", OUT_RATE),
|
||
(PROD_IN, "T12转入基金", "stock", IN_RATE),
|
||
]:
|
||
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, :n, 'R2', :t, 1, 1, :r, :co, :ta)"
|
||
),
|
||
{"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA},
|
||
)
|
||
for mh, mh_max, rate in FEE_TIERS:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
|
||
"max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mh_max, :rate)"
|
||
),
|
||
{"p": PROD_OUT, "mh": mh, "mh_max": mh_max, "rate": rate},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||
"VALUES (:p, :nav, 0, :d)"
|
||
),
|
||
{"p": PROD_IN, "nav": str(IN_NAV), "d": TRADE_DATE},
|
||
)
|
||
for lot_id, qty, nav, confirmed in [
|
||
("LOT-T12C-A1", "400000", "1.0300", datetime(2025, 8, 1, 10, 0, 0)),
|
||
("LOT-T12C-A2", "100000", "1.0000", datetime(2026, 9, 1, 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": lot_id, "c": CUSTOMER, "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, 500000, 512000.00, 512000.00, 0, :d)"
|
||
),
|
||
{"c": CUSTOMER, "p": PROD_OUT, "d": TRADE_DATE},
|
||
)
|
||
|
||
|
||
def _req(cid_req: str | None = CID_REQ) -> dict:
|
||
return {
|
||
"customer_id": CUSTOMER,
|
||
"from_product_id": PROD_OUT,
|
||
"to_product_id": PROD_IN,
|
||
"qty": "120000",
|
||
"client_request_id": cid_req,
|
||
}
|
||
|
||
|
||
def _boom_engine(out_trade, in_trade):
|
||
raise RuntimeError("T12C 故意:阶段 1.5 引擎失败")
|
||
|
||
|
||
# ── 断言主体 ────────────────────────────────────────────────────────
|
||
def build_pending_state(core, repo, crepo) -> str:
|
||
"""构造待补偿态:阶段二写详情失败 + 阶段 1.5 引擎失败,但 Core 侧两条流水已成立。"""
|
||
print("\n[A] 待补偿态构造(阶段二 + 阶段 1.5 双失败)")
|
||
with patch.object(
|
||
ConvertRepository, "complete_convert", side_effect=RuntimeError("T12C 故意:阶段二失败")
|
||
):
|
||
resp = convert_fund(
|
||
_req(),
|
||
core_ro=core,
|
||
risk_repo=repo,
|
||
convert_repo=crepo,
|
||
thresholds=TH,
|
||
now=NOW,
|
||
engine_hook=_boom_engine,
|
||
id_factory=_t12c_id,
|
||
)
|
||
gid = resp["convert_group_id"]
|
||
trades = rows(
|
||
core_engine, "SELECT * FROM core_trade WHERE convert_group_id = :g", g=gid
|
||
)
|
||
check("Core 侧两条流水已成立(交易不可回滚)", len(trades), 2)
|
||
check(
|
||
"两条流水 trade_type 齐备",
|
||
sorted(t["trade_type"] for t in trades),
|
||
["redeem", "subscribe"],
|
||
)
|
||
detail = rows(
|
||
agent_engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
|
||
)
|
||
check("占位已置 failed(待补偿)", detail[0]["status"] if detail else None, "failed")
|
||
check(
|
||
"此时本客户无预警单",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c",
|
||
c=CUSTOMER,
|
||
),
|
||
0,
|
||
)
|
||
check(
|
||
"验收 17 前置:convert_detail_write_failed 审计存在",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_detail_write_failed' "
|
||
"AND trace_id LIKE 'T12C-VERIFY%'",
|
||
),
|
||
1,
|
||
)
|
||
check(
|
||
"阶段 1.5 失败审计(decision='engine_error')存在",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM audit_log WHERE decision = 'engine_error' "
|
||
"AND trace_id LIKE 'T12C-VERIFY%'",
|
||
),
|
||
1,
|
||
)
|
||
return gid
|
||
|
||
|
||
def check_cleanup(core, repo, crepo) -> None:
|
||
"""B 组:超时 pending → expired(ENUM 值域 / 时间边界 / 幂等 / 不硬删)。"""
|
||
print("\n[B] 超时 pending 占位 → expired(cleanup_pending_convert 同款仓储路径)")
|
||
orphan = f"CNV-T12C-ORPHAN-{uuid.uuid4().hex[:6].upper()}"
|
||
crepo.insert_placeholder(orphan, f"T12C-ORPHAN-{uuid.uuid4().hex[:6].upper()}")
|
||
|
||
cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)]
|
||
check("刚落下的 pending 不算超时(时间边界下界)", orphan in cand, False)
|
||
|
||
# 回拨 created_at 到 30h 前 → 应进候选
|
||
with agent_engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"UPDATE risk_convert_detail SET created_at = :t WHERE convert_group_id = :g"
|
||
),
|
||
{"t": datetime.now() - timedelta(hours=30), "g": orphan},
|
||
)
|
||
cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)]
|
||
check("回拨 30h 后进候选(时间边界上界)", orphan in cand, True)
|
||
|
||
crepo.mark_expired(orphan)
|
||
check(
|
||
"真库 ENUM 接受 status='expired'",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT status FROM risk_convert_detail WHERE convert_group_id = :g",
|
||
g=orphan,
|
||
),
|
||
"expired",
|
||
)
|
||
cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)]
|
||
check("复跑不再进候选(幂等)", orphan in cand, False)
|
||
check(
|
||
"S2 标记不硬删:行仍在",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM risk_convert_detail WHERE convert_group_id = :g",
|
||
g=orphan,
|
||
),
|
||
1,
|
||
)
|
||
|
||
|
||
def check_compensate(gid: str, core, repo, crepo) -> None:
|
||
"""C/D/E 组:补偿端到端 + 幂等 + 真 JSON 列 LIKE。"""
|
||
print("\n[C] compensate_convert 端到端(详情 + 预警一次补齐)")
|
||
out = compensate_convert(
|
||
gid,
|
||
core_ro=core,
|
||
risk_repo=repo,
|
||
convert_repo=crepo,
|
||
thresholds=TH,
|
||
now=NOW,
|
||
)
|
||
check("state", out["state"], "rebuilt")
|
||
check("detail 分支", out["detail"], "rebuilt")
|
||
check("engine 分支", out["engine"], "rebuilt")
|
||
check("补偿后出一张单", len(out["alert_ids"]), 1)
|
||
check("命中 RISK-001", "RISK-001" in out["triggered_rules"], True)
|
||
|
||
detail = rows(
|
||
agent_engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
|
||
)[0]
|
||
check("详情置 completed", detail["status"], "completed")
|
||
check("回填 out_trade_id 与 Core 一致", detail["out_trade_id"], out["out_trade_id"])
|
||
check("回填 in_trade_id 与 Core 一致", detail["in_trade_id"], out["in_trade_id"])
|
||
check("回填 nav 非空", detail["nav"] is not None, True)
|
||
check("回填 nav_date 非空", detail["nav_date"] is not None, True)
|
||
check(
|
||
"主审计(convert_accepted)已补",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_accepted' "
|
||
"AND trace_id LIKE 'T12C-VERIFY%'",
|
||
),
|
||
1,
|
||
)
|
||
|
||
alerts = rows(agent_engine, "SELECT * FROM risk_alert WHERE customer_id = :c", c=CUSTOMER)
|
||
check("预警单 1 张", len(alerts), 1)
|
||
payload = json.loads(alerts[0]["payload"]) if isinstance(alerts[0]["payload"], str) \
|
||
else alerts[0]["payload"]
|
||
check("一张单两条事件(转出 + 转入,真 JSON 列反解)", len(payload["events"]), 2)
|
||
|
||
print("\n[D] 重复补偿 → skipped(幂等,不产生第二张单)")
|
||
again = compensate_convert(
|
||
gid, core_ro=core, risk_repo=repo, convert_repo=crepo, thresholds=TH, now=NOW
|
||
)
|
||
check("state", again["state"], "skipped")
|
||
check("detail 分支", again["detail"], "already_completed")
|
||
check("engine 分支", again["engine"], "skipped")
|
||
check("alert_ids 与首次一致", again["alert_ids"], out["alert_ids"])
|
||
check(
|
||
"预警单仍只 1 张",
|
||
q1(agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c", c=CUSTOMER),
|
||
1,
|
||
)
|
||
check(
|
||
"主审计不重复",
|
||
q1(
|
||
agent_engine,
|
||
"SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_accepted' "
|
||
"AND trace_id LIKE 'T12C-VERIFY%'",
|
||
),
|
||
1,
|
||
)
|
||
check("幂等跳过时附人工核对提示", "人工核对" in (again.get("warning") or ""), True)
|
||
|
||
print("\n[E] 真 JSON 列上 input_summary LIKE 生效(has_engine_error_audit)")
|
||
col_type = q1(
|
||
agent_engine,
|
||
"SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() "
|
||
"AND TABLE_NAME = 'audit_log' AND COLUMN_NAME = 'input_summary'",
|
||
)
|
||
check("前提:input_summary 真库列类型为 json", str(col_type).lower(), "json")
|
||
check(
|
||
"按 group_id 命中(sqlite 是 TEXT,此断言只在真库有意义)",
|
||
repo.has_engine_error_audit(gid, decision="engine_error"),
|
||
True,
|
||
)
|
||
check(
|
||
"决策码不匹配则不命中(证明判定真的按 decision 过滤)",
|
||
repo.has_engine_error_audit(gid, decision="risk_engine_error"),
|
||
False,
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
global core_engine, agent_engine
|
||
core_engine = get_engine(settings.mysql_core_database, "admin")
|
||
agent_engine = get_engine(settings.mysql_database, "admin")
|
||
print("T-12 真库验证:补偿脚本(详情 + 预警)+ 超时占位清理")
|
||
print(f"客户={CUSTOMER} 产品={PROD_OUT}/{PROD_IN} 交易日={TRADE_DATE}")
|
||
|
||
cleanup(core_engine, agent_engine)
|
||
new_trace(TRACE) # 固定 trace:审计行才能按 'T12C-VERIFY%' 精准清理与断言
|
||
try:
|
||
seed(core_engine)
|
||
core = CoreReadOnlyRepository(engine=core_engine)
|
||
# 业务路径用生产默认账号(ro 读 / rw 写),仅建清数据用 admin
|
||
core_ro = CoreReadOnlyRepository(engine=get_engine(settings.mysql_core_database, "ro"))
|
||
repo = RiskRepository(engine=get_engine(settings.mysql_database, "rw"))
|
||
crepo = ConvertRepository(engine=get_engine(settings.mysql_database, "rw"))
|
||
|
||
gid = build_pending_state(core_ro, repo, crepo)
|
||
check_cleanup(core_ro, repo, crepo)
|
||
check_compensate(gid, core_ro, repo, crepo)
|
||
finally:
|
||
cleanup(core_engine, agent_engine)
|
||
left = q1(
|
||
core_engine,
|
||
"SELECT (SELECT COUNT(*) FROM core_trade WHERE customer_id LIKE 'CUST-T12C%') "
|
||
"+ (SELECT COUNT(*) FROM core_share_lot WHERE customer_id LIKE 'CUST-T12C%') "
|
||
"+ (SELECT COUNT(*) FROM core_holding WHERE customer_id LIKE 'CUST-T12C%') "
|
||
"+ (SELECT COUNT(*) FROM core_product WHERE product_id LIKE 'PROD-T12C%') "
|
||
"+ (SELECT COUNT(*) FROM core_customer WHERE customer_id LIKE 'CUST-T12C%')",
|
||
) + q1(
|
||
agent_engine,
|
||
"SELECT (SELECT COUNT(*) FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-T12C%') "
|
||
"+ (SELECT COUNT(*) FROM risk_alert WHERE customer_id = 'CUST-T12C') "
|
||
"+ (SELECT COUNT(*) FROM audit_log WHERE trace_id LIKE 'T12C-VERIFY%')",
|
||
)
|
||
print(f"\n清理后残留行数:{left}")
|
||
dispose_engines()
|
||
|
||
print(f"\n结果:{_passed} 通过 / {_failed} 失败")
|
||
return 1 if _failed else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|