"""基金转换编排(`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"]