Files
group_xinghuo_jinrong/app/service/convert/convert_service.py
T

889 lines
35 KiB
Python
Raw Normal View History

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