Files
group_xinghuo_jinrong/app/repository/convert_request_repository.py
T
GaoYiYuan_0626 03feacf173 基金转换 T+1 模型:T-12 补偿 + SLA 清理切 Core 链路 + confirm_one 引擎异常留痕修复
- cleanup_pending_convert.py 整体重写:扫描对象迁 Core(list_inflight_before 扫
  accepted/nav_pending 严格早于 cutoff → 条件 UPDATE 置 expired,S2 不硬删、
  幂等不加锁);SLA 边界 = 当前业务日上推 convert_confirm_sla_days 个交易日
  00:00(previous_biz_day 链式,与 confirm_batch._window_start 同口径);
  --days/--dry-run;不动 agent 镜像(T-4 契约)
- compensate_convert 从 v1.0 Deprecated 组救回在役:详情侧 Core 三件套前置校验
  (流水≥2 → confirmed → 明细非空,任一不满足 missing 零写入),补写与确认段
  第⑧步同口径 sync_mirror + confirmed 审计 phase=confirm-compensate
- convert_request_repository +list_inflight_before(T-12 专用捞单,R-2 守卫复用)
- 修复 confirm_one 第⑦步引擎异常只 log 不留痕(全仓唯一不落痕路径,补偿侧
  「人工核对」保护对主链路失效):except 补写 decision='engine_error' 审计 +
  engine_error 标志贯穿审计与响应(D17 不阻断保留;三链路与 v1.0/gateway 对齐)
- verify_convert_compensate.py 整体重写 T+1 链路:受理→确认双失败→Core 三件套
  对表→补偿/cleanup,真库 56/56 退出码 0 残留 0(受理日/SLA 边界走真库交易日历
  链式推算,A 股最长连续休市 2020 春节 10 天已联网核实留痕)
- 测试:补偿用例 4 改 + 2 增、cleanup 段重写 + 窗口等价新用例、并发补偿 T+1 造数
  + 真库清场补 core_convert_request(FK 1451)、engine 异常用例断言翻转
  (不阻断但留痕+标记);全量 849 passed / 10 skipped(846+3 零回归);
  突变 4 组全被抓还原(cleanup 窗口反转 5 红 / 前置守卫 1 红 / 真库
  expired→cancelled 2 红 / 真库 nav_stale 1 红)
2026-09-12 10:54:49 +08:00

261 lines
12 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.
"""基金转换受理单仓储(T+1 受理/确认分离模型 · D21)· 仅操作 Core 库 `core_convert_request`。
本仓储是**6 态权威状态机**(accepted / nav_pending / confirmed / rejected /
cancelled / expired)的唯一写入口(R-2 条件 UPDATE 守卫):
- `insert`:首插(撞 uk_idem 上抛 IntegrityError,由 service 转幂等 202)
- `transition_status`:**条件 UPDATE `WHERE status=:from`**,rowcount 0 → 并发冲突
(两线程同迁一个单,恰一个 rowcount=1,天然幂等)
- `mark_actual`:部分成交记 actual_qty(R-10)
- `list_pending_by_biz_date`:供 T+1 批处理(confirm_batch)捞单
写侧走 Core 库可写引擎(D20:`xh_core_rw`,仅授权 4 表含 core_convert_request);
**无 DELETE**(S2 标记不硬删哲学延伸:受理单历史留痕,只改状态)。
"""
from __future__ import annotations
import logging
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from app.config.settings import settings
from app.utils.db import get_engine
logger = logging.getLogger(__name__)
#: 6 态权威状态机(与 scripts/core/01-ddl.sql ENUM 一致)
STATUS_ACCEPTED = "accepted"
STATUS_NAV_PENDING = "nav_pending"
STATUS_CONFIRMED = "confirmed"
STATUS_REJECTED = "rejected"
STATUS_CANCELLED = "cancelled"
STATUS_EXPIRED = "expired"
#: 在途态(占用份额,R-3)
INFLIGHT_STATUSES = (STATUS_ACCEPTED, STATUS_NAV_PENDING)
#: 终态(不占用,不可再迁)
FINAL_STATUSES = (STATUS_CONFIRMED, STATUS_REJECTED, STATUS_CANCELLED, STATUS_EXPIRED)
# 受理单 `remark` 列的**结构化标记**(自由文本列,此处登记全部合法取值)。
# 写法:单个标记原样;多标记用 `;` 连接(受理标记在前,确认标记在后)。
REMARK_FULL_TRANSFER = "full_transfer" # 受理段:最低持有触发强制全转
REMARK_PARTIAL = "partial" # 确认段:部分成交(R-10,实际确认量 < 申请量)
def _to_bind(value: Any) -> Any:
"""Decimal 转 float 再绑定(sqlite 不支持直接绑定 Decimal;MySQL DECIMAL 自动收口)。"""
return float(value) if isinstance(value, Decimal) else value
def _end_of_day(value: Any) -> Any:
"""把日期规整为「**次日 00:00**」(闭区间上界的半开表示)。
统一 `as_of` 语义(T-7):`list_convert_requests` 与 `list_pending_by_biz_date`
都按「**含 as_of 当日**」理解。此前本方法直接把上界喂给 `requested_at < :to_ts`,
调用方各自 +1 天,谁忘了谁就漏捞当日受理单 —— T+1 批处理漏捞当日单会让
整个确认轮次空跑(PRD §7.0.2 步 1),且**症状静默**(不报错、只是不处理)。
"""
if value is None:
return None
if isinstance(value, datetime):
value = value.date()
if isinstance(value, date):
return datetime.combine(value + timedelta(days=1), time.min)
return value # 字符串等原样透传(由调用方自负格式)
class ConvertRequestRepository:
"""core_convert_request 读写(仅该表 DML + 无 DELETE)。
`engine` 缺省用 Core 库 `rw` 账号(xh_core_rw);测试注入 sqlite/真库引擎。
"""
def __init__(self, engine: Engine | None = None) -> None:
self._engine = engine or get_engine(settings.mysql_core_database, "rw")
# ---------- 首插 ----------
def insert(
self,
convert_group_id: str,
customer_id: str,
from_product_id: str,
to_product_id: str,
qty: Decimal,
*,
client_request_id: str | None = None,
requested_at: Any = None,
cancel_before: Any = None,
remark: str | None = None,
) -> None:
"""首插受理单(status='accepted')。
撞 `uk_idem`(同 client_request_id 已有行)→ 抛 IntegrityError,
由调用方(convert_service.accept_convert)转幂等 202(R-2 守卫)。
`client_request_id` 为 None 时绑 NULL——UNIQUE 约束允许多个 NULL,
故「无幂等键的请求」可重复受理(语义:每次都是独立转换)。
`remark`:受理阶段即确定的标记(当前唯一取值 `REMARK_FULL_TRANSFER`
——「最低持有强制全转」)。**这是受理段对确认段的唯一决策交接位**:
受理时 `qty` 已被收敛为「实际全转量」,确认段无法再由 `qty` 反推
「客户原本申请的是另一笔更小的数、被系统改成全转」,故必须落库传承。
"""
with self._engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO core_convert_request
(convert_group_id, client_request_id, customer_id,
from_product_id, to_product_id, qty, status,
cancel_before, requested_at, remark)
VALUES (:gid, :cid_req, :cid, :from_pid, :to_pid, :qty,
:status, :cancel_before, :requested_at, :remark)
"""
),
{
"gid": convert_group_id,
"cid_req": client_request_id,
"cid": customer_id,
"from_pid": from_product_id,
"to_pid": to_product_id,
"qty": _to_bind(qty),
"status": STATUS_ACCEPTED,
"cancel_before": cancel_before,
"requested_at": requested_at,
"remark": remark,
},
)
# ---------- 状态迁移(R-2 条件 UPDATE 守卫)----------
def transition_status(
self, convert_group_id: str, from_status: str, to_status: str
) -> bool:
"""**条件 UPDATE** `WHERE status=:from` → 若 rowcount=0 说明并发冲突/状态已变。
返回 True=迁移成功(调用方拿到迁移权)、False=未迁移(另一方已先到)。
终态不可再迁(由 service 层先读状态判断;并发下条件 UPDATE 兜底)。
"""
with self._engine.begin() as conn:
result = conn.execute(
text(
"UPDATE core_convert_request SET status = :to "
"WHERE convert_group_id = :gid AND status = :from "
),
{"to": to_status, "gid": convert_group_id, "from": from_status},
)
return result.rowcount == 1
def mark_actual(self, convert_group_id: str, actual_qty: Decimal, remark: str | None = None) -> None:
"""部分成交(R-10):记录实际确认份额 + 备注(partial 等),状态仍由 confirm 段迁移。"""
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE core_convert_request SET actual_qty = :q, remark = :remark "
"WHERE convert_group_id = :gid"
),
{"q": _to_bind(actual_qty), "remark": remark, "gid": convert_group_id},
)
# ---------- 批处理捞单(T-7 confirm_batch)----------
def list_pending_by_biz_date(self, as_of: Any, accepted_from: Any) -> list[dict[str, Any]]:
"""捞 `accepted/nav_pending` 且受理日落在 **`[accepted_from, as_of]`(含当日)** 的单。
`as_of` 为**闭区间上界**(与 `list_convert_requests` 同语义,T-7 统一):
内部转「次日 00:00」再比较(见 `_end_of_day`)。
`accepted_from` 由调用方从 as_of 上推若干交易日给出 —— T+1 确认窗口
(覆盖净值晚公告导致的上轮积压,`settings.convert_confirm_sla_days`)。
**串行处理(组内串行 FR-C23)**:按 `requested_at ASC` 排序,确保 FIFO 顺序
—— 先受理的单先确认,与真实登记机构批量作业顺序一致。
"""
sql = text(
"""
SELECT * FROM core_convert_request
WHERE status IN ('accepted', 'nav_pending')
AND requested_at >= :from_ts
AND requested_at < :to_ts
ORDER BY requested_at ASC
"""
)
with self._engine.connect() as conn:
return [
dict(r)
for r in conn.execute(
sql, {"from_ts": accepted_from, "to_ts": _end_of_day(as_of)}
).mappings()
]
# ---------- SLA 超时候选(T-12 cleanup)----------
def list_inflight_before(self, cutoff_requested_at: Any) -> list[dict[str, Any]]:
"""扫 `accepted/nav_pending` 且受理日**严格早于** `cutoff_requested_at` 的单(T-12 · R-14)。
语义对齐 `confirm_batch` 的捞单窗口(`confirm_service._window_start`):
窗口下界 = 当前业务日上推 `convert_confirm_sla_days` 个交易日的 00:00 ——
**受理日早于窗口下界的单永远不会被批处理再捞到**,必须置 `expired` 收口,
否则在途占用(R-3 由状态推导)永久泄漏。调用方负责把窗口下界算成
`datetime`(交易日链式回推,见 `trading_calendar.previous_biz_day`)。
幂等:状态过滤保证已 `expired`/其它终态的行**不再进候选**,
重复执行第二次为空操作(与条件 UPDATE 守卫 `transition_status` 配合,
并发双跑也最多一方迁成功)。
"""
sql = text(
"""
SELECT convert_group_id, client_request_id, customer_id, from_product_id,
to_product_id, qty, status, requested_at
FROM core_convert_request
WHERE status IN ('accepted', 'nav_pending')
AND requested_at < :cutoff
ORDER BY requested_at ASC
"""
)
with self._engine.connect() as conn:
return [
dict(r)
for r in conn.execute(sql, {"cutoff": cutoff_requested_at}).mappings()
]
# ---------- 确认段状态迁移(T-7)----------
def reject(self, convert_group_id: str, from_status: str, reason: str) -> bool:
"""确认段 T+1 复核不通过 → `rejected` + `remark=reason`(PRD §7.0.2 步 3)。
条件 UPDATE `WHERE status=:from` 同样是并发守卫:返回 False = 别处已改(让路)。
占用随状态离开 `accepted`/`nav_pending` 自动释放(R-3),
**无需额外写冻结/占用列**(本项目不新增冻结列,见架构 D21 附注)。
`reason` 落 `remark` 列(DDL 无独立 `reject_reason` 列;PRD §7.0.2 的
`reject_reason` 与扩展备注共用该列)。
"""
with self._engine.begin() as conn:
result = conn.execute(
text(
"UPDATE core_convert_request SET status = 'rejected', remark = :reason "
"WHERE convert_group_id = :gid AND status = :from"
),
{"reason": reason, "gid": convert_group_id, "from": from_status},
)
return result.rowcount == 1
# ---------- 幂等查询(受理段用)----------
def get_by_client_request_id(self, client_request_id: str | None) -> dict[str, Any] | None:
"""按 client_request_id 读单行(幂等命中);None 直接返回 None。"""
if client_request_id is None:
return None
with self._engine.connect() as conn:
row = conn.execute(
text("SELECT * FROM core_convert_request WHERE client_request_id = :cid_req"),
{"cid_req": client_request_id},
).mappings().first()
return dict(row) if row else None