554 lines
19 KiB
Python
554 lines
19 KiB
Python
# 返利班次:领取戳、租约、查号落表、系统故障短码。
|
||
|
||
from contextlib import contextmanager
|
||
from datetime import datetime, timedelta
|
||
|
||
from job import (
|
||
HEADER_FAILED,
|
||
IMPORT_FAILED,
|
||
PAGE_FAILED,
|
||
SUBMIT_FAILED,
|
||
recorder,
|
||
run_batch,
|
||
)
|
||
from persist import (
|
||
KIND_FAIL,
|
||
KIND_NOT_FOUND,
|
||
KIND_OK,
|
||
USER_NAME,
|
||
AgreementOutcome,
|
||
)
|
||
from rebate_flow_steps import RebateImportResult, RebateSubmitResult
|
||
from repo import MemoryRebateRepo
|
||
|
||
|
||
# 构造一条表 4 起草行。
|
||
def _t4(**overrides) -> dict:
|
||
row = {
|
||
"fid": 1,
|
||
"oms_id": 2026100501,
|
||
"action_code": 1,
|
||
"contract_status": 1,
|
||
"contract_fail_reason": None,
|
||
"contract_no": "HT1",
|
||
"agreement_status": None,
|
||
"agreement_fail_reason": None,
|
||
"agreement_id": None,
|
||
"agreement_dtl_id": None,
|
||
"org_code": "901",
|
||
"push_status": 0,
|
||
"process_status": 0,
|
||
"create_user_name": USER_NAME,
|
||
"update_user_name": USER_NAME,
|
||
}
|
||
row.update(overrides)
|
||
return row
|
||
|
||
|
||
# 构造一条表 3 只录协议行。
|
||
def _t3(**overrides) -> dict:
|
||
row = {
|
||
"fid": 10,
|
||
"oms_id": 2026100503,
|
||
"contract_no": "HT-OLD",
|
||
"contract_entry_status": 0,
|
||
"process_status": 0,
|
||
"process_result": "",
|
||
"org_code": "901",
|
||
}
|
||
row.update(overrides)
|
||
return row
|
||
|
||
|
||
# 总单替身,返回固定协议总单号。
|
||
def _ok_header(contract_no: str) -> str:
|
||
return "RB1"
|
||
|
||
|
||
# 细单替身,全部成功。
|
||
def _ok_importer(contract_no: str) -> RebateImportResult:
|
||
return RebateImportResult(1, 0, (), True)
|
||
|
||
|
||
# 送审替身,带总单/细单号。
|
||
def _ok_submitter(contract_no: str) -> RebateSubmitResult:
|
||
return RebateSubmitResult(True, "AGR1", "AGRD1")
|
||
|
||
|
||
# 搜到并送审成功:起草行 process_status=1,每 oms_id 插一条 action_code=2。
|
||
def test_job_finds_contract_and_writes_agreement():
|
||
repo = MemoryRebateRepo(
|
||
table4=[
|
||
_t4(),
|
||
_t4(fid=2, oms_id=2026100502),
|
||
_t4(fid=3, contract_status=2, contract_no="HT-DONE", process_status=1),
|
||
]
|
||
)
|
||
steps: list[str] = []
|
||
|
||
# 记下合同号并回成功。
|
||
def runner(group):
|
||
steps.append(group.contract_no)
|
||
return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None)
|
||
|
||
summary = run_batch(repo, runner=runner)
|
||
assert summary.claimed == 2
|
||
assert summary.ok == 1
|
||
assert steps == ["HT1"]
|
||
drafts = [row for row in repo.table4.values() if row["fid"] in (1, 2)]
|
||
assert {row["contract_status"] for row in drafts} == {2}
|
||
assert {row["action_code"] for row in drafts} == {1}
|
||
assert {row["process_status"] for row in drafts} == {1}
|
||
assert {row["update_user_name"] for row in drafts} == {USER_NAME}
|
||
assert {row["push_status"] for row in drafts} == {0}
|
||
agreements = [row for row in repo.table4.values() if row["action_code"] == 2]
|
||
assert sorted(row["oms_id"] for row in agreements) == [2026100501, 2026100502]
|
||
assert {row["contract_no"] for row in agreements} == {"HT1"}
|
||
assert {row["agreement_status"] for row in agreements} == {1}
|
||
assert {row["agreement_id"] for row in agreements} == {"AGR1"}
|
||
assert {row["agreement_dtl_id"] for row in agreements} == {"AGRD1"}
|
||
assert {row["push_status"] for row in agreements} == {0}
|
||
assert {row["process_status"] for row in agreements} == {1}
|
||
assert {row["create_user_name"] for row in agreements} == {USER_NAME}
|
||
assert {row["update_user_name"] for row in agreements} == {USER_NAME}
|
||
assert repo.table4[3]["contract_status"] == 2
|
||
assert repo.table4[3]["action_code"] == 1
|
||
|
||
|
||
# 送审失败:起草行回到可领,agreement_fail_reason 可查,不插协议行。
|
||
def test_job_failed_draft_stays_claimable_with_reason():
|
||
repo = MemoryRebateRepo(
|
||
table4=[_t4(), _t4(fid=2, oms_id=2026100502)]
|
||
)
|
||
|
||
# 模拟送审失败。
|
||
def runner(group):
|
||
return AgreementOutcome(KIND_FAIL, None, None, "送审失败")
|
||
|
||
summary = run_batch(repo, runner=runner)
|
||
assert summary.failed == 1
|
||
assert summary.ok == 0
|
||
for fid in (1, 2):
|
||
row = repo.table4[fid]
|
||
assert row["contract_status"] == 1
|
||
assert row["process_status"] == 0
|
||
assert row["agreement_fail_reason"] == "送审失败"
|
||
assert row["action_code"] == 1
|
||
assert [row["action_code"] for row in repo.table4.values()] == [1, 1]
|
||
again = repo.claim_pending()
|
||
assert [row["fid"] for row in again.drafts] == [1, 2]
|
||
|
||
|
||
# 搜不到:起草行 process_status 改回 0,不插协议行,留给下一班。
|
||
def test_job_leaves_not_found_for_next_shift():
|
||
repo = MemoryRebateRepo(table4=[_t4()])
|
||
later = {"n": 0}
|
||
|
||
# 模拟查号未命中。
|
||
def runner(group):
|
||
later["n"] += 1
|
||
return AgreementOutcome(KIND_NOT_FOUND, None, None, None)
|
||
|
||
run_batch(repo, runner=runner)
|
||
assert later["n"] == 1
|
||
row = repo.table4[1]
|
||
assert row["contract_status"] == 1
|
||
assert row["action_code"] == 1
|
||
assert row["process_status"] == 0
|
||
assert row["update_user_name"] == USER_NAME
|
||
assert [item["action_code"] for item in repo.table4.values()] == [1]
|
||
|
||
|
||
# 表 3 只录协议行领走后写表 4,已完成行不领。
|
||
def test_job_claims_protocol_only_and_writes_table4():
|
||
repo = MemoryRebateRepo(table3=[_t3(), _t3(fid=11, process_status=1, contract_no="HT-SKIP")])
|
||
steps: list[str] = []
|
||
|
||
# 记下合同号并回成功。
|
||
def runner(group):
|
||
steps.append(group.contract_no)
|
||
return AgreementOutcome(KIND_OK, "AGR-OLD", "AGRD-OLD", None)
|
||
|
||
run_batch(repo, runner=runner)
|
||
assert steps == ["HT-OLD"]
|
||
assert repo.table3[10]["process_status"] == 1
|
||
assert repo.table3[11]["process_status"] == 1
|
||
success = [row for row in repo.table4.values() if row["action_code"] == 1]
|
||
agreements = [row for row in repo.table4.values() if row["action_code"] == 2]
|
||
assert len(success) == 1
|
||
assert success[0]["oms_id"] == 2026100503
|
||
assert success[0]["contract_status"] == 2
|
||
assert success[0]["contract_no"] == "HT-OLD"
|
||
assert success[0]["push_status"] == 0
|
||
assert len(agreements) == 1
|
||
assert agreements[0]["agreement_status"] == 1
|
||
assert agreements[0]["agreement_id"] == "AGR-OLD"
|
||
assert agreements[0]["agreement_dtl_id"] == "AGRD-OLD"
|
||
|
||
|
||
# 待查号行领走后 process_status=3,已回写行不领。
|
||
def test_claim_pending_stamps_drafts_and_skips_done():
|
||
repo = MemoryRebateRepo(
|
||
table4=[
|
||
_t4(),
|
||
_t4(fid=2, contract_status=2, process_status=1),
|
||
]
|
||
)
|
||
claimed = repo.claim_pending()
|
||
assert [row["fid"] for row in claimed.drafts] == [1]
|
||
assert claimed.drafts[0]["process_status"] == 3
|
||
assert claimed.drafts[0]["update_user_name"] == USER_NAME
|
||
assert repo.table4[1]["process_status"] == 3
|
||
assert repo.table4[2]["process_status"] == 1
|
||
|
||
|
||
# 表 4 process_status=3 且 update_time 超过租约的行可以再领。
|
||
def test_claim_reclaims_expired_claiming_stamp():
|
||
now = datetime(2026, 10, 22, 12, 0, 0)
|
||
repo = MemoryRebateRepo(
|
||
table4=[_t4(process_status=3, update_time=now - timedelta(minutes=31))]
|
||
)
|
||
claimed = repo.claim_pending(now=now)
|
||
assert [row["fid"] for row in claimed.drafts] == [1]
|
||
assert repo.table4[1]["process_status"] == 3
|
||
|
||
|
||
# 表 4 process_status=3 且未过租约的行不领。
|
||
def test_claim_skips_fresh_claiming_stamp():
|
||
now = datetime(2026, 10, 22, 12, 0, 0)
|
||
repo = MemoryRebateRepo(
|
||
table4=[_t4(process_status=3, update_time=now - timedelta(minutes=1))]
|
||
)
|
||
claimed = repo.claim_pending(now=now)
|
||
assert claimed.drafts == []
|
||
assert repo.table4[1]["process_status"] == 3
|
||
|
||
|
||
# limit 不切开同一合同号;组内有未过租约的 3 则整组不领。
|
||
def test_claim_keeps_whole_contract_and_skips_fresh_sibling():
|
||
now = datetime(2026, 10, 22, 12, 0, 0)
|
||
repo = MemoryRebateRepo(
|
||
table4=[
|
||
_t4(fid=1, contract_no="HT1"),
|
||
_t4(fid=2, oms_id=2026100502, contract_no="HT1"),
|
||
_t4(fid=3, oms_id=2026100503, contract_no="HT2"),
|
||
_t4(fid=4, oms_id=2026100504, contract_no="HT3"),
|
||
_t4(
|
||
fid=5,
|
||
oms_id=2026100505,
|
||
contract_no="HT3",
|
||
process_status=3,
|
||
update_time=now - timedelta(minutes=1),
|
||
),
|
||
]
|
||
)
|
||
claimed = repo.claim_pending(limit=1, now=now)
|
||
assert [row["fid"] for row in claimed.drafts] == [1, 2]
|
||
assert repo.table4[3]["process_status"] == 0
|
||
assert repo.table4[4]["process_status"] == 0
|
||
assert repo.table4[5]["process_status"] == 3
|
||
|
||
|
||
# 表 3 只录协议行停在 3 且 process_time 超过租约可以再领。
|
||
def test_claim_reclaims_expired_protocol_status_3():
|
||
now = datetime(2026, 10, 22, 12, 0, 0)
|
||
repo = MemoryRebateRepo(
|
||
table3=[_t3(process_status=3, process_time=now - timedelta(minutes=31))]
|
||
)
|
||
claimed = repo.claim_pending(now=now)
|
||
assert [row["fid"] for row in claimed.protocols] == [10]
|
||
assert repo.table3[10]["process_status"] == 3
|
||
|
||
|
||
# 表 3 只录协议行停在 3 且未过租约的不领。
|
||
def test_claim_skips_fresh_protocol_status_3():
|
||
now = datetime(2026, 10, 22, 12, 0, 0)
|
||
repo = MemoryRebateRepo(
|
||
table3=[_t3(process_status=3, process_time=now - timedelta(minutes=1))]
|
||
)
|
||
claimed = repo.claim_pending(now=now)
|
||
assert claimed.protocols == []
|
||
assert repo.table3[10]["process_status"] == 3
|
||
|
||
|
||
# recorder 按总单 → 细单 → 送审顺序把合同号传下去。
|
||
def test_recorder_passes_contract_no_to_header_import_submit():
|
||
steps: list[str] = []
|
||
|
||
# 记下总单调用。
|
||
def header(contract_no: str) -> str:
|
||
steps.append(f"header:{contract_no}")
|
||
return "RB1"
|
||
|
||
# 记下细单调用。
|
||
def importer(contract_no: str) -> RebateImportResult:
|
||
steps.append(f"import:{contract_no}")
|
||
return RebateImportResult(1, 0, (), True)
|
||
|
||
# 记下送审调用。
|
||
def submitter(contract_no: str) -> RebateSubmitResult:
|
||
steps.append(f"submit:{contract_no}")
|
||
return RebateSubmitResult(True, "AGR1", "AGRD1")
|
||
|
||
outcome = recorder("HT1", header, importer, submitter)
|
||
assert steps == ["header:HT1", "import:HT1", "submit:HT1"]
|
||
assert outcome.kind == KIND_OK
|
||
assert outcome.agreement_id == "AGR1"
|
||
assert outcome.agreement_dtl_id == "AGRD1"
|
||
|
||
|
||
# 总单抛错只写 HEADER_FAILED,不含异常原文。
|
||
def test_recorder_maps_header_error_to_short_code():
|
||
# 模拟总单页面未捕获异常。
|
||
def header(contract_no: str) -> str:
|
||
raise RuntimeError("ORA-00001 unique constraint (RPA.UK)")
|
||
|
||
outcome = recorder("HT1", header, _ok_importer, _ok_submitter)
|
||
assert outcome.kind == KIND_FAIL
|
||
assert outcome.reason == HEADER_FAILED
|
||
assert "ORA-" not in (outcome.reason or "")
|
||
assert outcome.error_text == "ORA-00001 unique constraint (RPA.UK)"
|
||
|
||
|
||
# 细单抛错只写 IMPORT_FAILED,不含异常原文。
|
||
def test_recorder_maps_import_error_to_short_code():
|
||
# 模拟细单页面未捕获异常。
|
||
def importer(contract_no: str) -> RebateImportResult:
|
||
raise RuntimeError("traceback here")
|
||
|
||
outcome = recorder("HT1", _ok_header, importer, _ok_submitter)
|
||
assert outcome.kind == KIND_FAIL
|
||
assert outcome.reason == IMPORT_FAILED
|
||
assert "traceback" not in (outcome.reason or "")
|
||
|
||
|
||
# 送审抛错只写 SUBMIT_FAILED,不含异常原文。
|
||
def test_recorder_maps_submit_error_to_short_code():
|
||
# 模拟送审页面未捕获异常。
|
||
def submitter(contract_no: str) -> RebateSubmitResult:
|
||
raise RuntimeError("ORA-12541 boom")
|
||
|
||
outcome = recorder("HT1", _ok_header, _ok_importer, submitter)
|
||
assert outcome.kind == KIND_FAIL
|
||
assert outcome.reason == SUBMIT_FAILED
|
||
assert "ORA-" not in (outcome.reason or "")
|
||
|
||
|
||
# runner 抛未捕获异常时表 3 原因列只写 PAGE_FAILED。
|
||
def test_job_maps_unexpected_error_to_short_code():
|
||
repo = MemoryRebateRepo(table3=[_t3()])
|
||
|
||
# 模拟查号后页面未捕获异常。
|
||
def runner(group):
|
||
raise RuntimeError("traceback here")
|
||
|
||
run_batch(repo, runner=runner)
|
||
assert repo.table3[10]["process_status"] == 2
|
||
assert repo.table3[10]["process_result"] == PAGE_FAILED
|
||
assert "traceback" not in repo.table3[10]["process_result"]
|
||
assert len(repo.shift_errors) == 1
|
||
assert repo.shift_errors[0].shift_name == "返利"
|
||
assert repo.shift_errors[0].biz_key == "HT-OLD"
|
||
assert repo.shift_errors[0].error_code == PAGE_FAILED
|
||
assert repo.shift_errors[0].error_text == "traceback here"
|
||
|
||
|
||
def test_default_limit_caps_claim_at_10():
|
||
"""不传 --limit 时返利班最多领 10 行。"""
|
||
from run_batch import DEFAULT_CLAIM_LIMIT, main as batch_main
|
||
|
||
repo = MemoryRebateRepo(
|
||
table4=[
|
||
_t4(fid=i, oms_id=2026100500 + i, contract_no=f"HT{i}")
|
||
for i in range(1, 16)
|
||
]
|
||
)
|
||
seen: dict[str, int | None] = {}
|
||
original = repo.claim_pending
|
||
|
||
def claim_pending(limit=None, **kwargs):
|
||
"""记下入口传给领取的上限和实际领走的行数。"""
|
||
seen["limit"] = limit
|
||
claimed = original(limit=limit, **kwargs)
|
||
seen["count"] = len(claimed.drafts) + len(claimed.protocols)
|
||
return claimed
|
||
|
||
repo.claim_pending = claim_pending
|
||
|
||
def runner(_group):
|
||
"""不打开页面,当作没搜到。"""
|
||
return AgreementOutcome(KIND_NOT_FOUND, None, None, None)
|
||
|
||
batch_main([], repo=repo, runner=runner)
|
||
assert seen["limit"] == DEFAULT_CLAIM_LIMIT
|
||
assert seen["count"] == 10
|
||
assert seen["count"] <= DEFAULT_CLAIM_LIMIT
|
||
|
||
|
||
class HoldingRebateRepo(MemoryRebateRepo):
|
||
"""内存仓库加上可计数的 session,用来断言页面期间连接已归还。"""
|
||
|
||
def __init__(self, table4=None, table3=None):
|
||
"""在内存行上加 held 计数。"""
|
||
super().__init__(table4=table4, table3=table3)
|
||
self.held = 0
|
||
|
||
@contextmanager
|
||
def session(self):
|
||
"""借连接时 held 加一,退出时减回。"""
|
||
self.held += 1
|
||
try:
|
||
yield
|
||
finally:
|
||
self.held -= 1
|
||
|
||
|
||
def test_page_runs_after_connection_is_returned():
|
||
"""两组查号时连接已归还,落表时才再借。"""
|
||
repo = HoldingRebateRepo(
|
||
table4=[
|
||
_t4(fid=1, contract_no="HT1"),
|
||
_t4(fid=2, oms_id=2026100502, contract_no="HT2"),
|
||
]
|
||
)
|
||
during_page: list[int] = []
|
||
during_save: list[int] = []
|
||
original_claim = repo.claim_pending
|
||
original_save = repo.save_group
|
||
original_errors = repo.save_shift_errors
|
||
|
||
def claim_pending(limit=None, **kwargs):
|
||
"""领取时必须占着本班的连接。"""
|
||
assert repo.held == 1
|
||
return original_claim(limit=limit, **kwargs)
|
||
|
||
def save_group(payload):
|
||
"""落表时必须占着本班的连接。"""
|
||
during_save.append(repo.held)
|
||
return original_save(payload)
|
||
|
||
def save_shift_errors(items):
|
||
"""审计走连接池,不能在 session 里写。"""
|
||
assert repo.held == 0
|
||
return original_errors(items)
|
||
|
||
def runner(_group):
|
||
"""查号和录协议时连接必须已经归还。"""
|
||
during_page.append(repo.held)
|
||
return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None)
|
||
|
||
repo.claim_pending = claim_pending
|
||
repo.save_group = save_group
|
||
repo.save_shift_errors = save_shift_errors
|
||
summary = run_batch(repo, runner=runner)
|
||
assert during_page == [0, 0]
|
||
assert during_save == [1, 1]
|
||
assert repo.held == 0
|
||
assert summary.groups == 2
|
||
assert summary.ok == 2
|
||
|
||
|
||
def test_existing_agreement_is_not_created_again():
|
||
"""对方已有协议、本地没写上时,重跑不产生第二份。"""
|
||
repo = MemoryRebateRepo(
|
||
table4=[_t4(), _t4(fid=2, oms_id=2026100502)]
|
||
)
|
||
created: list[str] = []
|
||
|
||
def runner(group):
|
||
"""已有总单号则补写,不再走总单、细单和送审。"""
|
||
|
||
def header(_number: str) -> str:
|
||
"""本用例不应新建总单。"""
|
||
created.append("header")
|
||
return "NEW"
|
||
|
||
def importer(_number: str) -> RebateImportResult:
|
||
"""本用例不应新建细单。"""
|
||
created.append("import")
|
||
return RebateImportResult(1, 0, (), True)
|
||
|
||
def submitter(_number: str) -> RebateSubmitResult:
|
||
"""本用例不应再送审。"""
|
||
created.append("submit")
|
||
return RebateSubmitResult(True, "AGR-NEW", "DTL-NEW")
|
||
|
||
def existing(number: str) -> str:
|
||
"""对方这一合同已经有协议总单。"""
|
||
assert number == "HT1"
|
||
return "AGR-ALREADY"
|
||
|
||
return recorder(
|
||
group.contract_no,
|
||
header,
|
||
importer,
|
||
submitter,
|
||
existing=existing,
|
||
)
|
||
|
||
summary = run_batch(repo, runner=runner)
|
||
assert created == []
|
||
assert summary.ok == 1
|
||
agreements = [row for row in repo.table4.values() if row["action_code"] == 2]
|
||
assert sorted(row["oms_id"] for row in agreements) == [2026100501, 2026100502]
|
||
assert {row["agreement_id"] for row in agreements} == {"AGR-ALREADY"}
|
||
assert {row["agreement_status"] for row in agreements} == {1}
|
||
|
||
|
||
def test_unreadable_agreement_stays_in_process():
|
||
"""读号失败保持处理中。确认没有单据才放回待领。"""
|
||
repo = MemoryRebateRepo(table4=[_t4()])
|
||
|
||
def runner(group):
|
||
"""查已有协议时读不到号。"""
|
||
|
||
def existing(_number: str) -> str:
|
||
"""页面没有把总单号读出来。"""
|
||
raise RuntimeError("页面没有总单号")
|
||
|
||
return recorder(
|
||
group.contract_no,
|
||
_ok_header,
|
||
_ok_importer,
|
||
_ok_submitter,
|
||
existing=existing,
|
||
)
|
||
|
||
run_batch(repo, runner=runner)
|
||
row = repo.table4[1]
|
||
assert row["process_status"] == 3
|
||
assert row["contract_status"] == 1
|
||
assert row["action_code"] == 1
|
||
assert row["agreement_fail_reason"] == "读号失败"
|
||
assert [item for item in repo.table4.values() if item["action_code"] == 2] == []
|
||
assert repo.claim_pending().drafts == []
|
||
|
||
|
||
def test_confirmed_absent_agreement_still_creates_once():
|
||
"""确认没有协议时才新建一份,空串不算已有总单。"""
|
||
steps: list[str] = []
|
||
|
||
def header(contract_no: str) -> str:
|
||
"""确认没有协议后才填总单。"""
|
||
steps.append(f"header:{contract_no}")
|
||
return "RB1"
|
||
|
||
def importer(contract_no: str) -> RebateImportResult:
|
||
"""确认没有协议后才新增细单。"""
|
||
steps.append(f"import:{contract_no}")
|
||
return RebateImportResult(1, 0, (), True)
|
||
|
||
def submitter(contract_no: str) -> RebateSubmitResult:
|
||
"""确认没有协议后才送审。"""
|
||
steps.append(f"submit:{contract_no}")
|
||
return RebateSubmitResult(True, "AGR1", "AGRD1")
|
||
|
||
def existing(_number: str) -> str:
|
||
"""对方确认没有这份协议。"""
|
||
return ""
|
||
|
||
outcome = recorder("HT1", header, importer, submitter, existing=existing)
|
||
assert steps == ["header:HT1", "import:HT1", "submit:HT1"]
|
||
assert outcome.kind == KIND_OK
|
||
assert outcome.agreement_id == "AGR1"
|