一、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' 直接复现原缺陷)。均已恢复。
889 lines
35 KiB
Python
889 lines
35 KiB
Python
"""基金转换编排(`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"]
|