Files
group_xinghuo_jinrong/tests/test_convert_service.py
T
GaoYiYuan_0626 7ad1c8204d 基金转换 T-8:规则引擎改造(_amount_view 去重视图 + process_convert_event)
一次转换落两条流水(转出 redeem + 转入 subscribe,同 convert_group_id),
金额聚合类规则若两条都算会翻倍报假预警,故按「逐笔 / 聚合」拆成两个视图。

- rules.py:新增 _amount_view —— 同组内只保留 redeem 那条(无 convert_group_id
  的交易恒等通过、组内无 redeem 保首条、绝不删行);run_rules 双视图分流:
  RISK-001/003/004 用全量 eligible(逐笔判定),RISK-002/005 走金额视图去重。
- alert_service.py:抽出公开 build_trade_event(事件体结构唯一定义),
  record_trade_alerts 新增可选 events 参数;缺省 None 退化为单条,
  既有调用零改动。新建单落 payload.events 全部,聚合追加只追首条(只认转出端)。
- engine.py:抽 _run 共用实现;process_trade_event 变薄封装(签名与行为不变);
  新增 process_convert_event(out_trade, in_trade, ...) 与 _notify_error_hook
  (hook 自身异常吞掉,原始异常照常上抛)。

实质影响:阶段 1.5 从「ImportError 静默跳过」变为「真跑」——T-7 部署时
process_convert_event 不存在,T-8 落地后同一笔转换会真实出单 + 写 L3 + 落审计;
T-7 真库脚本复跑仍 35/35,无连带破坏。

验证:新增 tests/test_convert_engine.py(15 用例)+ test_convert_service.py
接线回归 1 条;pytest 672 passed / 3 skipped(基线 656 +16,零回归);
新增 scripts/dev/verify_convert_engine.py 真 MySQL 验证 31/31;
突变验证(关掉去重视图 → 4 条变红)确认用例非假绿。
2026-09-10 17:34:35 +08:00

471 lines
19 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.
"""T-7 `convert_service` 八步编排单测(开发计划 §6.2 DoD)。
sqlite 内存库(`conftest.sqlite_engine`,含 C×R 矩阵种子),数据自建。
折算期望值一律由生产 `calc.py` 实算(不自造公式副本,自检第 13 问)。
覆盖:
1. 三阶段贯通(占位 pending→completed / 2 流水 / 响应字段与 PRD §5.3 对齐)
2. **blocked 不占位**(PRD §7.0:前四步不落库)
3. 幂等命中返回首次结果、**不产生第二组流水**
4. 未抢到执行权 → `status=processing`(T-9 映射 202)
5. 全额转出豁免 `min_redeem_qty`(验收 16)/ 强制全转留痕
6. `nav_stale` 额外落副审计(1~2 条)
7. 阶段 1.5 引擎异常**不阻断**已成立交易
8. 阶段二失败 → `convert_detail_write_failed` 审计 + 占位 `failed`
9. 各 4xx/503 分支:SameProduct / 不可赎回 / 跨主体 / 份额不足 / 低于最低份额 /
无净值 / 批次数超限
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.calc import (
convert_amount,
diff_fee,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
)
from app.service.convert.convert_service import PROCESSING, convert_fund
from app.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
InsufficientShares,
NavNotReady,
ProductNotRedeemable,
SameProduct,
TooManyLots,
)
from app.service.convert.fee import pick_fee_rate
from app.service.convert.types import FeeRule, Lot
from app.service.risk.rules import RiskThresholds
CUST = "CUST-T7" # C3 客户 → R4 产品 allowed_with_disclosure(放行)
CUST_LOW = "CUST-T7L" # C1 客户 → R4 forbidden(用于 blocked 分支)
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 (customer_id, display_name, age, is_active) "
"VALUES (:c, 'T7低风险客户', 40, 1)",
c=CUST_LOW,
)
_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_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C1', :t, :exp)",
c=CUST_LOW, 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,
)
# CUST_LOW(C1,R4 应被适当性拦截)也需有份额,否则 ② 份额校验会先于 ④ 触发
_seed_lot(engine, "LOT-T7L-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0),
customer=CUST_LOW)
_seed_lot(engine, "LOT-T7L-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0),
customer=CUST_LOW)
_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_LOW, 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_writer=ConvertCoreRepository(engine=engine),
)
def _req(customer: str = CUST, qty: str = "120", cid_req: str | None = None) -> dict:
return {
"customer_id": customer,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": qty,
"client_request_id": cid_req,
}
def _expected(engine, requested: str) -> dict:
"""用生产纯函数算一遍期望值(与 service 内部同一套口径)。"""
core = CoreReadOnlyRepository(engine=engine)
lots = [Lot.from_row(r) for r in core.list_share_lots(CUST, PROD_OUT)]
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(PROD_OUT)]
plan = plan_lots(lots, Decimal(requested))
out_amount = Decimal("0")
redeem_fee = Decimal("0")
for alloc in plan.allocations:
amount = lot_amount(alloc.qty, alloc.nav)
rate = pick_fee_rate(rules, hold_days(TODAY, alloc.confirmed_at), product_id=PROD_OUT)
out_amount += amount
redeem_fee += lot_fee(amount, rate)
conv = convert_amount(out_amount, redeem_fee)
gap = diff_fee(conv, OUT_RATE, IN_RATE, "amount_diff")
in_amount = conv - gap
return {
"out_amount": out_amount,
"redeem_fee": redeem_fee,
"convert_amount": conv,
"diff_fee": gap,
"in_amount": in_amount,
"in_qty": in_qty(in_amount, IN_NAV),
"actual_qty": plan.actual_qty,
}
# ── 1. 三阶段贯通 ───────────────────────────────────────────────────
def test_happy_path_writes_two_trades_and_completes_placeholder(sqlite_engine):
_seed(sqlite_engine)
exp = _expected(sqlite_engine, "120")
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["estimated"] is True
assert resp["convert_group_id"].startswith("CNV-")
assert Decimal(resp["out_amount"]) == exp["out_amount"]
assert Decimal(resp["redeem_fee"]) == exp["redeem_fee"]
assert Decimal(resp["convert_amount"]) == exp["convert_amount"]
assert Decimal(resp["diff_fee"]) == exp["diff_fee"]
assert Decimal(resp["in_amount"]) == exp["in_amount"]
assert Decimal(resp["in_qty"]) == exp["in_qty"]
assert Decimal(resp["actual_qty"]) == exp["actual_qty"]
assert resp["lot_count"] == 2
assert [b["hold_days"] for b in resp["lot_breakdown"]] == [34, 3]
assert resp["confirm_basis"] == "natural_day_approx"
# 阶段二:占位 completed
rows = _rows(
sqlite_engine,
"SELECT * FROM risk_convert_detail WHERE convert_group_id = :g",
g=resp["convert_group_id"],
)
assert len(rows) == 1 and rows[0]["status"] == "completed"
# 两条流水同组、redeem/subscribe(R-b)
trades = _rows(
sqlite_engine,
"SELECT * FROM core_trade WHERE convert_group_id = :g ORDER BY trade_type",
g=resp["convert_group_id"],
)
assert [t["trade_type"] for t in trades] == ["redeem", "subscribe"]
# 主审计 1 条(净值新鲜 → 无 nav_stale 副审计)
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
assert _rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'") == []
# ── 2. blocked 不占位(PRD §7.0)─────────────────────────────────────
def test_suitability_blocked_does_not_placeholder(sqlite_engine):
_seed(sqlite_engine)
resp = convert_fund(
_req(customer=CUST_LOW), now=NOW, **_services(sqlite_engine)
)
assert resp["blocked"] is True
assert resp["block_response_code"]
# 关键:前四步不落库 —— 无占位、无流水
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'suitability_blocked'")
) == 1
# ── 3. 幂等:同键重复提交不产生第二组流水 ────────────────────────────
def test_idempotent_repeat_returns_first_result(sqlite_engine):
_seed(sqlite_engine)
svc = _services(sqlite_engine)
first = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
again = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
assert again["convert_group_id"] == first["convert_group_id"]
assert again["in_qty"] == first["in_qty"]
assert again["out_trade_id"] == first["out_trade_id"]
# 只有一组流水(2 条),没有第二组
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1
# ── 4. 未抢到执行权 → processing(T-9 映射 202)───────────────────────
def test_lock_not_acquired_returns_processing(sqlite_engine, monkeypatch):
import app.service.convert.convert_service as cs
from app.service.risk.locks import _NoLock
_seed(sqlite_engine)
monkeypatch.setattr(cs, "try_lock", lambda *a, **k: _NoLock())
resp = convert_fund(_req(cid_req="CLI-T7-002"), now=NOW, **_services(sqlite_engine))
assert resp["status"] == PROCESSING
assert "convert_group_id" in resp
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
# ── 5. 全额转出豁免最低份额(验收 16)────────────────────────────────
def test_full_transfer_waives_min_redeem_qty(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150(< min_redeem 10000)但申请全额 → 豁免,成功
resp = convert_fund(_req(qty="150"), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert Decimal(resp["actual_qty"]) == Decimal("150")
def test_below_min_qty_rejected_when_not_full(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150、申请 100(< 最低 10000,且非全额)→ 400 BELOW_MIN_QTY
with pytest.raises(BelowMinQty):
convert_fund(_req(qty="100"), now=NOW, **_services(sqlite_engine))
# ── 6. 强制全转留痕 ─────────────────────────────────────────────────
def test_forced_full_transfer_flag(sqlite_engine):
_seed(sqlite_engine, min_hold="100")
# 持 150、申请 140 → 余额 10 < 100 → 强制全转 150
resp = convert_fund(_req(qty="140"), now=NOW, **_services(sqlite_engine))
assert resp["forced_full_transfer"] is True
assert resp["min_hold_action"] == "force_transfer"
assert Decimal(resp["actual_qty"]) == Decimal("150")
# ── 7. nav_stale 额外落副审计 ────────────────────────────────────────
def test_nav_stale_adds_second_audit(sqlite_engine):
_seed(sqlite_engine, nav_date=date(2026, 8, 25)) # 距今 10 天 > 3
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["nav_stale"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'")) == 1
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
# ── 8. 阶段 1.5:引擎异常不阻断已成立的交易 ───────────────────────────
def test_engine_exception_does_not_block_trade(sqlite_engine):
_seed(sqlite_engine)
def boom(out_trade, in_trade): # noqa: ANN001
raise RuntimeError("引擎炸了")
resp = convert_fund(_req(), now=NOW, engine_hook=boom, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["engine_error"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2 # 交易仍成立
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'engine_error'")) == 1
def test_engine_result_merged_into_response(sqlite_engine):
_seed(sqlite_engine)
hook = lambda o, i: { # noqa: E731
"triggered_rules": ["RISK-002"],
"alert_ids": ["ALT-1"],
"aml_hit": False,
}
resp = convert_fund(_req(), now=NOW, engine_hook=hook, **_services(sqlite_engine))
assert resp["triggered_rules"] == ["RISK-002"]
assert resp["alert_ids"] == ["ALT-1"]
# ── 9. 阶段二失败:留痕 + 占位 failed(不回滚 Core)───────────────────
def test_phase_two_failure_keeps_trade_and_marks_failed(sqlite_engine, monkeypatch):
_seed(sqlite_engine)
def boom(self, group_id, **kwargs): # noqa: ANN001
raise RuntimeError("阶段二写失败")
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
resp = convert_fund(
_req(cid_req="CLI-T7-003"), now=NOW, **_services(sqlite_engine)
)
# 交易已成立:core 有两条流水,响应照常返回
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert resp["blocked"] is False
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
)
) == 1
rows = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert rows[0]["status"] == "failed"
# ── 10. 各 4xx / 503 分支 ───────────────────────────────────────────
def test_same_product_rejected(sqlite_engine):
_seed(sqlite_engine)
req = _req()
req["to_product_id"] = PROD_OUT
with pytest.raises(SameProduct):
convert_fund(req, now=NOW, **_services(sqlite_engine))
def test_cross_entity_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET fund_company = '易方达模拟基金' WHERE product_id = :p",
p=PROD_IN,
)
with pytest.raises(CrossEntityNotSupported):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_out_product_not_redeemable_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET can_redeem = 0 WHERE product_id = :p",
p=PROD_OUT,
)
with pytest.raises(ProductNotRedeemable):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_insufficient_shares_rejected(sqlite_engine):
_seed(sqlite_engine)
with pytest.raises(InsufficientShares):
convert_fund(_req(qty="500"), now=NOW, **_services(sqlite_engine))
def test_nav_not_ready_503(sqlite_engine):
_seed(sqlite_engine)
_exec(sqlite_engine, "DELETE FROM core_product_nav")
with pytest.raises(NavNotReady) as exc:
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert exc.value.status_code == 503
def test_too_many_lots_rejected(sqlite_engine, monkeypatch):
from app.config import settings as settings_module
_seed(sqlite_engine)
monkeypatch.setattr(settings_module.settings, "convert_batch_max_lots", 1)
with pytest.raises(TooManyLots) as exc:
convert_fund(_req(qty="120"), now=NOW, **_services(sqlite_engine))
assert exc.value.extra == {"batch_count": 2, "max_lots": 1}
# ── 10. 阶段 1.5 接线(T-8):引擎真跑并出单 ─────────────────────────
def test_engine_wired_produces_single_alert_with_two_events(sqlite_engine):
"""T-8 落地后阶段 1.5 不再跳过:大额转换**真出一张单**、`payload.events` 两条(验收 7)。
本用例是「接线回归」:若 `_run_engine` 又被改回静默跳过(或签名对不上被
ImportError 吞掉),这里会因 `triggered_rules` 为空而变红。
"""
_seed(sqlite_engine)
th = RiskThresholds(
large_amount=Decimal("100"), # 调低以让 120 份的折算额命中 RISK-001
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,
)
resp = convert_fund(_req(), now=NOW, thresholds=th, **_services(sqlite_engine))
assert resp["engine_error"] is False, "引擎真的跑了且没炸(未走 ImportError 跳过分支)"
assert "RISK-001" in resp["triggered_rules"]
assert len(resp["alert_ids"]) == 1, "一次转换只出一张单"
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
assert len(alerts) == 1
payload = json.loads(alerts[0]["payload"])
assert len(payload["events"]) == 2, "一张单承载两条事件(转出 + 转入)"
assert [e["trade_type"] for e in payload["events"]] == ["redeem", "subscribe"]