AutoRpa/rpa_rebate/tests/test_job.py

554 lines
19 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.

# 返利班次:领取戳、租约、查号落表、系统故障短码。
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"