# 合同 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