- 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 红)
171 lines
7.2 KiB
Python
171 lines
7.2 KiB
Python
"""超 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())
|