456 lines
20 KiB
Python
456 lines
20 KiB
Python
"""`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"
|