Files
group_xinghuo_jinrong/app/gateway/gateway_repository.py
T
GaoYiYuan_0626 5e6fa06d03 基金转换 T-10:普通申赎批次维护(FR-C16)
让 core_trade 的普通申赎同步维护 core_share_lot 批次,为 convert 提供权威份额口径。

落地(改码 3 处 + 新增 2 个脚本)
- gateway_repository:新增 insert_share_lots / deduct_share_lots,各自单事务、走 rw 账号;
  只落已分配结果、不含分配逻辑,避免出现第二份 FIFO 口径
- share_lot_repository:__init__ 增可选 core_ro 入参,让 trade_gateway 复用同一份 FIFO 选批口径(D18)
- trade_gateway:新增 _nav_as_of / _maintain_lots,在落流水后、规则引擎前调用
  · subscribe:取 T 日(含)前最新净值 → qty = amount ÷ 净值(2 位 HALF_UP)→ 建批次(confirmed_at = T)
  · redeem:FIFO 扣减;无批次但有持仓 → D8 兜底补建后再扣;两者皆无 → 降级跳过
  · 整段 try 包住,任何异常只 warning,绝不阻断交易(R-c(1))
- 新增 scripts/core/rebuild_lots.py:按 core_holding 快照重建批次(L-7),与网关 D8 同调 bootstrap_lots;
  默认只补建无批次的持仓行(幂等),--force 才先删后建,--dry-run 只报告
- 新增 scripts/dev/verify_convert_lots.py:真库验证脚本(MySQL 8.0.46)

口径订正(联网查证 4 家管理人业务规则后)
- redeem 的 amount ÷ 净值 折算属本项目简化建模,不是行业标准
- 行业铁律是「金额申购、份额赎回」:投资者以份额申报,登记机构按 T 日净值算金额
  (睿远业务规则 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银 §33;无一家公募支持按金额赎回)
- 派生风险:未知价法下 T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值,
  故此处算出的份额只是估算值
- 已记入 PRD §10.1 已知差异清单;trade_gateway 注释同步订正

验证
- pytest -q → 714 passed / 3 skipped(基线 697 加 17,零回归)
- R16 test_redeem_accepted_without_alert 零改动通过(由 R-c(1) 降级保住)
- 真库 20/20;T-6 24/24、T-7 35/35 复跑零回归;真库隔离数据零残留
- 突变验证 2 组:切断接线 → 精准 2 条红;关掉 D8 兜底 → 精准 2 条红(含 D18 同源断言)
2026-09-10 18:28:48 +08:00

137 lines
5.5 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.
"""模拟交易网关写侧(PRD FR-1;FRAMEWORK §3 例外条款)。
仅本类可 INSERT jinrong_core.core_trade;core_ro 仍只读。生产环境由真实
交易系统回调替代,本类随 app/gateway/ 退役。
**T-10 起并承担 `core_share_lot` 的普通申赎批次维护写侧**(架构 §12 Q1:
`core_share_lot` 的写入方 = `convert_core_repository`(convert 转换)+
本类(普通申赎,含 D8 兜底补建)。分工刻意如此:
- **FIFO 选批/分配口径仍只有一处** —— `share_lot_repository`(D18);
本类**不含任何分配逻辑**,只把已分配好的结果落库,避免出现第二份口径。
- 批次维护属**尽力而为**(FR-C16 / R-c(1)):数据不全时由调用方
(`trade_gateway`)warning 跳过,**不抛异常、不阻断交易**。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Sequence
from sqlalchemy import text
from sqlalchemy.engine import Engine
from app.config.settings import settings
from app.service.convert.types import Lot
from app.utils.db import get_engine
# ── 批次写入 SQL(模块级常量:口径集中,便于单测逐条核对)──────────────
# ⚠️ `(:q + 0.0)` **不能省**:数值参数统一以字符串绑定(`str()`,跨库无损),
# 而 sqlite 在 **UPDATE 的算术表达式**里不会把 TEXT 绑定参数转数值 ——
# `remain_qty = remain_qty - :q` 传 '50' 会扣 0(见开发计划 §B.8 第 11 条)。
# 加 `+ 0.0` 强制数值化后两库行为一致(MySQL 本就隐式转换)。
_SHARE_LOT_INSERT = text(
"""
INSERT INTO core_share_lot
(lot_id, customer_id, product_id, qty, remain_qty, nav,
confirmed_at, source_trade_id)
VALUES (:lot, :cid, :pid, :q, :q, :nav, :cat, :src)
"""
)
_SHARE_LOT_DEDUCT = text(
"""
UPDATE core_share_lot
SET remain_qty = remain_qty - (:q + 0.0)
WHERE lot_id = :lot
"""
)
class GatewayRepository:
"""core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。
D20(T-0b):绑定 `role="rw"`(账号 `xh_core_rw`,仅限 4 张表的
SELECT/INSERT/UPDATE,**无 DELETE、无 DDL**)。注意 gateway 的**读**走
`role="ro"`(`CoreReadOnlyRepository`),两个 engine 并存——这是「最小
权限」能否成立的关键边界(架构 §11.1)。
"""
def __init__(self, engine: Engine | None = None) -> None:
self._engine = engine or get_engine(settings.mysql_core_database, "rw")
def insert_trade(
self,
trade_id: str,
customer_id: str,
product_id: str,
trade_type: str,
amount: Decimal,
traded_at: datetime,
trade_status: str = "confirmed",
) -> None:
sql = text(
"""
INSERT INTO core_trade
(trade_id, customer_id, product_id, trade_type, amount,
trade_status, traded_at)
VALUES (:tid, :cid, :pid, :ttype, :amount, :status, :at)
"""
)
with self._engine.begin() as conn:
conn.execute(
sql,
{
"tid": trade_id,
"cid": customer_id,
"pid": product_id,
"ttype": trade_type,
# str 无损传递:MySQL DECIMAL 隐式转换;sqlite text SQL 不支持 Decimal 绑定
"amount": str(amount),
"status": trade_status,
"at": traded_at,
},
)
# ── 批次维护写侧(T-10 · FR-C16 · 架构 §12 Q1)────────────────────
def insert_share_lots(self, lots: Sequence[Lot]) -> None:
"""批量写入 `core_share_lot`(**单事务**)—— 申购建批次 / D8 兜底补建共用。
`qty` 与 `remain_qty` 写**同一个值**(新批次未发生任何扣减);
取值统一用 `remain_qty`(`bootstrap_lots` 保证两者相等)。
`nav` 以字符串绑定:sqlite 列有 NUMERIC affinity 会转换,MySQL 精确。
"""
if not lots:
return
with self._engine.begin() as conn:
for lot in lots:
conn.execute(
_SHARE_LOT_INSERT,
{
"lot": lot.lot_id,
"cid": lot.customer_id,
"pid": lot.product_id,
"q": str(lot.remain_qty),
"nav": str(lot.nav),
"cat": lot.confirmed_at,
"src": lot.source_trade_id,
},
)
def deduct_share_lots(self, deductions: Sequence[tuple[str, Decimal]]) -> None:
"""按 FIFO 分配结果逐批扣减 `remain_qty`(**单事务**)。
⚠️ 与 `convert_core_repository._deduct_lots` 的**关键差别**:本方法
**不带 `remain_qty >= :q` 哨兵**。传入的份额已由 `share_lot_repository`
按「不超过可用余额」裁剪,普通赎回**不因份额不足阻断交易**(R-c(1),
与既有 redeem 语义一致);而 convert 的份额不足是**严格校验**(409
`LotConflict`)。两处口径不可互相套用。
"""
if not deductions:
return
with self._engine.begin() as conn:
for lot_id, qty in deductions:
conn.execute(_SHARE_LOT_DEDUCT, {"lot": lot_id, "q": str(qty)})