Files
group_xinghuo_jinrong/scripts/agent/cleanup_pending_convert.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

171 lines
7.2 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.
"""超 SLA 在途受理单清理(T-12 · R-14 · PRD §7.1)。
**T+1 受理/确认分离模型下的语义**(取代 v0.x「扫 agent 库 pending 占位」):
权威状态机在 **Core 库 `core_convert_request`**(6 态 · D21)。确认批处理
(`confirm_batch`)按业务日捞单,窗口 = 当前业务日上推 `convert_confirm_sla_days`
(Q14 拍板 = 2)个**交易日** —— **受理日早于窗口下界的单永远不会被批处理再捞到**,
其在途占用(R-3:可用份额 = 批次余量 − Σ 在途受理单)将永久泄漏。
本脚本把这类单置 `status='expired'` 收口(占用随状态离开在途态自动释放)。
**标记不硬删(评审 S2 沿用)**:只 UPDATE 状态,**绝不 DELETE** —— 受理单历史
留痕供对账;`expired` 是建表即含的 ENUM 值(`01-ddl.sql` 6 态状态机),无需 ALTER。
状态迁移走 `ConvertRequestRepository.transition_status` 的**条件 UPDATE 守卫**
(R-2),不复制 SQL(自检第 13 问)。
**不动 agent 镜像**:`risk_convert_detail` 是进度镜像,T-4 契约规定镜像**不推
cancelled/expired**(保留值不写),业务状态一律以 Core 6 态为准;查询侧读权威。
**SLA 边界与确认捞单窗口同口径**(由单测钉住等价性):
cutoff = 当前业务日 上推 sla_days 个交易日 的 00:00
(当前业务日 = 今天若开市,否则回退最近交易日;日历数据缺失时退化为自然日今日并
告警 —— 宁可少清、不可误清。)交易日而非自然日:窗口跨周末/长假时自然日近似会把
周五受理的单漏在窗口外(与 `_window_start` 同一教训)。
**并发**:不加分布式锁 —— 逐单条件 UPDATE `WHERE status IN (accepted, nav_pending)`
天然幂等,双跑最多一方迁成功,无资金操作(与 confirm_batch 需要锁的本质区别)。
用法与退出码:
python scripts/agent/cleanup_pending_convert.py # 按 SLA(缺省 2 交易日)清理
python scripts/agent/cleanup_pending_convert.py --days 1 # 覆盖 SLA 交易日数(演练/测试)
python scripts/agent/cleanup_pending_convert.py --dry-run # 只报告候选不写库
0 = 清理完成(含「无候选」);1 = MySQL 不可达或写库失败。
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from datetime import date, datetime, time, timedelta
from pathlib import Path
# ① sys.path 引导项目根(rebuild_alerts / escalation_scan 先例)
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
from app.repository.convert_request_repository import ( # noqa: E402
STATUS_EXPIRED,
ConvertRequestRepository,
)
from app.service.convert.trading_calendar import ( # noqa: E402
is_biz_day,
previous_biz_day,
)
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.utils.trace import new_trace # noqa: E402
logger = logging.getLogger("cleanup_pending_convert")
def _slab_cutoff(now: datetime, core, days: int) -> datetime:
"""SLA 边界:当前业务日上推 `days` 个交易日的 00:00(与 `_window_start` 同口径)。
当前业务日 = 今天开市取今天,否则 `previous_biz_day` 回退最近交易日;
日历数据边界(`ValueError`)→ 退化为自然日今日,宁可少清、不可误清。
"""
today = now.date()
try:
current = today if is_biz_day(today, core.is_open) else previous_biz_day(today, core.is_open)
cursor = current
for _ in range(max(1, int(days))):
cursor = previous_biz_day(cursor, core.is_open)
return datetime.combine(cursor, time.min)
except ValueError:
logger.warning("SLA 边界推算失败(交易日历数据缺失),退化为自然日今日:%s", today)
return datetime.combine(today - timedelta(days=max(0, int(days) - 1)), time.min)
def cleanup(
request_repo: ConvertRequestRepository,
core,
days: int,
*,
now: datetime | None = None,
dry_run: bool = False,
) -> dict:
"""扫出超 SLA 的在途受理单并置 `expired`;返回 JSON 友好摘要。
幂等:`list_inflight_before` 只扫 `accepted/nav_pending`,已 `expired`(或其它
终态)的行不再进候选,重复执行第二次为空操作;并发双跑由条件 UPDATE 守卫收敛。
"""
now = now or datetime.now()
cutoff = _slab_cutoff(now, core, days)
candidates = request_repo.list_inflight_before(cutoff)
expired: list[str] = []
conceded: list[str] = [] # 条件 UPDATE 未迁移(并发下另一方已先改走)
if not dry_run:
for row in candidates:
group_id = str(row["convert_group_id"])
from_status = str(row["status"])
if request_repo.transition_status(group_id, from_status, STATUS_EXPIRED):
expired.append(group_id)
else:
conceded.append(group_id)
logger.info("并发让路(状态已被他方迁移):%s", group_id)
return {
"sla_days": days,
"cutoff_requested_at": str(cutoff),
"dry_run": dry_run,
"candidate_count": len(candidates),
"expired_count": len(expired),
"conceded_count": len(conceded),
"expired": expired,
"conceded": conceded,
"candidates": [
{
"convert_group_id": str(r["convert_group_id"]),
"client_request_id": r.get("client_request_id"),
"customer_id": r.get("customer_id"),
"status": r.get("status"),
"requested_at": r.get("requested_at"),
}
for r in candidates
],
}
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
parser = argparse.ArgumentParser(
description="超 SLA 在途转换受理单 → status='expired'(标记不硬删,S2;扫描对象 = Core 库 core_convert_request)"
)
parser.add_argument(
"--days",
type=int,
default=None,
help=f"覆盖 SLA 交易日数(缺省取 settings.convert_confirm_sla_days="
f"{settings.convert_confirm_sla_days};与确认批处理捞单窗口同口径)",
)
parser.add_argument("--dry-run", action="store_true", help="只报告候选,不写库")
args = parser.parse_args()
days = args.days if args.days is not None else settings.convert_confirm_sla_days
new_trace()
request_repo = ConvertRequestRepository()
core = CoreReadOnlyRepository()
# 连接自检(零写入探针)
try:
request_repo.list_inflight_before(datetime.now())
except Exception as exc:
print(f"MySQL 不可达:{exc}", file=sys.stderr)
print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr)
return 1
try:
summary = cleanup(request_repo, core, days, dry_run=args.dry_run)
except Exception as exc: # noqa: BLE001
print(f"清理失败:{exc}", file=sys.stderr)
return 1
print(json.dumps(summary, ensure_ascii=False, default=str, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())