333 lines
10 KiB
Python
333 lines
10 KiB
Python
# 合同 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。
|
||
|
||
from __future__ import annotations
|
||
|
||
from contextlib import contextmanager
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
import os
|
||
|
||
import pytest
|
||
|
||
from rpa_oracle import RepoUnavailable, load_job_env
|
||
from persist import KIND_OK, ContractOutcome, build_save
|
||
from repo import (
|
||
LEASE_MINUTES,
|
||
OracleContractRepo,
|
||
PurchaseContractRow,
|
||
_SELECT_PENDING,
|
||
_SELECT_PENDING_LIMIT,
|
||
)
|
||
|
||
|
||
# 构造一条待录合同的表 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)
|
||
|
||
|
||
# 合同 .env 空缺时补 oms .env,已有非空环境变量不覆盖。
|
||
def test_load_runtime_env_fills_blank_from_oms_env(tmp_path, monkeypatch):
|
||
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
|
||
contract_env = tmp_path / "contract.env"
|
||
oms_env = tmp_path / "oms.env"
|
||
contract_env.write_text(
|
||
"RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-contract\n",
|
||
encoding="utf-8",
|
||
)
|
||
oms_env.write_text(
|
||
"RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n",
|
||
encoding="utf-8",
|
||
)
|
||
load_job_env(tmp_path, [contract_env, oms_env])
|
||
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
|
||
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"
|
||
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
|
||
|
||
|
||
# 缺 HOST/用户/密码时 from_env 抛数据库不可用。
|
||
def test_from_env_without_user_raises(monkeypatch):
|
||
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
|
||
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||
with pytest.raises(RepoUnavailable):
|
||
OracleContractRepo.from_env()
|
||
|
||
|
||
# 录 SQL 的假游标,fetchall 返回预设行。
|
||
class _FakeCursor:
|
||
# 挂上共享的 executed 列表和预设查询结果。
|
||
def __init__(
|
||
self,
|
||
executed: list,
|
||
rows: list | None = None,
|
||
description=None,
|
||
many: list | None = None,
|
||
):
|
||
self._executed = executed
|
||
self._rows = rows or []
|
||
self._many = many if many is not None else []
|
||
self.description = description
|
||
self.rowcount = 1
|
||
|
||
# 进入 with,返回自身。
|
||
def __enter__(self):
|
||
return self
|
||
|
||
# 退出 with,不吞异常。
|
||
def __exit__(self, *args):
|
||
return False
|
||
|
||
# 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。
|
||
def execute(self, sql, params=None):
|
||
params = params or {}
|
||
_reject_oversized_in(params)
|
||
self._executed.append((sql, params))
|
||
|
||
# 记下一次批量 SQL 和全部 bind。
|
||
def executemany(self, sql, seq):
|
||
self._many.append((sql, [dict(item) for item in seq]))
|
||
|
||
# 返回预设行的副本。
|
||
def fetchall(self):
|
||
return list(self._rows)
|
||
|
||
|
||
# 假连接:同一实例上的 cursor 共享 executed 列表。
|
||
class _FakeConn:
|
||
# 可选带上领取查询要返回的行。
|
||
def __init__(self, rows: list | None = None, description=None):
|
||
self.executed: list[tuple[str, dict]] = []
|
||
self.many: list[tuple[str, list]] = []
|
||
self.committed = False
|
||
self.commits = 0
|
||
self._rows = rows or []
|
||
self._description = description
|
||
|
||
# 新开一个录 SQL 的游标。
|
||
def cursor(self):
|
||
return _FakeCursor(self.executed, self._rows, self._description, many=self.many)
|
||
|
||
# 记下已提交。
|
||
def commit(self):
|
||
self.committed = True
|
||
self.commits += 1
|
||
|
||
# 本套用例不应回滚。
|
||
def rollback(self):
|
||
raise AssertionError("不应 rollback")
|
||
|
||
# 归还时由池调用,假连接不计数。
|
||
def close(self):
|
||
return None
|
||
|
||
|
||
# 只交出一条连接,并记下借了几次。
|
||
class _FakePool:
|
||
# 挂上要借出的那条假连接。
|
||
def __init__(self, conn):
|
||
self._conn = conn
|
||
self.acquires = 0
|
||
|
||
# 借出连接并给 acquires 加一。
|
||
@contextmanager
|
||
def acquire(self):
|
||
self.acquires += 1
|
||
yield self._conn
|
||
|
||
|
||
_CLAIM_DESCRIPTION = (
|
||
("FID",),
|
||
("OMS_ID",),
|
||
("SUPPLIER_ID",),
|
||
("SUPPLIER_NAME",),
|
||
("CONTRACT_SIGN_DATE",),
|
||
("CONTRACT_START_DATE",),
|
||
("CONTRACT_END_DATE",),
|
||
("PAYMENT_TERM",),
|
||
("GOODS_ID",),
|
||
("GOODS_NAME",),
|
||
("REG_NO",),
|
||
("SPEC",),
|
||
("UNIT",),
|
||
("QTY",),
|
||
("UNIT_PRICE",),
|
||
("PURCHASER_ID",),
|
||
("PURCHASER_NAME",),
|
||
("PLANNER_ID",),
|
||
("PLANNER_NAME",),
|
||
("CONTRACT_ENTRY_STATUS",),
|
||
("CONTRACT_NO",),
|
||
("PROCESS_STATUS",),
|
||
("ORG_CODE",),
|
||
("GOODS_MDM_CODE",),
|
||
("SUPPLIER_MDM_CODE",),
|
||
("PROCESS_RESULT",),
|
||
)
|
||
|
||
|
||
# 构造领取查询返回的一行元组。
|
||
def _fetched_row(fid: int = 11, oms_id: int = 2026100501):
|
||
return (
|
||
fid,
|
||
oms_id,
|
||
"SUP001",
|
||
"示例供应商",
|
||
date(2026, 10, 1),
|
||
date(2026, 10, 2),
|
||
date(2027, 9, 30),
|
||
90,
|
||
"G0001",
|
||
"阿莫西林胶囊",
|
||
"国药准字H20201234",
|
||
"0.25g*24粒",
|
||
"盒",
|
||
Decimal("10.0000"),
|
||
Decimal("100.0000"),
|
||
"P001",
|
||
"张三",
|
||
"PL001",
|
||
"李四",
|
||
None,
|
||
None,
|
||
0,
|
||
"901",
|
||
"QX0100167583",
|
||
"DW0100046361",
|
||
"",
|
||
)
|
||
|
||
|
||
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领。
|
||
def test_claim_sql_uses_skip_locked_and_lease():
|
||
sql = " ".join(_SELECT_PENDING.split()).lower()
|
||
assert "for update skip locked" in sql
|
||
assert "process_status = 0" in sql
|
||
assert "process_status = 3" in sql
|
||
assert "process_time" in sql
|
||
assert "numtodsinterval(:lease_minutes" in sql
|
||
assert "skip locked" in " ".join(_SELECT_PENDING_LIMIT.split()).lower()
|
||
assert LEASE_MINUTES == 30
|
||
|
||
|
||
# 领取一次借连接:SKIP LOCKED 选出后盖成 3。
|
||
def test_claim_pending_locks_and_stamps_in_one_acquire():
|
||
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
|
||
pool = _FakePool(conn)
|
||
repo = OracleContractRepo(pool)
|
||
claimed = repo.claim_pending(limit=5)
|
||
assert pool.acquires == 1
|
||
assert [row.fid for row in claimed] == [11]
|
||
assert claimed[0].process_status == 3
|
||
assert conn.committed is True
|
||
select_sql, select_binds = conn.executed[0]
|
||
assert "skip locked" in select_sql.lower()
|
||
assert select_binds["lease_minutes"] == 30
|
||
assert select_binds["limit"] == 5
|
||
update_sql = " ".join(conn.executed[1][0].split()).lower()
|
||
assert "process_status = 3" in update_sql
|
||
assert "fid" in update_sql
|
||
|
||
|
||
# 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。
|
||
def test_save_group_inserts_then_updates_in_one_acquire():
|
||
conn = _FakeConn()
|
||
pool = _FakePool(conn)
|
||
repo = OracleContractRepo(pool)
|
||
payload = build_save(
|
||
[_row(), _row(fid=2, oms_id=2026100502)],
|
||
ContractOutcome(KIND_OK, "HT1", None),
|
||
)
|
||
repo.save_group(payload)
|
||
assert pool.acquires == 1
|
||
assert conn.committed is True
|
||
assert conn.executed == []
|
||
assert len(conn.many) == 2
|
||
insert_sql, insert_binds = conn.many[0]
|
||
update_sql, update_binds = conn.many[1]
|
||
assert "rpa_contract_agreement_result" in insert_sql.lower()
|
||
assert [row["oms_id"] for row in insert_binds] == [2026100501, 2026100502]
|
||
assert "rpa_purchase_contract_data" in update_sql.lower()
|
||
assert [row["fid"] for row in update_binds] == [1, 2]
|
||
assert [row["process_status"] for row in update_binds] == [1, 1]
|
||
|
||
|
||
# 班次 session 内领取加落表只从池里借一次连接。
|
||
def test_session_claim_and_save_acquire_once():
|
||
conn = _FakeConn(
|
||
rows=[_fetched_row(11), _fetched_row(12, oms_id=2026100502)],
|
||
description=_CLAIM_DESCRIPTION,
|
||
)
|
||
pool = _FakePool(conn)
|
||
repo = OracleContractRepo(pool)
|
||
with repo.session():
|
||
claimed = repo.claim_pending()
|
||
assert [row.fid for row in claimed] == [11, 12]
|
||
repo.save_group(build_save(claimed, ContractOutcome(KIND_OK, "HT1", None)))
|
||
assert pool.acquires == 1
|
||
assert conn.commits >= 2
|
||
|
||
|
||
def _fid_bind_count(params: dict) -> int:
|
||
"""数出本段 bind 里的 fid 个数。"""
|
||
return sum(
|
||
1
|
||
for key in params
|
||
if isinstance(key, str) and key.startswith("id") and key[2:].isdigit()
|
||
)
|
||
|
||
|
||
def _reject_oversized_in(params: dict) -> None:
|
||
"""一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。"""
|
||
if _fid_bind_count(params) > 1000:
|
||
raise RuntimeError("ORA-01795")
|
||
|
||
|
||
def test_claim_1001_rows_updates_first_chunk_without_unavailable():
|
||
"""领取 1001 条时第一段 500 能领走,同一事务提交,不报数据库不可用。"""
|
||
conn = _FakeConn(
|
||
rows=[_fetched_row(fid, oms_id=2026100500 + fid) for fid in range(1, 1002)],
|
||
description=_CLAIM_DESCRIPTION,
|
||
)
|
||
pool = _FakePool(conn)
|
||
repo = OracleContractRepo(pool)
|
||
claimed = repo.claim_pending()
|
||
assert pool.acquires == 1
|
||
assert len(claimed) == 1001
|
||
assert claimed[0].process_status == 3
|
||
updates = [
|
||
item for item in conn.executed if item[0].lstrip().lower().startswith("update")
|
||
]
|
||
assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1]
|
||
assert updates[0][1]["id0"] == 1
|
||
assert conn.committed is True
|