- 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 红)
469 lines
19 KiB
Python
469 lines
19 KiB
Python
"""B9a 演示/运维脚本单测(开发计划 B9a · sqlite)。
|
||
|
||
rebuild_alerts:补偿重放出单并推送、幂等跳过(防重复 append/aml 重复出单)、
|
||
missing 不落库;subscribe_alerts:payload → 单行可读文本。
|
||
**T-12 追加**:`rebuild_alerts --convert-group` 的分派与接线、
|
||
`cleanup_pending_convert` 超时占位清理(标记不硬删)。
|
||
脚本目录非包,动态入 sys.path 后按模块名导入。
|
||
"""
|
||
|
||
import sys
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
DEMO_DIR = Path(__file__).resolve().parents[1] / "scripts" / "demo"
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(DEMO_DIR))
|
||
sys.path.insert(0, str(ROOT / "scripts" / "agent"))
|
||
|
||
import rebuild_alerts # noqa: E402
|
||
from rebuild_alerts import main, rebuild_convert_group, rebuild_trade # noqa: E402
|
||
from subscribe_alerts import format_alert # noqa: E402
|
||
from cleanup_pending_convert import cleanup # noqa: E402
|
||
|
||
from app.repository.convert_repository import ConvertRepository # noqa: E402
|
||
from app.repository.convert_request_repository import ConvertRequestRepository # noqa: E402
|
||
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
|
||
from app.repository.risk_repository import RiskRepository # noqa: E402
|
||
from app.service.risk import alert_service # noqa: E402
|
||
from app.service.risk.alert_service import handle_alert # noqa: E402
|
||
from app.service.risk.engine import process_trade_event # noqa: E402
|
||
|
||
|
||
class FakePublisher:
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
|
||
@pytest.fixture()
|
||
def env():
|
||
engine = create_sqlite_engine()
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
|
||
" ('C1', '张某某', 40, 1), ('C2', '李四', 35, 1)"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)"
|
||
" VALUES ('P1', '测试混合基金', 'R3', 'mixed')"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold,"
|
||
" source, list_version, is_active) VALUES ('PEP-1', 'pep', '李四', 0.85, 'mock', 'v1', 1)"
|
||
)
|
||
)
|
||
core = CoreReadOnlyRepository(engine=engine)
|
||
repo = RiskRepository(engine=engine)
|
||
pub = FakePublisher()
|
||
alert_service.set_publisher(pub)
|
||
yield core, repo, pub, engine
|
||
alert_service.set_publisher(None)
|
||
engine.dispose()
|
||
|
||
|
||
def _seed_trade(conn, trade_id, amount, customer="C1", at=datetime(2026, 9, 6, 14, 0, 0)):
|
||
"""直接落 core_trade 不调引擎(engine_error 补偿场景:交易已成立、预警缺失)。"""
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount,"
|
||
" trade_status, traded_at) VALUES (:tid, :cid, 'P1', 'subscribe', :amt, 'confirmed', :at)"
|
||
),
|
||
{"tid": trade_id, "cid": customer, "amt": amount, "at": at},
|
||
)
|
||
|
||
|
||
def _counts(engine, table, where="1=1"):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one()
|
||
|
||
|
||
def test_rebuild_creates_alert_and_publishes(env):
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB1", "600000")
|
||
out = rebuild_trade("TRD-TEST-RB1", core, repo)
|
||
assert out["state"] == "rebuilt"
|
||
assert out["triggered_rules"] == ["RISK-001", "RISK-002"]
|
||
assert len(out["alert_ids"]) == 1 and out["aml_hit"] is False
|
||
alert = repo.get_alert(out["alert_ids"][0])
|
||
assert alert["status"] == "pending_review" and alert["risk_score"] == 70
|
||
(channel, _), = pub.messages
|
||
assert channel == "risk:pub:alert"
|
||
assert _counts(engine, "audit_log", "decision='alert_created'") == 1 # P-05 留痕链
|
||
|
||
|
||
def test_rebuild_idempotent_skips_second_run(env):
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB2", "600000")
|
||
first = rebuild_trade("TRD-TEST-RB2", core, repo)
|
||
second = rebuild_trade("TRD-TEST-RB2", core, repo)
|
||
assert first["state"] == "rebuilt" and second["state"] == "skipped"
|
||
assert second["alert_ids"] == first["alert_ids"]
|
||
assert _counts(engine, "risk_alert") == 1
|
||
assert len(repo.get_alert(first["alert_ids"][0])["payload"]["events"]) == 1
|
||
assert len(pub.messages) == 1 # 重放不重复推送
|
||
|
||
|
||
def test_rebuild_missing_trade_touches_nothing(env):
|
||
core, repo, pub, engine = env
|
||
out = rebuild_trade("TRD-NO-SUCH", core, repo)
|
||
assert out["state"] == "missing" and out["alert_ids"] == []
|
||
assert _counts(engine, "risk_alert") == 0
|
||
assert _counts(engine, "audit_log") == 0
|
||
assert pub.messages == []
|
||
|
||
|
||
def test_rebuild_aml_hit_then_idempotent(env):
|
||
"""aml 单幂等是 LIKE 检查的关键价值:record_aml_alert 本身无去重,重放防二次出单。"""
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB3", "1000", customer="C2")
|
||
first = rebuild_trade("TRD-TEST-RB3", core, repo)
|
||
assert first["state"] == "rebuilt" and first["aml_hit"] is True
|
||
aml_ids = [
|
||
aid
|
||
for aid in first["alert_ids"]
|
||
if repo.get_alert(aid)["alert_type"] == "aml"
|
||
]
|
||
assert aml_ids
|
||
second = rebuild_trade("TRD-TEST-RB3", core, repo)
|
||
assert second["state"] == "skipped" and second["alert_ids"] == first["alert_ids"]
|
||
assert _counts(engine, "risk_alert", "alert_type='aml'") == 1
|
||
|
||
|
||
def test_rebuild_non_first_trade_of_merged_alert_skips(env):
|
||
"""B8 复审口径(P2-2):幂等保障依赖 LIKE 对 events[n](n≥1)命中——
|
||
同日第二笔 append 进同单后 rebuild,必须 skip 且不重复 append。"""
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB4", "600000")
|
||
_seed_trade(conn, "TRD-TEST-RB5", "600000", at=datetime(2026, 9, 6, 15, 0, 0))
|
||
first = rebuild_trade("TRD-TEST-RB4", core, repo)
|
||
assert first["state"] == "rebuilt"
|
||
# 第二笔走正常引擎路径(等价网关同步调用)→ append 进同单
|
||
process_trade_event(core.get_trade_by_id("TRD-TEST-RB5"), core_ro=core, risk_repo=repo)
|
||
merged_id = first["alert_ids"][0]
|
||
assert len(repo.get_alert(merged_id)["payload"]["events"]) == 2
|
||
second = rebuild_trade("TRD-TEST-RB5", core, repo)
|
||
assert second["state"] == "skipped" and second["alert_ids"] == [merged_id]
|
||
assert len(repo.get_alert(merged_id)["payload"]["events"]) == 2 # 不重复 append
|
||
|
||
|
||
def test_rebuild_after_disposal_still_skips(env):
|
||
"""已处置单仍 skip(重放不得绕过处置结论,评审 P3-7)。"""
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB6", "600000")
|
||
out = rebuild_trade("TRD-TEST-RB6", core, repo)
|
||
aid = out["alert_ids"][0]
|
||
handle_alert(aid, "confirmed_normal", "STAFF-R1", risk_repo=repo)
|
||
again = rebuild_trade("TRD-TEST-RB6", core, repo)
|
||
assert again["state"] == "skipped" and again["alert_ids"] == [aid]
|
||
assert _counts(engine, "risk_alert") == 1
|
||
|
||
|
||
def test_rebuild_warns_on_engine_error_middle_state(env):
|
||
"""P2-1:首张单落库后 engine_error 中断(部分失败中间态)→ skip + warning 提示人工核对。"""
|
||
core, repo, pub, engine = env
|
||
with engine.begin() as conn:
|
||
_seed_trade(conn, "TRD-TEST-RB7", "600000")
|
||
out = rebuild_trade("TRD-TEST-RB7", core, repo)
|
||
assert "warning" not in out # 正常补偿无警示
|
||
# 模拟中断留痕:同一笔再走一遍"交易已成立但引擎异常"的审计(如 aml 单缺失场景)
|
||
repo.insert_audit_log(
|
||
{
|
||
"trace_id": "tr-x", "event_type": "trade_request", "agent_type": "platform",
|
||
"actor_id": "SYSTEM", "customer_id": "C1", "rule_id": None,
|
||
"input_summary": {"trade_id": "TRD-TEST-RB7", "error_stage": "process_trade_event"},
|
||
"decision": "risk_engine_error", "risk_score": None,
|
||
"handler_id": None, "handler_result": None, "handler_comment": None,
|
||
}
|
||
)
|
||
again = rebuild_trade("TRD-TEST-RB7", core, repo)
|
||
assert again["state"] == "skipped"
|
||
assert "人工核对" in again["warning"]
|
||
|
||
|
||
def test_format_alert_renders_payload_fields():
|
||
line = format_alert(
|
||
{
|
||
"alert_id": "ALT-20260906-ABC",
|
||
"alert_type": "aml",
|
||
"customer_id_mask": "CUST-9**",
|
||
"risk_score": 95,
|
||
"trace_id": "tr-1",
|
||
"notify_role": ["risk_officer", "compliance"],
|
||
}
|
||
)
|
||
for frag in ("aml", "ALT-20260906-ABC", "score=95", "CUST-9**", "tr-1",
|
||
"risk_officer,compliance"):
|
||
assert frag in line
|
||
assert "CUST-9527" not in line # payload 只有脱敏掩码,无原始 id
|
||
assert format_alert({"raw": "not-a-json-dict"}) == "[raw] not-a-json-dict"
|
||
|
||
|
||
# ---------- T-12 · rebuild_alerts --convert-group 分派与接线 ----------
|
||
|
||
|
||
def test_convert_group_delegates_to_service(monkeypatch):
|
||
"""`--convert-group` 只是薄封装:所有逻辑必须落在 compensate_convert(不复制实现)。"""
|
||
seen: dict = {}
|
||
|
||
def fake(group_id, **kwargs):
|
||
seen["gid"] = group_id
|
||
seen["kwargs"] = kwargs
|
||
return {"state": "skipped", "alert_ids": ["ALT-9"]}
|
||
|
||
monkeypatch.setattr(rebuild_alerts, "compensate_convert", fake)
|
||
out = rebuild_convert_group("CNV-T12-1", "CORE", "REPO", "CREPO")
|
||
assert out["state"] == "skipped"
|
||
assert seen["gid"] == "CNV-T12-1"
|
||
assert seen["kwargs"] == {"core_ro": "CORE", "risk_repo": "REPO", "convert_repo": "CREPO"}
|
||
|
||
|
||
def test_convert_group_missing_is_reported_without_writes(env):
|
||
"""未知 group(Core 侧无流水)→ missing,且不写任何东西。"""
|
||
core, repo, pub, engine = env
|
||
out = rebuild_convert_group("CNV-T12-NOPE", core, repo, ConvertRepository(engine=engine))
|
||
assert out["state"] == "missing" and out["trade_count"] == 0
|
||
assert _counts(engine, "risk_alert") == 0
|
||
assert _counts(engine, "audit_log") == 0
|
||
|
||
|
||
def test_cli_rejects_convert_group_with_trade_ids(monkeypatch):
|
||
"""`--convert-group` 与 trade_id 位置参数互斥(argparse 用法错误 → 退出码 2)。"""
|
||
monkeypatch.setattr(
|
||
sys, "argv", ["rebuild_alerts.py", "TRD-X", "--convert-group", "CNV-Y"]
|
||
)
|
||
with pytest.raises(SystemExit) as exc:
|
||
main()
|
||
assert exc.value.code == 2
|
||
|
||
|
||
def test_cli_requires_one_of_the_two_forms(monkeypatch):
|
||
"""两者都不给 → 用法错误(不是静默什么都不做)。"""
|
||
monkeypatch.setattr(sys, "argv", ["rebuild_alerts.py"])
|
||
with pytest.raises(SystemExit) as exc:
|
||
main()
|
||
assert exc.value.code == 2
|
||
|
||
|
||
# ---------- T-12 · cleanup_pending_convert:超 SLA 在途受理单 → expired ----------
|
||
# (T+1 模型:扫描对象 = Core 库 `core_convert_request`,v0.x 的 agent 库 pending 占位口径已废)
|
||
|
||
|
||
def _seed_calendar_allopen(engine, *, days_back: int = 90) -> None:
|
||
"""插「每天都开市」的日历(today 往前 days_back 天):交易日 = 自然日,边界可精确推算。
|
||
|
||
测试须显式建日历走**真实路径**(R-c(2):不得用日历缺失的降级路径充当覆盖率)。
|
||
"""
|
||
from datetime import date as _date, timedelta as _td
|
||
|
||
today = _date.today()
|
||
with engine.begin() as conn:
|
||
for i in range(days_back + 1):
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_trade_calendar (cal_date, is_open, remark)"
|
||
" VALUES (:d, 1, 't')"
|
||
),
|
||
{"d": today - _td(days=i)},
|
||
)
|
||
|
||
|
||
def _seed_convert_request(engine, rows) -> None:
|
||
"""rows: (group_id, status, requested_at) —— 在途/终态受理单直接落 Core 表。"""
|
||
with engine.begin() as conn:
|
||
for gid, status, requested in rows:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_convert_request (convert_group_id, customer_id,"
|
||
" from_product_id, to_product_id, qty, status, requested_at)"
|
||
" VALUES (:g, 'CUST-T12S', 'PA', 'PB', 1000.0, :s, :r)"
|
||
),
|
||
{"g": gid, "s": status, "r": requested},
|
||
)
|
||
|
||
|
||
def _request_status_map(engine) -> dict:
|
||
with engine.connect() as conn:
|
||
return {
|
||
r["convert_group_id"]: r["status"]
|
||
for r in conn.execute(
|
||
text("SELECT convert_group_id, status FROM core_convert_request")
|
||
).mappings()
|
||
}
|
||
|
||
|
||
def _t12_cleanup(engine, days: int, **kw) -> dict:
|
||
"""统一调用形态:新签名 `cleanup(request_repo, core, days, ...)`。"""
|
||
return cleanup(
|
||
ConvertRequestRepository(engine=engine),
|
||
CoreReadOnlyRepository(engine=engine),
|
||
days,
|
||
**kw,
|
||
)
|
||
|
||
|
||
def test_cleanup_expires_only_overdue_inflight(sqlite_engine):
|
||
"""只动超 SLA 的**在途态**(accepted/nav_pending):fresh / 终态一律不碰。
|
||
|
||
S2 标记不硬删:行仍在;**镜像不动**(T-4 契约:镜像不推 expired,
|
||
业务状态以 Core 6 态为准)。
|
||
"""
|
||
_seed_calendar_allopen(sqlite_engine)
|
||
overdue = datetime.now() - timedelta(days=10)
|
||
fresh = datetime.now() - timedelta(hours=1)
|
||
_seed_convert_request(
|
||
sqlite_engine,
|
||
[
|
||
("CNV-OLD", "accepted", overdue),
|
||
("CNV-NEW", "accepted", fresh),
|
||
("CNV-NAV", "nav_pending", overdue),
|
||
("CNV-DONE", "confirmed", overdue),
|
||
("CNV-CANC", "cancelled", overdue),
|
||
],
|
||
)
|
||
with sqlite_engine.begin() as conn: # 一行镜像留痕,断言 cleanup 不碰它
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO risk_convert_detail (convert_group_id, status, estimated,"
|
||
" created_at) VALUES ('CNV-OLD', 'pending', 0, :c)"
|
||
),
|
||
{"c": overdue},
|
||
)
|
||
|
||
summary = _t12_cleanup(sqlite_engine, 2)
|
||
|
||
assert summary["sla_days"] == 2 and summary["dry_run"] is False
|
||
assert summary["candidate_count"] == 2
|
||
assert summary["expired"] == ["CNV-OLD", "CNV-NAV"]
|
||
assert _request_status_map(sqlite_engine) == {
|
||
"CNV-OLD": "expired",
|
||
"CNV-NAV": "expired",
|
||
"CNV-NEW": "accepted",
|
||
"CNV-DONE": "confirmed",
|
||
"CNV-CANC": "cancelled",
|
||
}
|
||
# S2:标记不硬删 —— 行仍在(5 行一行不少)
|
||
assert _counts(sqlite_engine, "core_convert_request") == 5
|
||
# 镜像不被 cleanup 推状态(T-4 契约:业务状态以 Core 为准)
|
||
with sqlite_engine.connect() as conn:
|
||
mirror_status = conn.execute(
|
||
text("SELECT status FROM risk_convert_detail WHERE convert_group_id = 'CNV-OLD'")
|
||
).scalar_one()
|
||
assert mirror_status == "pending"
|
||
|
||
|
||
def test_cleanup_idempotent_second_run_finds_nothing(sqlite_engine):
|
||
"""复跑 → 候选为空(已 expired 的行不再进候选),行数不变。"""
|
||
_seed_calendar_allopen(sqlite_engine)
|
||
_seed_convert_request(
|
||
sqlite_engine,
|
||
[("CNV-OLD", "accepted", datetime.now() - timedelta(days=10))],
|
||
)
|
||
first = _t12_cleanup(sqlite_engine, 2)
|
||
second = _t12_cleanup(sqlite_engine, 2)
|
||
assert first["expired"] == ["CNV-OLD"]
|
||
assert second["candidate_count"] == 0 and second["expired"] == []
|
||
assert _request_status_map(sqlite_engine) == {"CNV-OLD": "expired"}
|
||
assert _counts(sqlite_engine, "core_convert_request") == 1
|
||
|
||
|
||
def test_cleanup_dry_run_writes_nothing(sqlite_engine):
|
||
"""--dry-run 只报告候选,状态不动。"""
|
||
_seed_calendar_allopen(sqlite_engine)
|
||
_seed_convert_request(
|
||
sqlite_engine,
|
||
[("CNV-OLD", "accepted", datetime.now() - timedelta(days=10))],
|
||
)
|
||
summary = _t12_cleanup(sqlite_engine, 2, dry_run=True)
|
||
assert summary["dry_run"] is True
|
||
assert summary["candidate_count"] == 1 and summary["expired_count"] == 0
|
||
assert summary["candidates"][0]["convert_group_id"] == "CNV-OLD"
|
||
assert _request_status_map(sqlite_engine) == {"CNV-OLD": "accepted"}
|
||
|
||
|
||
def test_cleanup_honours_custom_sla(sqlite_engine):
|
||
"""--days 覆盖 SLA:窗口宽度 = days 个交易日(全开市日历下交易日 = 自然日)。
|
||
|
||
cutoff = 当前业务日上推 days 个交易日(与 `_window_start` 同构):
|
||
days=2 → cutoff = today−2,受理日 today−3 的单超时、**边界日 today−2 的单保留**
|
||
(confirm_batch 当天仍会尝试确认它);days=3 → cutoff = today−3,边界外扩,
|
||
该单回到窗口内不再超时 —— SLA 越宽清得越少(方向断言防口径倒置)。
|
||
"""
|
||
_seed_calendar_allopen(sqlite_engine)
|
||
two_days_ago = datetime.now() - timedelta(days=2)
|
||
three_days_ago = datetime.now() - timedelta(days=3)
|
||
_seed_convert_request(
|
||
sqlite_engine,
|
||
[
|
||
("CNV-2D", "accepted", two_days_ago),
|
||
("CNV-3D", "accepted", three_days_ago),
|
||
],
|
||
)
|
||
narrow = _t12_cleanup(sqlite_engine, 2, dry_run=True)
|
||
assert [c["convert_group_id"] for c in narrow["candidates"]] == ["CNV-3D"]
|
||
wider = _t12_cleanup(sqlite_engine, 3, dry_run=True)
|
||
assert wider["candidate_count"] == 0 # SLA 放宽一天:3D 的单回到确认窗口内
|
||
|
||
|
||
def test_cleanup_window_matches_confirm_batch_window(sqlite_engine):
|
||
"""SLA 边界与确认批处理捞单窗口**同口径**(R-14 核心约束)。
|
||
|
||
行为化等价断言:受理日 = 窗口下界(边界日)的单 —— `confirm_batch` 的
|
||
`list_pending_by_biz_date` 仍能捞到(含下界),cleanup 也**不清**它;
|
||
受理日 = 下界前一交易日的单 —— 两边都排除。口径漂移在此变红。
|
||
"""
|
||
from datetime import date as _date
|
||
|
||
from app.service.convert.confirm_service import _window_start
|
||
from app.service.convert.trading_calendar import previous_biz_day
|
||
|
||
_seed_calendar_allopen(sqlite_engine)
|
||
core = CoreReadOnlyRepository(engine=sqlite_engine)
|
||
today = _date.today()
|
||
current_biz = today if core.is_open(today) else previous_biz_day(today, core.is_open)
|
||
window_start = _window_start(core, current_biz) # confirm_batch 捞单窗口下界
|
||
day_before = previous_biz_day(window_start.date(), core.is_open)
|
||
|
||
_seed_convert_request(
|
||
sqlite_engine,
|
||
[
|
||
("CNV-EDGE", "accepted", datetime.combine(window_start.date(), datetime.min.time())),
|
||
("CNV-BEFORE", "accepted", datetime.combine(day_before, datetime.min.time())),
|
||
],
|
||
)
|
||
|
||
# confirm_batch 视角:边界日在窗口内(仍会被确认)、前一日已被窗口排除
|
||
rrepo = ConvertRequestRepository(engine=sqlite_engine)
|
||
scanned = {
|
||
r["convert_group_id"]
|
||
for r in rrepo.list_pending_by_biz_date(current_biz, window_start)
|
||
}
|
||
assert scanned == {"CNV-EDGE"}
|
||
|
||
# cleanup 视角:与窗口同口径 —— 只清窗口外的,边界日单保留
|
||
summary = _t12_cleanup(sqlite_engine, 2)
|
||
assert summary["expired"] == ["CNV-BEFORE"]
|
||
assert _request_status_map(sqlite_engine)["CNV-EDGE"] == "accepted"
|