- 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 红)
261 lines
12 KiB
Python
261 lines
12 KiB
Python
"""基金转换受理单仓储(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 |