Files
group_xinghuo_jinrong/tests/test_convert_service.py
T

456 lines
20 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.
"""`convert_service` 补偿单点单测(T-12/T-13 · 开发计划 §6.2 DoD)。
sqlite 内存库(`conftest.sqlite_engine`,含 C×R 矩阵种子),数据自建。
**T-13 退役说明**:本文件原承载 v1.0 两阶段实时入口 `convert_fund` 的八步编排
用例(三阶段贯通 / 占位幂等 / nav_stale 副审计 / 阶段一失败重试等)。`convert_fund`
随 T+1 模型退役删除后,其验证点的 **T+1 替代断言**已由以下用例承接:
- 受理段(blocked 不占位 / 同键幂等 / 最低份额 / 强制全转 / 产品校验 / 在途占用 /
uk_idem 竞态)→ `tests/test_convert_accept.py`;
- 确认段(happy path 与 PRD §5.3 对表 / 缺净值 nav_pending / 引擎恰好一次 /
引擎异常不阻断但留痕 / 真引擎出单去重 / 强制全转继承)→ `tests/test_convert_confirm.py`。
本文件保留 **补偿单点 `compensate_convert`** 的全部用例,并补 2 条 T+1 缺口:
确认第 ⑧ 步镜像失败不回滚 Core(v1.0「阶段二失败」语义的承接)、
确认段批次数超限 rejected(v1.0 `TooManyLots` 503 语义的 T+1 承接)。
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
from unittest.mock import patch
from app.config.settings import settings
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.convert_request_repository import ConvertRequestRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.confirm_service import confirm_one
from app.service.convert.convert_service import compensate_convert
from app.service.risk.locks import try_lock
from app.service.risk.rules import RiskThresholds
CUST = "CUST-T7" # C3 客户 → R4 产品 allowed_with_disclosure(放行)
PROD_OUT = "PROD-T7A"
PROD_IN = "PROD-T7B"
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
NOW = datetime(2026, 9, 4, 10, 0, 0)
TODAY = date(2026, 9, 4)
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
IN_NAV = Decimal("0.9500")
FEE_TIERS = [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]
def _exec(engine, sql: str, **params) -> None:
with engine.begin() as conn:
conn.execute(text(sql), params)
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, *, nav_date: date = TODAY, min_redeem: str = "0", min_hold: str = "0") -> None:
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'T7客户', 40, 1)",
c=CUST,
)
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C3', :t, :exp)",
c=CUST, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
)
_exec(
engine,
"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, "
"fund_company, ta_code) VALUES (:p, '转出债基', 'R2', 'bond', 1, 1, :r, :mr, :mh, :co, :ta)",
p=PROD_OUT, r=float(OUT_RATE), mr=float(min_redeem), mh=float(min_hold),
co=COMPANY, ta=TA,
)
_exec(
engine,
"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, '转入股基', 'R4', 'stock', 1, 1, :r, :co, :ta)",
p=PROD_IN, r=float(IN_RATE), co=COMPANY, ta=TA,
)
for mh, mh_max, rate in FEE_TIERS:
_exec(
engine,
"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=float(rate),
)
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :nav, 0, :d)",
p=PROD_IN, nav=float(IN_NAV), d=nav_date,
)
_seed_lot(engine, "LOT-T7-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0))
_seed_lot(engine, "LOT-T7-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0))
_exec(
engine,
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
c=CUST, p=PROD_OUT, d=TODAY,
)
def _seed_lot(engine, lot_id: str, qty: str, nav: str, confirmed_at: datetime,
customer: str = CUST) -> None:
_exec(
engine,
"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=float(qty), nav=float(nav), cat=confirmed_at,
)
def _services(engine):
return dict(
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
convert_repo=ConvertRepository(engine=engine),
# 确认事务的 core 写侧也必须钉在同一 sqlite 引擎(缺省构造会连真 MySQL,
# CUST-T7 在真库无 customer 行 → FK 1452)
core_writer=ConvertCoreRepository(engine=engine),
request_repo=ConvertRequestRepository(engine=engine),
)
# ── 11. T-12 补偿:确认段第 ⑦/⑧ 步失败 → 按 group 补写详情 + 预警 ─────────
def _low_thresholds() -> RiskThresholds:
"""调低大额阈值,让 120 份的折算额足以命中 RISK-001(与 §10 接线用例同口径)。"""
return RiskThresholds(
large_amount=Decimal("100"),
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,
)
def _prepare_failed_confirm_t1(sqlite_engine, cid_req: str) -> str:
"""构造 **T+1 模型的真实待补偿现场**:确认事务已提交、agent 侧两件事全失败。
T+1 下确认段第 ⑥ 步(Core 事务:受理单 confirmed + 两条流水 + 计费明细)与
第 ⑦/⑧ 步(引擎 / 镜像 + 审计,附加写入)分离;补偿场景 = 后者失败:
- Core 侧:直接插「确认后净数据」(受理单 confirmed + 2 流水 + 2 明细,同事务
三件套缺一不可 —— 补偿前置逐项校验);
- 镜像:`risk_convert_detail` 停在 `failed`(sync_mirror 调用点契约 4 的入口态);
- 预警:`engine_error` 审计已落(第 ⑦ 步异常的留痕)→ 无预警单,补偿后 warning。
这正是 PRD §7.1 描述的补偿场景(Core 已成、agent 侧两件事都没落)。
返回 convert_group_id。
"""
_seed(sqlite_engine)
gid = "CNV-T12C-0001"
traded_at = NOW
_exec(
sqlite_engine,
"INSERT INTO core_convert_request (convert_group_id, client_request_id,"
" customer_id, from_product_id, to_product_id, qty, actual_qty, status,"
" cancel_before, requested_at, confirmed_at, coupon, remark)"
" VALUES (:gid, :cid_req, :c, :fp, :tp, 30000.0, 30000.0, 'confirmed',"
" NULL, :rat, :cat, 121.82, NULL)",
gid=gid, cid_req=cid_req, c=CUST, fp=PROD_OUT, tp=PROD_IN,
rat=traded_at, cat=traded_at,
)
_exec(
sqlite_engine,
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount,"
" qty, trade_status, traded_at, convert_group_id) VALUES"
" ('TRD-T12C-OUT', :c, :fp, 'redeem', 40607.94, 30000.0, 'confirmed', :t, :gid),"
" ('TRD-T12C-IN', :c, :tp, 'subscribe', 40486.12, 21093.15, 'confirmed', :t, :gid)",
c=CUST, fp=PROD_OUT, tp=PROD_IN, t=traded_at, gid=gid,
)
_exec(
sqlite_engine,
"INSERT INTO core_convert_lot_detail (convert_group_id, lot_id, qty, hold_days,"
" amount, fee_rate, fee_amount, nav, nav_date) VALUES"
" (:gid, 'LOT-T12C-1', 20000.0, 65, 27208.00, 0.0050, 136.04, 1.3604, :nd),"
" (:gid, 'LOT-T12C-2', 10000.0, 3, 13604.00, 0.0150, 204.06, 1.3604, :nd)",
gid=gid, nd=TODAY,
)
_exec(
sqlite_engine,
"INSERT INTO risk_convert_detail (convert_group_id, status, estimated, created_at)"
" VALUES (:gid, 'failed', 0, :t)",
gid=gid, t=traded_at,
)
# 第 ⑦ 步引擎异常的留痕(与确认段 engine_error 审计同码;也是补偿 warning 的来源)
RiskRepository(engine=sqlite_engine).insert_audit_log(
{
"trace_id": "T12C-VERIFY-TRACE",
"event_type": "convert_request",
"agent_type": "platform",
"actor_id": "SYSTEM",
"customer_id": CUST,
"rule_id": None,
"input_summary": {"convert_group_id": gid, "phase": "confirm"},
"decision": "engine_error",
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
return gid
def _compensate(sqlite_engine, gid: str) -> dict:
svc = _services(sqlite_engine)
return compensate_convert(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
convert_repo=svc["convert_repo"],
thresholds=_low_thresholds(),
now=NOW,
)
def _detail_rows(engine, gid: str) -> list[dict]:
return _rows(
engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
)
def test_compensate_rebuilds_detail_and_alerts(sqlite_engine):
"""补偿把「详情 + 预警」两件事都补齐,且详情直接置 completed(验收 17 · T+1)。
详情侧走 `sync_mirror`(与确认段第 ⑧ 步**同一镜像入口**),审计码为确认段同款
**`confirmed`**(不再是 v1.0 的 `convert_accepted`)。
"""
gid = _prepare_failed_confirm_t1(sqlite_engine, "CLI-T12-001")
# 前置态自检:确认净数据齐、镜像 failed、无预警单、engine_error 已留痕
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert _rows(sqlite_engine, "SELECT status FROM core_convert_request")[0]["status"] == "confirmed"
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed"
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
out = _compensate(sqlite_engine, gid)
assert out["state"] == "rebuilt"
assert out["detail"] == "rebuilt" and out["engine"] == "rebuilt"
assert "RISK-001" in out["triggered_rules"]
assert len(out["alert_ids"]) == 1
row = _detail_rows(sqlite_engine, gid)[0]
assert row["status"] == "completed"
# 补偿回填的指针与 Core 侧一致(幂等锚点 = 转出端)
assert row["out_trade_id"] == out["out_trade_id"] == "TRD-T12C-OUT"
assert row["in_trade_id"] == out["in_trade_id"] == "TRD-T12C-IN"
assert row["nav"] is not None and row["nav_date"] is not None
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1
# 补偿也补了主审计 —— 与确认段第 ⑧ 步**同一决策码**(T+1 口径)
audits = _rows(
sqlite_engine, "SELECT decision FROM audit_log WHERE decision = 'confirmed'"
)
assert len(audits) == 1
# 补偿审计可追溯:phase 标注补偿来源 + 引擎异常事实入 summary
summary = json.loads(
_rows(
sqlite_engine,
"SELECT input_summary FROM audit_log WHERE decision = 'confirmed'",
)[0]["input_summary"]
)
assert summary["phase"] == "confirm-compensate"
assert summary["engine_error"] is True
assert summary["out_trade_id"] == "TRD-T12C-OUT"
def test_compensate_is_idempotent(sqlite_engine):
"""重复补偿 → `skipped`,不产生第二张预警单、不重复写详情与主审计。"""
gid = _prepare_failed_confirm_t1(sqlite_engine, "CLI-T12-002")
first = _compensate(sqlite_engine, gid)
second = _compensate(sqlite_engine, gid)
assert first["state"] == "rebuilt"
assert second["state"] == "skipped"
assert second["detail"] == "already_completed"
assert second["engine"] == "skipped"
assert second["alert_ids"] == first["alert_ids"]
assert second["triggered_rules"] == []
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1 # 只有一张单
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'confirmed'")
) == 1 # 主审计不重复
# 曾引擎中断过 → 幂等跳过时附「人工核对」提示(与 rebuild_alerts 同款警示)
assert "人工核对" in second["warning"]
def test_compensate_missing_group_touches_nothing(sqlite_engine):
"""Core 侧不足两条流水 → missing,零写入(不是有效转换组)。"""
_seed(sqlite_engine)
out = _compensate(sqlite_engine, "CNV-T12-NOPE")
assert out["state"] == "missing"
assert out["trade_count"] == 0
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
assert _rows(sqlite_engine, "SELECT * FROM audit_log") == []
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
def test_compensate_missing_when_request_not_confirmed(sqlite_engine):
"""流水在但受理单非 confirmed(数据不完整)→ missing,零写入不硬补。
两条流水与受理单 confirmed 是**同一确认事务**(T-7);缺任意一件即不是
可补偿现场 —— 补偿只认「Core 已成、agent 侧缺失」。
"""
gid = _prepare_failed_confirm_t1(sqlite_engine, "CLI-T12-003")
_exec(
sqlite_engine,
"UPDATE core_convert_request SET status = 'accepted' WHERE convert_group_id = :g",
g=gid,
)
out = _compensate(sqlite_engine, gid)
assert out["state"] == "missing"
assert out["trade_count"] == 2 # 流水在,但受理单状态不满足 → 仍拒绝
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
# 镜像仍停在 failed(零写入)
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed"
def test_compensate_inserts_mirror_when_row_absent(sqlite_engine):
"""镜像行完全缺失(第 ⑧ 步首步即炸的现场)→ 补偿凭 Core 数据 INSERT 兜底。"""
gid = _prepare_failed_confirm_t1(sqlite_engine, "CLI-T12-005")
_exec(
sqlite_engine, "DELETE FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
)
out = _compensate(sqlite_engine, gid)
assert out["state"] == "rebuilt" and out["detail"] == "rebuilt"
row = _detail_rows(sqlite_engine, gid)[0]
assert row["status"] == "completed"
assert row["out_trade_id"] == "TRD-T12C-OUT"
def test_compensate_returns_locked_when_execution_right_taken(sqlite_engine):
"""未抢到 `convert:rerun:{gid}` → locked(有并发重试/实例在跑),零写入。"""
gid = _prepare_failed_confirm_t1(sqlite_engine, "CLI-T12-004")
with try_lock(f"convert:rerun:{gid}", 30) as acquired:
assert acquired is True
out = _compensate(sqlite_engine, gid)
assert out["state"] == "locked" and out["alert_ids"] == []
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed" # 仍待补偿
# ── 12. T+1 缺口补用(v1.0 用例退役后的承接,T-13)─────────────────────
def _seed_accepted_request(engine, cid_req: str, qty: str = "30000") -> str:
"""直插一张 accepted 受理单(确认段输入态;日历缺失由 `_safe_next_biz_day` 容错)。
两端净值都锚在受理日 T(`get_nav_on` 精确匹配);转出端净值取批次 nav 同值,
使逐批折算与明细口径自洽。
"""
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, 1.0300, 0, :d)",
p=PROD_OUT, d=TODAY,
)
_exec(
engine,
"INSERT INTO core_convert_request (convert_group_id, client_request_id,"
" customer_id, from_product_id, to_product_id, qty, status,"
" cancel_before, requested_at)"
" VALUES (:gid, :cid_req, :c, :fp, :tp, :q, 'accepted', NULL, :rat)",
gid="CNV-T13-0001", cid_req=cid_req, c=CUST, fp=PROD_OUT, tp=PROD_IN,
q=float(qty), rat=NOW,
)
return "CNV-T13-0001"
def test_confirm_mirror_write_failure_keeps_core_confirmed(sqlite_engine):
"""确认第 ⑧ 步镜像写入失败 → Core 已 confirmed 不回滚、镜像停 pending(v1.0
「阶段二失败不回滚」语义的 T+1 承接;失败现场由补偿 T-12 收口,真库证据
`verify_convert_compensate.py` A 组)。"""
_seed(sqlite_engine)
gid = _seed_accepted_request(sqlite_engine, "CLI-T13-MIRROR")
svc = _services(sqlite_engine)
with patch.object(
ConvertRepository, "sync_mirror", side_effect=RuntimeError("T13 故意:镜像失败")
):
out = confirm_one(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
convert_repo=svc["convert_repo"],
core_writer=svc["core_writer"],
request_repo=svc["request_repo"],
thresholds=_low_thresholds(),
now=NOW,
as_of=TODAY,
)
assert out["status"] == "confirmed" # Core 事务不受 agent 故障影响
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
req_status = _rows(
sqlite_engine,
"SELECT status FROM core_convert_request WHERE convert_group_id = :g", g=gid,
)[0]["status"]
assert req_status == "confirmed"
mirror = _detail_rows(sqlite_engine, gid)
assert mirror == [] or mirror[0]["status"] == "pending" # 第 ⑧ 步未推进(绝不写 completed)
def test_confirm_rejects_too_many_lots(sqlite_engine, monkeypatch):
"""参与批次数超上限 → `rejected` + `TOO_MANY_LOTS`(占用释放,份额不变;
v1.0 `TooManyLots` 4xx 语义在确认段的承接)。"""
_seed(sqlite_engine)
# 再补 2 个小批次 → 共 4 批;把确认批上限钳到 2
_seed_lot(sqlite_engine, "LOT-T13-3", "30", "1.0100", datetime(2026, 7, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "LOT-T13-4", "20", "1.0200", datetime(2026, 6, 1, 10, 0, 0))
gid = _seed_accepted_request(sqlite_engine, "CLI-T13-LOTS")
monkeypatch.setattr(settings, "convert_batch_max_lots", 2)
svc = _services(sqlite_engine)
out = confirm_one(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
core_writer=svc["core_writer"],
request_repo=svc["request_repo"],
convert_repo=svc["convert_repo"],
thresholds=_low_thresholds(),
now=NOW,
as_of=TODAY,
)
assert out["status"] == "rejected"
assert out["reject_reason"] == "TOO_MANY_LOTS"
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 0 # 无流水
remain = _rows(
sqlite_engine,
"SELECT COALESCE(SUM(remain_qty), 0) AS s FROM core_share_lot "
"WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_OUT,
)[0]["s"]
assert Decimal(str(remain)) == Decimal("200") # 份额原封不动(占用自然释放)
req_status = _rows(
sqlite_engine,
"SELECT status FROM core_convert_request WHERE convert_group_id = :g", g=gid,
)[0]["status"]
assert req_status == "rejected"