Files
group_xinghuo_jinrong/app/service/convert/convert_service.py
T
GaoYiYuan_0626 d0097d6004 基金转换 T-9:API 模型 + 网关分派(HTTP 层 convert 走通)+ 展示位数口径修复
一、T-9 本体:HTTP 层 convert 端到端走通

- api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount;
  convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验;
  未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True);
  PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError
  (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。
- gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派
  (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。
- utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots);
  既有 ApiError 无 extra 属性 → 老错误体逐字节不变。
- utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份
  内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py
  成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。

二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正)

发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。
根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的
回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。

修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 ——
金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口;
原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。

依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」;
「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位);
费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径
并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。

三、文档订正

- PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据);
  §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5
  「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。
- 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。

四、验证

- 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离):
  折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、
  幂等重试不产生第二组、跨主体 400、未知类型 400,以及
  「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。
- test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。
- test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为
  test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。
- pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。
- 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。
- 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条;
  关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红
  (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
2026-09-10 18:00:20 +08:00

889 lines
35 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-7 · 关键路径。
**八步顺序(PRD §7.0 固定顺序,前四步不落库)**
```
① 参数与产品校验 同产品 / can_redeem / can_subscribe / 同管理人+同 TA → 4xx,不占位
② 份额校验 Σ core_share_lot.remain_qty(权威源,非 holding.qty)
+ 最低转出份额(全额豁免)+ 批次数上限 → 4xx,不占位
③ 净值取数与折算 纯函数 calc.py;无净值 → 503 NAV_NOT_READY → 不占位
④ 适当性校验 仅**转入端**(FR-C5「转换即销售」)→ blocked → R-02 预警+审计 → return,不占位
⑤ 阶段零 try_lock("convert:idem:{cid_req}") + agent 库占位
⑥ 阶段一 apply_convert(core 库单事务)
⑦ 阶段 1.5 同步跑规则引擎(D17):异常不阻断已成立的交易
⑧ 阶段二 回写 completed + 主审计(+ nav_stale 副审计)
```
**为什么 blocked 与 4xx 都必须在占位之前(PRD §7.0)**:阶段零一旦占位,
失败就会留下 `pending` 孤儿;把纯校验前置后,这些路径**根本不产生持久化**,
不需要任何清理。
**幂等的两个锚点**
- `client_request_id` → `uk_idem`(agent 库唯一键)兜底重复提交;
- `convert_group_id`(**阶段零预生成**)→ 判定「阶段一是否已成」,
杜绝阶段二失败后重试产生**第二组流水**(PRD §7.4 v0.3 缺陷)。
**响应体**:PRD §5.3 字段,全部 `Decimal → str`(架构 §1 原则 11)。
未抢到执行权时返回 `{"status": "processing", "convert_group_id": ...}`,
由 T-9(`api/simulate.py`)映射为 **HTTP 202**。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Callable
from uuid import uuid4
from app.config.settings import settings
from app.gateway.convert_core_repository import (
ConvertApplyInput,
ConvertCoreRepository,
LotCharge,
)
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,
ensure_batch_limit,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
round2,
rounding_diff,
)
from app.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
IdempotencyUnavailable,
NavNotReady,
ProductNotRedeemable,
ProductNotSubscribable,
SameProduct,
)
from app.service.convert.fee import pick_fee_rate
from app.service.convert.types import FeeRule, Lot, PlanResult, to_decimal
from app.service.risk.alert_service import record_suitability_alert
from app.service.risk.locks import try_lock
from app.service.risk.rules import RiskThresholds
from app.service.suitability import suitability_check
from app.utils.trace import current_trace, ensure_trace, new_trace
logger = logging.getLogger(__name__)
#: 未抢到执行权时响应的 status 值(T-9 据此返回 HTTP 202)
PROCESSING = "processing"
CONFIRM_BASIS = "natural_day_approx" # T+1 用自然日近似(模拟库无交易日历,D14)
# ── 对外展示规格(**唯一定义点**,理由与外部依据见 `_q()` docstring)──────────
_D2 = Decimal("0.01") # 金额 · 份额
_D4 = Decimal("0.0001") # 净值 · 费率 · 份额尾差
@dataclass(frozen=True)
class _Quote:
"""③④ 步的折算结果(纯计算产物,用 dataclass 防止 dict 键名漂移)。"""
plan: PlanResult
charges: tuple[LotCharge, ...]
out_nav: Decimal
out_amount: Decimal
redeem_fee: Decimal
convert_amount: Decimal
diff_fee: Decimal
in_amount: Decimal
in_qty: Decimal
rounding_diff: Decimal
in_nav: Decimal
nav_date: date
nav_stale: bool
out_subscribe_fee_rate: Decimal
in_subscribe_fee_rate: Decimal
@property
def hold_days_range(self) -> tuple[int, int]:
return (
min(c.hold_days for c in self.charges),
max(c.hold_days for c in self.charges),
)
def _new_id(prefix: str, now: datetime) -> str:
return f"{prefix}-{now:%Y%m%d}-{uuid4().hex[:8].upper()}"
def _q(value: Decimal | None, unit: Decimal) -> str | None:
"""按**展示规格**量化后转字符串(对外唯一出口;None 原样透出)。
展示位数不是随手定的,取真实 TA 公告口径(PRD §2.5 · 架构 §1 原则 11):
================== ===== ==================================================
字段类 位数 现实依据
================== ===== ==================================================
金额(转出额/费用) 2 「转出金额以四舍五入的方式保留至小数点后两位」
份额(申请/转入) 2 「转入份额以四舍五入的方式保留至小数点后两位」;
「申请转换份额精确到小数点后两位」
净值 4 份额净值保留 4 位、第 5 位四舍五入(估值环节完成)
费率 4 公告以百分比 2 位表示(0.30% ↔ 0.0030)
份额尾差 4 需与净值同级(PRD §5.3 示例 `-0.0026`)
================== ===== ==================================================
⚠️ **库内一律 `DECIMAL(18,4)`**(TA 内部精度高于展示位),故展示必须经此处收敛 ——
否则同一响应在「首次按公式算」与「按库重建」两条路径下会吐出两种位数
(实测:首次 `53456.95` vs 重放 `53456.9500`)。
首次路径的值已由 `calc` 按同一规格量化 → 此处**幂等**(逐字节不变);
重放路径的值直读 `DECIMAL(18,4)` → 由此处收敛。
"""
if value is None:
return None
return str(to_decimal(value).quantize(unit, rounding=ROUND_HALF_UP))
def _audit(
repo: RiskRepository,
*,
decision: str,
group_id: str,
summary: dict[str, Any],
customer_id: str,
rule_id: str | None = None,
actor_id: str | None = None,
) -> None:
"""主/副审计写入(审计表只 INSERT,架构 §7.3 以 convert_group_id 为关联主键)。"""
repo.insert_audit_log(
{
"trace_id": current_trace() or new_trace(),
"event_type": "convert_request",
"agent_type": "platform",
"actor_id": actor_id or "SYSTEM",
"customer_id": customer_id,
"rule_id": rule_id,
"input_summary": {"convert_group_id": group_id, **summary},
"decision": decision,
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
# ── ① 参数与产品校验 ────────────────────────────────────────────────
def _validate_products(
core: CoreReadOnlyRepository, req: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
from_pid = str(req["from_product_id"])
to_pid = str(req["to_product_id"])
if from_pid == to_pid:
raise SameProduct()
out_product = core.get_product(from_pid)
if out_product is None or not int(out_product.get("can_redeem") or 0):
raise ProductNotRedeemable(f"转出基金 {from_pid} 当前不可赎回")
in_product = core.get_product(to_pid)
if in_product is None or not int(in_product.get("can_subscribe") or 0):
raise ProductNotSubscribable(f"转入基金 {to_pid} 当前不可申购")
# 同销售机构 + 同管理人 + 同 TA(PRD §2:三者缺一不可互转)
if (out_product.get("fund_company") or "") != (in_product.get("fund_company") or ""):
raise CrossEntityNotSupported(
f"两端管理人不同:{out_product.get('fund_company')} ≠ {in_product.get('fund_company')}"
)
if (out_product.get("ta_code") or "") != (in_product.get("ta_code") or ""):
raise CrossEntityNotSupported(
f"两端注册登记机构不同:{out_product.get('ta_code')} ≠ {in_product.get('ta_code')}"
)
return out_product, in_product
# ── ②③ 份额校验 + 净值折算 ──────────────────────────────────────────
def _plan_and_quote(
core: CoreReadOnlyRepository,
req: dict[str, Any],
out_product: dict[str, Any],
in_product: dict[str, Any],
now: datetime,
) -> _Quote:
customer_id = str(req["customer_id"])
from_pid = str(req["from_product_id"])
to_pid = str(req["to_product_id"])
trade_date = now.date()
requested = to_decimal(req["qty"])
if requested <= 0:
raise BelowMinQty("申请份额必须大于 0")
lots = [Lot.from_row(r) for r in core.list_share_lots(customer_id, from_pid)]
available = sum(
(lot.remain_qty for lot in lots if lot.remain_qty > 0), Decimal("0")
)
# 最低转出份额:**全额转出豁免**(PRD §12 I-3:清仓不受最低份额限制)
min_redeem = to_decimal(out_product.get("min_redeem_qty") or 0)
if min_redeem > 0 and requested < min_redeem and requested < available:
raise BelowMinQty(
f"申请份额 {requested} 低于最低转出份额 {min_redeem}"
"(全额转出可豁免)"
)
plan = plan_lots(
lots,
requested,
min_hold_qty=to_decimal(out_product.get("min_hold_qty") or 0)
if out_product.get("min_hold_qty") is not None
else None,
min_hold_action=str(out_product.get("min_hold_action") or "force_transfer"),
)
ensure_batch_limit(plan, settings.convert_batch_max_lots)
# ── 净值:转出端用**各批次自身成交净值**,转入端取 T 日净值(未知价法)──
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(from_pid)]
charges: list[LotCharge] = []
for alloc in plan.allocations:
days = hold_days(trade_date, alloc.confirmed_at)
rate = pick_fee_rate(rules, days, product_id=from_pid)
amount = lot_amount(alloc.qty, alloc.nav)
charges.append(
LotCharge(
lot_id=alloc.lot_id,
qty=alloc.qty,
hold_days=days,
amount=amount,
fee_rate=rate,
fee_amount=lot_fee(amount, rate),
nav=alloc.nav,
nav_date=trade_date,
)
)
in_nav_row = core.get_nav_as_of(to_pid, trade_date)
if in_nav_row is None:
raise NavNotReady(f"转入基金 {to_pid} 尚无 {trade_date} 当日或之前的净值")
in_nav = to_decimal(in_nav_row["nav"])
nav_date = in_nav_row["nav_date"]
if not isinstance(nav_date, date): # sqlite 读回为字符串
nav_date = date.fromisoformat(str(nav_date)[:10])
out_amount = sum((c.amount for c in charges), Decimal("0"))
redeem_fee = sum((c.fee_amount for c in charges), Decimal("0"))
conv = convert_amount(out_amount, redeem_fee)
out_rate = to_decimal(out_product.get("subscribe_fee_rate") or 0)
in_rate = to_decimal(in_product.get("subscribe_fee_rate") or 0)
gap = diff_fee(conv, out_rate, in_rate, settings.convert_diff_fee_mode)
in_amount = conv - gap
shares = in_qty(in_amount, in_nav)
# 转出端展示净值 = 金额 ÷ 份额(加权平均;计费仍逐批用各自 nav)
out_nav = round2(out_amount / plan.actual_qty) if plan.actual_qty else Decimal("0")
return _Quote(
plan=plan,
charges=tuple(charges),
out_nav=out_nav,
out_amount=out_amount,
redeem_fee=redeem_fee,
convert_amount=conv,
diff_fee=gap,
in_amount=in_amount,
in_qty=shares,
rounding_diff=rounding_diff(in_amount, in_nav, shares),
in_nav=in_nav,
nav_date=nav_date,
nav_stale=(trade_date - nav_date).days > settings.convert_nav_stale_days,
out_subscribe_fee_rate=out_rate,
in_subscribe_fee_rate=in_rate,
)
def _pick_rate(rules: list[FeeRule], days: int, product_id: str) -> Decimal:
"""持有天数 → 赎回费率(委托 `fee.pick_fee_rate`,无命中即 500 FeeRuleMissing)。"""
from app.service.convert.fee import pick_fee_rate
return pick_fee_rate(rules, days, product_id=product_id)
def _build_response(
req: dict[str, Any],
group_id: str,
quote: _Quote,
*,
out_trade_id: str,
in_trade_id: str,
engine_result: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""PRD §5.3 响应体(Decimal 全部按展示规格转 str,见 `_q()`)。"""
lo, hi = quote.hold_days_range
return {
"blocked": False,
"estimated": True, # T 日未知价法:申请时金额均为预估
"convert_group_id": group_id,
"client_request_id": req.get("client_request_id"),
"requested_qty": _q(quote.plan.requested_qty, _D2),
"actual_qty": _q(quote.plan.actual_qty, _D2),
"forced_full_transfer": quote.plan.forced_full_transfer,
"min_hold_action": quote.plan.action,
"out_trade_id": out_trade_id,
"out_nav": _q(quote.out_nav, _D4),
"out_amount": _q(quote.out_amount, _D2),
"lot_count": len(quote.charges),
"lot_breakdown": [
{
"lot_id": c.lot_id,
"qty": _q(c.qty, _D2),
"hold_days": c.hold_days,
"fee_rate": _q(c.fee_rate, _D4),
"fee_amount": _q(c.fee_amount, _D2),
"nav": _q(c.nav, _D4),
}
for c in quote.charges
],
"redeem_fee": _q(quote.redeem_fee, _D2),
"in_trade_id": in_trade_id,
"in_nav": _q(quote.in_nav, _D4),
"convert_amount": _q(quote.convert_amount, _D2),
"diff_fee": _q(quote.diff_fee, _D2),
"in_amount": _q(quote.in_amount, _D2),
"in_qty": _q(quote.in_qty, _D2),
"rounding_diff": _q(quote.rounding_diff, _D4),
"out_subscribe_fee_rate": _q(quote.out_subscribe_fee_rate, _D4),
"in_subscribe_fee_rate": _q(quote.in_subscribe_fee_rate, _D4),
"nav_date": str(quote.nav_date),
"nav_stale": quote.nav_stale,
"confirm_basis": CONFIRM_BASIS,
"hold_days_min": lo,
"hold_days_max": hi,
"triggered_rules": (engine_result or {}).get("triggered_rules", []),
"alert_ids": (engine_result or {}).get("alert_ids", []),
"aml_hit": (engine_result or {}).get("aml_hit", False),
"engine_error": bool((engine_result or {}).get("engine_error")),
}
# ── 主入口 ──────────────────────────────────────────────────────────
def convert_fund(
req: dict[str, Any],
core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None,
convert_repo: ConvertRepository | None = None,
core_writer: ConvertCoreRepository | None = None,
thresholds: RiskThresholds | None = None,
now: datetime | None = None,
actor_id: str | None = None,
*,
engine_hook: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]] | None = None,
id_factory: Callable[[str, datetime], str] | None = None,
) -> dict[str, Any]:
"""执行一次基金转换(PRD §7.0 八步)。
`req`:`{customer_id, from_product_id, to_product_id, qty, client_request_id?}`。
`engine_hook`:阶段 1.5 的注入点(T-8 未落地时传假函数;缺省自动尝试
`engine.process_convert_event`,不存在则跳过并记录 warning)。
`id_factory`:`(前缀, now) -> id`,测试注入点(与 `trade_gateway` 同款)。
"""
core = core_ro or CoreReadOnlyRepository()
repo = risk_repo or RiskRepository()
crepo = convert_repo or ConvertRepository()
writer = core_writer or ConvertCoreRepository()
th = thresholds or RiskThresholds.from_settings()
ensure_trace()
now = now or datetime.now()
new_id = id_factory or _new_id
customer_id = str(req["customer_id"])
cid_req = req.get("client_request_id") or None
# ── 幂等前置(必须先于 ①②③④,实施期修正)──
# 同 client_request_id 重试时,首次已扣减 core_share_lot 份额,若先跑 ② plan_lots
# 会误报 InsufficientShares;故先判定「已完成 / 阶段一成」并直接返回首次结果。
if cid_req is None:
# 未带幂等键:免占位直跑(PRD §7.3 / Q7,无幂等语义)
group_id = new_id("CNV", now)
placeholder = False
else:
with try_lock(
f"convert:idem:{cid_req}", settings.convert_lock_ttl_seconds
) as acquired:
if not acquired:
# 有并发请求正在执行 → 202,不查占位、不进阶段一
return {"status": PROCESSING, "convert_group_id": None}
existing = crepo.get_by_client_request_id(cid_req)
if existing is not None:
hit_gid = str(existing["convert_group_id"])
if str(existing["status"]) == "completed":
rebuilt = rebuild_convert_response(hit_gid, core_ro=core)
if rebuilt is not None:
return rebuilt
# 阶段一已成、阶段二未成 → 只补跑阶段二(加锁防并发重试审计双写)
if core.has_convert_trades(hit_gid):
with try_lock(
f"convert:rerun:{hit_gid}", settings.convert_lock_ttl_seconds
):
rebuilt = _finalize_from_core(
req, hit_gid, core, repo, crepo, now, actor_id
)
if rebuilt is not None:
return rebuilt
# 阶段一未成 → 复用同一 group_id 重跑,杜绝第二组流水
group_id = hit_gid
else:
group_id = new_id("CNV", now)
placeholder = True
# ① 参数与产品校验(不落库)
out_product, in_product = _validate_products(core, req)
# ② 份额校验 + ③ 净值取数与折算(不落库)
quote = _plan_and_quote(core, req, out_product, in_product, now)
# ④ 适当性校验(转入端 · 唯一的业务阻断点)→ blocked 时**不占位**
suit = suitability_check(
customer_id,
str(req["to_product_id"]),
core_ro=core,
risk_repo=repo,
check_source="r02_trade",
actor_id=actor_id,
request_ref=group_id,
)
if suit.blocked:
record_suitability_alert(
{
"trade_id": group_id,
"customer_id": customer_id,
"product_id": str(req["to_product_id"]),
"trade_type": "convert",
"amount": str(quote.in_amount),
"traded_at": str(now),
},
rule_id=suit.rule_id,
block_reason=suit.block_reason,
risk_repo=repo,
)
_audit(
repo,
decision="suitability_blocked",
group_id=group_id,
customer_id=customer_id,
rule_id=suit.rule_id,
actor_id=actor_id,
summary={
"from_product_id": req["from_product_id"],
"to_product_id": req["to_product_id"],
"requested_qty": _q(quote.plan.requested_qty, _D2),
"block_reason": suit.block_reason,
"block_response_code": suit.block_response_code,
"reasons": list(suit.reasons),
},
)
return {
"blocked": True,
"convert_group_id": group_id,
"match_result": suit.match_result,
"mismatch_type": suit.mismatch_type,
"requires_disclosure": suit.requires_disclosure,
"needs_branch_confirm": suit.needs_branch_confirm,
"block_reason": suit.block_reason,
"block_response_code": suit.block_response_code,
"rule_refs": suit.rule_refs,
"reasons": list(suit.reasons),
"advice": "请联系持证投资顾问",
"notice": "本次请求已记录",
}
# ⑤ 阶段零:占位(仅带幂等键时;在全部 4xx 之后,故 4xx 不留占位)
if placeholder:
try:
crepo.insert_placeholder(group_id, cid_req)
except Exception as exc: # noqa: BLE001
# 占位失败 = 无法保证幂等 → 不放行(PRD §7.3)
logger.exception("convert 占位失败:%s", group_id)
raise IdempotencyUnavailable(f"幂等占位失败:{exc}") from exc
out_trade_id = new_id("TRD", now)
in_trade_id = new_id("TRD", now)
apply_input = ConvertApplyInput(
convert_group_id=group_id,
out_trade_id=out_trade_id,
in_trade_id=in_trade_id,
customer_id=customer_id,
from_product_id=str(req["from_product_id"]),
to_product_id=str(req["to_product_id"]),
traded_at=now,
out_qty=quote.plan.actual_qty,
out_amount=quote.out_amount,
in_qty=quote.in_qty,
in_amount=quote.in_amount,
in_nav=quote.in_nav,
in_nav_date=quote.nav_date,
in_lot_id=f"LOT-{group_id}-IN",
in_confirmed_at=now + timedelta(days=settings.convert_confirm_offset_days),
charges=quote.charges,
)
# ⑥ 阶段一:core 库单事务(失败 → 占位置 failed,供人工补偿)
try:
writer.apply_convert(apply_input)
except Exception:
logger.exception("convert 阶段一失败:%s", group_id)
if cid_req is not None:
try:
crepo.mark_failed(group_id)
except Exception: # noqa: BLE001
logger.exception("占位标记 failed 失败(不影响原始异常):%s", group_id)
raise
# ⑦ 阶段 1.5:同步跑规则引擎(D17:异常不阻断已成立的交易)
engine_result: dict[str, Any] | None = None
try:
engine_result = _run_engine(
{
"trade_id": out_trade_id,
"customer_id": customer_id,
"product_id": str(req["from_product_id"]),
"trade_type": "redeem",
"amount": quote.out_amount,
"qty": quote.plan.actual_qty,
"trade_status": "confirmed",
"traded_at": now,
"convert_group_id": group_id,
},
{
"trade_id": in_trade_id,
"customer_id": customer_id,
"product_id": str(req["to_product_id"]),
"trade_type": "subscribe",
"amount": quote.in_amount,
"qty": quote.in_qty,
"trade_status": "confirmed",
"traded_at": now,
"convert_group_id": group_id,
},
core_ro=core,
risk_repo=repo,
thresholds=th,
engine_hook=engine_hook,
)
except Exception: # noqa: BLE001
logger.exception("convert 阶段 1.5 引擎失败(不阻断交易):%s", group_id)
_audit(
repo,
decision="engine_error",
group_id=group_id,
customer_id=customer_id,
actor_id=actor_id,
summary={"error_stage": "process_convert_event"},
)
engine_result = {"triggered_rules": [], "alert_ids": [], "aml_hit": False,
"engine_error": True}
response = _build_response(
req, group_id, quote, out_trade_id=out_trade_id, in_trade_id=in_trade_id,
engine_result=engine_result,
)
# ⑧ 阶段二:回写 completed + 主审计(失败**不回滚 Core**)
lo, hi = quote.hold_days_range
try:
crepo.complete_convert(
group_id,
out_trade_id=out_trade_id,
in_trade_id=in_trade_id,
related_trade_id=out_trade_id,
nav=quote.in_nav,
nav_date=quote.nav_date,
fee_amount=quote.redeem_fee,
hold_days_min=lo,
hold_days_max=hi,
nav_stale=quote.nav_stale,
)
_write_main_audit(
req, group_id, quote, out_trade_id, in_trade_id, repo, now, actor_id, engine_result
)
except Exception: # noqa: BLE001
# 交易已成立:只能留痕 + 本地日志兜底(PRD §7.1 第三轮第 8 条)
logger.exception(
"convert 阶段二失败(交易已成立,待补偿)group_id=%s quote=%s",
group_id,
{
"out_amount": _q(quote.out_amount, _D2),
"in_amount": _q(quote.in_amount, _D2),
"in_qty": _q(quote.in_qty, _D2),
"out_trade_id": out_trade_id,
"in_trade_id": in_trade_id,
},
)
try:
_audit(
repo,
decision="convert_detail_write_failed",
group_id=group_id,
customer_id=customer_id,
actor_id=actor_id,
summary={"out_trade_id": out_trade_id, "in_trade_id": in_trade_id},
)
except Exception: # noqa: BLE001
logger.exception("阶段二失败审计亦写入失败:%s", group_id)
if cid_req is not None:
try:
crepo.mark_failed(group_id)
except Exception: # noqa: BLE001
logger.exception("占位标记 failed 失败:%s", group_id)
return response
# ── 阶段 1.5 的引擎调用(D17)──────────────────────────────────────
def _run_engine(
out_trade: dict[str, Any],
in_trade: dict[str, Any],
*,
core_ro: CoreReadOnlyRepository,
risk_repo: RiskRepository,
thresholds: RiskThresholds,
engine_hook: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]] | None,
) -> dict[str, Any] | None:
"""调用 `process_convert_event`(T-8 落地后自动生效,未落地则跳过)。
T-8 属并行组 B,与 T-7 并行开发,故此处**延迟导入 + 缺失即跳过**:
T-8 合入后无需改本文件;期间 convert 主流程不受影响(引擎本就不阻断交易)。
"""
if engine_hook is not None:
return engine_hook(out_trade, in_trade)
try:
from app.service.risk.engine import process_convert_event
except ImportError:
logger.warning(
"process_convert_event 尚未实现(T-8 未落地),跳过阶段 1.5:group=%s",
out_trade.get("convert_group_id"),
)
return None
return process_convert_event(
out_trade,
in_trade,
core_ro=core_ro,
risk_repo=risk_repo,
thresholds=thresholds,
)
def _finalize_from_core(
req: dict[str, Any],
group_id: str,
core: CoreReadOnlyRepository,
repo: RiskRepository,
crepo: ConvertRepository,
now: datetime,
actor_id: str | None,
) -> dict[str, Any] | None:
"""补跑阶段二:仅凭 Core 侧数据回填详情 + 审计(阶段一已成、阶段二未成)。
**幂等窗口闭合(验收 15)**:阶段二失败后带同键重试 → 不重跑阶段一,
不产生第二组流水,RISK-002 当日累计也不翻倍。
"""
rebuilt = _rebuild_quote(group_id, core)
if rebuilt is None:
return None
quote, out_trade_id, in_trade_id = rebuilt
lo, hi = quote.hold_days_range
try:
crepo.complete_convert(
group_id,
out_trade_id=out_trade_id,
in_trade_id=in_trade_id,
related_trade_id=out_trade_id,
nav=quote.in_nav,
nav_date=quote.nav_date,
fee_amount=quote.redeem_fee,
hold_days_min=lo,
hold_days_max=hi,
nav_stale=quote.nav_stale,
)
_write_main_audit(
req, group_id, quote, out_trade_id, in_trade_id, repo, now, actor_id, None
)
except Exception: # noqa: BLE001
logger.exception("补跑阶段二失败:%s", group_id)
return _build_response(
req, group_id, quote, out_trade_id=out_trade_id, in_trade_id=in_trade_id
)
def _write_main_audit(
req: dict[str, Any],
group_id: str,
quote: _Quote,
out_trade_id: str,
in_trade_id: str,
repo: RiskRepository,
now: datetime,
actor_id: str | None,
engine_result: dict[str, Any] | None,
) -> None:
"""主审计 + `nav_stale` 副审计(PRD §7.3:实际为 1~2 条)。"""
_audit(
repo,
decision="convert_accepted",
group_id=group_id,
customer_id=str(req["customer_id"]),
actor_id=actor_id,
summary={
"from_product_id": req.get("from_product_id"),
"to_product_id": req.get("to_product_id"),
"requested_qty": _q(quote.plan.requested_qty, _D2),
"actual_qty": _q(quote.plan.actual_qty, _D2),
"forced_full_transfer": quote.plan.forced_full_transfer,
"out_amount": _q(quote.out_amount, _D2),
"redeem_fee": _q(quote.redeem_fee, _D2),
"convert_amount": _q(quote.convert_amount, _D2),
"diff_fee": _q(quote.diff_fee, _D2),
"in_amount": _q(quote.in_amount, _D2),
"in_qty": _q(quote.in_qty, _D2),
"rounding_diff": _q(quote.rounding_diff, _D4),
"nav": _q(quote.in_nav, _D4),
"nav_date": str(quote.nav_date),
"nav_stale": quote.nav_stale,
"estimated": True,
"lot_count": quote.plan.batch_count,
"out_trade_id": out_trade_id,
"in_trade_id": in_trade_id,
**(dict(engine_result or {})),
},
)
if quote.nav_stale:
_audit(
repo,
decision="nav_stale",
group_id=group_id,
customer_id=str(req["customer_id"]),
actor_id=actor_id,
summary={
"nav_date": str(quote.nav_date),
"trade_date": str(now.date()),
"stale_days": (now.date() - quote.nav_date).days,
},
)
def _rebuild_quote(
group_id: str, core: CoreReadOnlyRepository
) -> tuple[_Quote, str, str] | None:
"""由 Core 侧数据重建 `_Quote`(幂等命中 / 阶段二补跑共用)。
`core_trade` 带两端金额与份额、`core_convert_lot_detail` 带 nav/fee,故即使
agent 库详情丢失也能完整重建(补偿可行性前提,PRD §7.1 第三轮第 2 条)。
返回 `(quote, out_trade_id, in_trade_id)`;Core 侧流水不足 2 条返回 None。
⚠️ 两处不可从 Core 还原、只能置默认值的字段见 `rebuild_convert_response` 注释。
"""
trades = core.list_convert_trades(group_id)
details = core.list_convert_lot_details(group_id)
if len(trades) < 2:
return None
out_trade = next(t for t in trades if t["trade_type"] == "redeem")
in_trade = next(t for t in trades if t["trade_type"] == "subscribe")
out_amount = to_decimal(out_trade["amount"])
in_amount = to_decimal(in_trade["amount"])
in_qty_val = to_decimal(in_trade["qty"])
out_qty_val = to_decimal(out_trade["qty"])
redeem_fee = sum((to_decimal(d["fee_amount"]) for d in details), Decimal("0"))
conv = convert_amount(out_amount, redeem_fee)
# 转入净值 = 净转入金额 ÷ 转入份额(与 in_qty 同口径反推,无需再查净值表)
in_nav = (in_amount / in_qty_val).quantize(Decimal("0.0001")) if in_qty_val else Decimal("0")
nav_date = details[0]["nav_date"] if details else _as_date(out_trade["traded_at"])
if not isinstance(nav_date, date):
nav_date = date.fromisoformat(str(nav_date)[:10])
def _rate_of(product_id: str) -> Decimal:
product = core.get_product(product_id)
return to_decimal(product.get("subscribe_fee_rate") or 0) if product else Decimal("0")
charges = tuple(
LotCharge(
lot_id=str(d["lot_id"]),
qty=to_decimal(d["qty"]),
hold_days=int(d["hold_days"]),
amount=to_decimal(d["amount"]),
fee_rate=to_decimal(d["fee_rate"]),
fee_amount=to_decimal(d["fee_amount"]),
nav=to_decimal(d["nav"]),
nav_date=d["nav_date"],
)
for d in details
)
plan = PlanResult(
allocations=tuple(),
requested_qty=out_qty_val,
actual_qty=out_qty_val,
available_qty=out_qty_val,
forced_full_transfer=False,
action="transfer",
)
quote = _Quote(
plan=plan,
charges=charges,
out_nav=round2(out_amount / out_qty_val) if out_qty_val else Decimal("0"),
out_amount=out_amount,
redeem_fee=redeem_fee,
convert_amount=conv,
diff_fee=conv - in_amount,
in_amount=in_amount,
in_qty=in_qty_val,
rounding_diff=rounding_diff(in_amount, in_nav, in_qty_val),
in_nav=in_nav,
nav_date=nav_date,
nav_stale=(date.today() - nav_date).days > settings.convert_nav_stale_days,
out_subscribe_fee_rate=_rate_of(str(out_trade["product_id"])),
in_subscribe_fee_rate=_rate_of(str(in_trade["product_id"])),
)
return quote, str(out_trade["trade_id"]), str(in_trade["trade_id"])
def rebuild_convert_response(
group_id: str, core_ro: CoreReadOnlyRepository | None = None
) -> dict[str, Any] | None:
"""由 Core 侧重建响应(幂等命中返回「首次结果」/ 补偿脚本共用)。
Core 侧**自包含**全部折算输入(D6:`core_convert_lot_detail` 带 nav/nav_date、
`core_trade` 带两端金额与份额),故即使 agent 库详情丢失也能完整重建 ——
这正是「补偿可行性」的前提(PRD §7.1 第三轮第 2 条)。
⚠️ 两处**不可从 Core 还原**、只能置默认值的字段:`nav_stale`(依赖当时的
交易日与阈值)、`forced_full_transfer`(依赖当时申请份额)。前者由
`nav_date` 重新判定(用当前日期),后者固定 False —— 幂等命中不会改动
已成立的交易,展示差异以注释标明。
"""
core = core_ro or CoreReadOnlyRepository()
rebuilt = _rebuild_quote(group_id, core)
if rebuilt is None:
return None
quote, out_trade_id, in_trade_id = rebuilt
trades = core.list_convert_trades(group_id)
out_trade = next(t for t in trades if t["trade_type"] == "redeem")
in_trade = next(t for t in trades if t["trade_type"] == "subscribe")
return _build_response(
{
"from_product_id": out_trade["product_id"],
"to_product_id": in_trade["product_id"],
},
group_id,
quote,
out_trade_id=out_trade_id,
in_trade_id=in_trade_id,
)
def _as_date(value: Any) -> date:
"""sqlite 读回 DATE/TIMESTAMP 为字符串 → 统一成 date。"""
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
__all__ = ["convert_fund", "rebuild_convert_response", "PROCESSING"]