AutoRpa/rpa_contract/tests/test_job.py

461 lines
15 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 date, datetime, timedelta
from decimal import Decimal
from errors import DiligenceError, NodeError
from job import PAGE_FAILED, outcome_from_run, run_batch
from persist import (
KIND_DILIGENCE,
KIND_FAIL,
KIND_OK,
LOOKUP_ONLY_RESULT,
ContractOutcome,
build_save,
)
from repo import MemoryContractRepo, PurchaseContractRow
from run_batch import (
DEFAULT_CLAIM_LIMIT,
contract_lookup_title,
main as batch_main,
production_runner,
)
# 构造一条待录合同的表 3 行。
def _row(**overrides) -> PurchaseContractRow:
data = dict(
fid=1,
oms_id=2026100501,
supplier_id="SUP001",
supplier_name="示例供应商",
contract_sign_date=date(2026, 10, 1),
contract_start_date=date(2026, 10, 2),
contract_end_date=date(2027, 9, 30),
payment_term=90,
goods_id="G0001",
goods_name="阿莫西林胶囊",
reg_no="国药准字H20201234",
spec="0.25g*24粒",
unit="盒",
qty=Decimal("10.0000"),
unit_price=Decimal("100.0000"),
purchaser_id="P001",
purchaser_name="张三",
planner_id="PL001",
planner_name="李四",
contract_entry_status=None,
contract_no=None,
process_status=0,
org_code="901",
goods_mdm_code="QX0100167583",
supplier_mdm_code="DW0100046361",
)
data.update(overrides)
return PurchaseContractRow(**data)
# process_status=0 领走后变成 3,协议重录行和已完成行不领。
def test_claim_pending_sets_processing_and_skips_protocol_rerun():
repo = MemoryContractRepo(
[
_row(),
_row(fid=2, oms_id=2026100502, process_status=1),
_row(fid=3, oms_id=2026100503, contract_entry_status=0, contract_no="HT-OLD"),
]
)
claimed = repo.claim_pending()
assert [row.fid for row in claimed] == [1]
assert repo.table3[1].process_status == 3
assert repo.table3[3].process_status == 0
# process_status=3 且 process_time 超过租约的行可以再领。
def test_claim_reclaims_expired_status_3():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[_row(process_status=3, process_time=now - timedelta(minutes=31))]
)
claimed = repo.claim_pending(now=now)
assert [row.fid for row in claimed] == [1]
assert repo.table3[1].process_status == 3
# process_status=3 且未过租约的行不领。
def test_claim_skips_fresh_status_3():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[_row(process_status=3, process_time=now - timedelta(minutes=1))]
)
claimed = repo.claim_pending(now=now)
assert claimed == []
assert repo.table3[1].process_status == 3
# 协议重录行即使停在过期 3 也不领。
def test_claim_skips_protocol_rerun_even_if_expired():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[
_row(
process_status=3,
process_time=now - timedelta(minutes=31),
contract_entry_status=0,
contract_no="HT-OLD",
)
]
)
assert repo.claim_pending(now=now) == []
assert repo.table3[1].process_status == 3
# 同一 oms_id 多行只插一条表 4,表 3 全部改成成功。
def test_save_group_inserts_one_table4_row_per_oms_id():
repo = MemoryContractRepo(
[
_row(),
_row(fid=2, oms_id=2026100501, goods_id="G0002"),
_row(fid=3, oms_id=2026100502, goods_id="G0003"),
]
)
repo.claim_pending()
outcome = ContractOutcome(KIND_OK, "HT20261005001", None)
payload = build_save(list(repo.table3.values()), outcome)
repo.save_group(payload)
assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502]
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
assert {row["action_code"] for row in repo.table4} == {1}
assert {row["contract_status"] for row in repo.table4} == {1}
assert {row["process_status"] for row in repo.table4} == {0}
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["update_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["push_status"] for row in repo.table4} == {0}
assert repo.table3[1].process_status == 1
assert repo.table3[3].process_status == 1
# 同一供应商合单一次录入,协议重录行不进本班。
def test_job_merges_same_supplier_and_writes_table4():
repo = MemoryContractRepo(
[
_row(),
_row(fid=2, oms_id=2026100502, goods_id="G0002"),
_row(fid=3, oms_id=2026100503, contract_entry_status=0, contract_no="HT-OLD"),
]
)
calls: list = []
# 记下草稿并返回成功合同号。
def runner(draft):
calls.append(draft)
return ContractOutcome(KIND_OK, "HT20261005001", None)
run_batch(repo, runner=runner)
assert len(calls) == 1
assert [line.goods_code for line in calls[0].goods_lines] == [
"QX0100167583",
"QX0100167583",
]
assert [line.mdm_code for line in calls[0].goods_lines] == [
"QX0100167583",
"QX0100167583",
]
assert calls[0].customer_code == "DW0100046361"
assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502]
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
assert {row["action_code"] for row in repo.table4} == {1}
assert {row["contract_status"] for row in repo.table4} == {1}
assert {row["process_status"] for row in repo.table4} == {0}
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["push_status"] for row in repo.table4} == {0}
assert repo.table3[1].process_status == 1
assert repo.table3[2].process_status == 1
assert repo.table3[3].process_status == 0
# 尽调失败写客户未尽调,表 3 改成 2,表 4 起草失败。
def test_job_marks_diligence_fail():
repo = MemoryContractRepo([_row()])
# 返回未尽调结果。
def runner(draft):
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
run_batch(repo, runner=runner)
assert repo.table3[1].process_status == 2
assert "客户未尽调" in repo.table3[1].process_result
assert repo.table4[0]["contract_status"] == 3
assert repo.table4[0]["process_status"] == 2
assert repo.table4[0]["update_user_name"] == "系统管理员"
assert repo.table4[0]["contract_no"] is None
assert repo.table4[0]["contract_fail_reason"] == "客户未尽调"
# 缺供应商 MDM 码时不调页面,直接业务失败。
def test_job_skips_runner_when_mdm_missing():
repo = MemoryContractRepo([_row(supplier_mdm_code="")])
called = {"n": 0}
# 本用例不应被调用。
def runner(draft):
called["n"] += 1
return ContractOutcome(KIND_OK, "HT-X", None)
run_batch(repo, runner=runner)
assert called["n"] == 0
assert repo.table3[1].process_status == 2
assert "缺少供应商MDM码" in repo.table3[1].process_result
# 页面尽调失败映射成 KIND_DILIGENCE。
def test_outcome_from_run_maps_diligence():
# 抛出未尽调。
def run():
raise DiligenceError("客户未尽调")
out = outcome_from_run(run)
assert out.kind == KIND_DILIGENCE
assert out.reason == "客户未尽调"
assert out.contract_no is None
# 提交成功但没读到合同号时记失败。
def test_outcome_from_run_maps_empty_number_to_fail():
out = outcome_from_run(lambda: "")
assert out.kind == KIND_FAIL
assert "未读取到真实合同编号" in (out.reason or "")
# 读到合同号时记成功。
def test_outcome_from_run_ok():
out = outcome_from_run(lambda: "HT9")
assert out == ContractOutcome(KIND_OK, "HT9", None)
# 页面 NodeError 仍写业务文案。
def test_outcome_from_run_maps_node_error():
# 抛出客商加载失败。
def run():
raise NodeError("客商加载失败")
out = outcome_from_run(run)
assert out.kind == KIND_FAIL
assert "客商加载失败" in (out.reason or "")
# 未捕获异常只写 PAGE_FAILED,不含原文。
def test_outcome_from_run_maps_unexpected_to_short_code():
# 抛出带 ORA 原文的异常。
def run():
raise RuntimeError("ORA-00001 unique constraint (RPA.UK)")
out = outcome_from_run(run)
assert out.kind == KIND_FAIL
assert out.reason == PAGE_FAILED
assert "ORA-" not in (out.reason or "")
assert out.error_text == "ORA-00001 unique constraint (RPA.UK)"
# runner 抛未捕获异常时表 3/表 4 原因列只写 PAGE_FAILED。
def test_job_maps_unexpected_error_to_short_code():
repo = MemoryContractRepo([_row()])
# 模拟页面未捕获异常。
def runner(draft):
raise RuntimeError("traceback here")
run_batch(repo, runner=runner)
assert repo.table3[1].process_status == 2
assert repo.table3[1].process_result == PAGE_FAILED
assert repo.table4[0]["contract_fail_reason"] == PAGE_FAILED
assert len(repo.shift_errors) == 1
assert repo.shift_errors[0].shift_name == "合同"
assert repo.shift_errors[0].biz_key == "SUP001"
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 行。"""
repo = MemoryContractRepo(
[
_row(fid=i, oms_id=2026100500 + i, supplier_id=f"SUP{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)
return claimed
repo.claim_pending = claim_pending
def runner(_draft):
"""不打开页面,直接记失败。"""
return ContractOutcome(KIND_FAIL, None, "skip")
batch_main([], repo=repo, runner=runner)
assert seen["limit"] == DEFAULT_CLAIM_LIMIT
assert seen["count"] == 10
assert seen["count"] <= DEFAULT_CLAIM_LIMIT
def test_unique_existing_contract_does_not_draft_again():
"""对方已有唯一合同号、本地没写上时,重跑不起草第二份。"""
repo = MemoryContractRepo([_row()])
drafted: list[str] = []
def finder(_browser, draft):
"""标题不含货主和起止日。对方只有一份合同时返回该号。"""
title = contract_lookup_title(draft)
assert title == "示例供应商采购协议20261001"
assert "901" not in title
assert "20261002" not in title
assert "20270930" not in title
return ["HT-EXISTING"]
def creator(_browser, _draft):
"""本用例不应起草。"""
drafted.append("created")
return "HT-NEW"
def runner(draft):
"""用注入的查重,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
summary = run_batch(repo, runner=runner)
assert drafted == []
assert summary.ok == 1
assert repo.table4[0]["contract_no"] == "HT-EXISTING"
assert repo.table3[1].process_status == 1
def test_many_contracts_stay_in_process():
"""命中多条时停在处理中,不新建,不取最新一条。"""
repo = MemoryContractRepo([_row()])
drafted: list[str] = []
def finder(_browser, _draft):
"""两行都在,调用方不能挑最新的那条。"""
return ["HT-OLD", "HT-NEW"]
def creator(_browser, _draft):
"""命中多条时不应起草。"""
drafted.append("created")
return "HT-PICKED"
def runner(draft):
"""用注入的查重,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
run_batch(repo, runner=runner)
assert drafted == []
assert repo.table4 == []
assert repo.table3[1].process_status == 3
assert repo.table3[1].process_result == "我的合同命中多条"
assert repo.claim_pending() == []
def test_missing_number_stays_in_process_and_next_shift_only_looks_up():
"""提交后读不到号停在处理中。下一班只查号,不再起草。"""
repo = MemoryContractRepo([_row()])
drafted: list[int] = []
found = {1: []}
def finder(_browser, draft):
"""第二班必须带着只查号标记。"""
if drafted:
assert draft.lookup_only is True
return found[1]
def creator(_browser, _draft):
"""只有第一班会进来,而且读不到号。"""
drafted.append(1)
return ""
def runner(draft):
"""用注入的查重和起草,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
run_batch(repo, runner=runner)
assert drafted == [1]
assert repo.table4 == []
assert repo.table3[1].process_status == 3
assert repo.table3[1].process_result == LOOKUP_ONLY_RESULT
repo.table3[1].process_time = datetime.now() - timedelta(minutes=31)
found[1] = ["HT-LATER"]
run_batch(repo, runner=runner)
assert drafted == [1]
assert repo.table4[0]["contract_no"] == "HT-LATER"
assert repo.table3[1].process_status == 1
class HoldingContractRepo(MemoryContractRepo):
"""内存仓库加上可计数的 session,用来断言页面期间连接已归还。"""
def __init__(self, rows):
"""在内存行上加 held 计数。"""
super().__init__(rows)
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 = HoldingContractRepo(
[
_row(fid=1, supplier_id="SUP-A"),
_row(fid=2, oms_id=2026100502, supplier_id="SUP-B"),
]
)
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(_draft):
"""页面执行时连接必须已经归还。"""
during_page.append(repo.held)
return ContractOutcome(KIND_OK, "HT-REUSE", 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