# 返利 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。 from __future__ import annotations from contextlib import contextmanager import os import pytest from rpa_oracle import RepoUnavailable, load_job_env from persist import USER_NAME, GroupSave from repo import ( LEASE_MINUTES, OracleRebateRepo, _SELECT_DRAFTS, _SELECT_DRAFTS_LIMIT, _SELECT_PROTOCOLS, _SELECT_PROTOCOLS_LIMIT, ) # 返利 .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") rebate_env = tmp_path / "rebate.env" oms_env = tmp_path / "oms.env" rebate_env.write_text( "RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-rebate\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, [rebate_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): OracleRebateRepo.from_env() # 录 SQL 的假游标,按表名返回预设领取行。 class _FakeCursor: # 挂上共享的假连接。 def __init__(self, conn): self._conn = conn self._rows: list = [] self.description = None self.rowcount = 1 # 进入 with,返回自身。 def __enter__(self): return self # 退出 with,不吞异常。 def __exit__(self, *args): return False # 记下一次批量 SQL 和全部 bind。 def executemany(self, sql, seq): self._conn.many.append((sql, [dict(item) for item in seq])) # 记下 SQL 和 bind;SELECT 按表切领取结果。一条 IN 超过 1000 个 fid 时抛错。 def execute(self, sql, params=None): params = params or {} _reject_oversized_in(params) self._conn.executed.append((sql, params)) text = sql.lstrip().lower() if text.startswith("select") and "rpa_contract_agreement_result" in text: self._rows = self._conn.draft_rows self.description = self._conn.draft_description elif text.startswith("select") and "rpa_purchase_contract_data" in text: self._rows = self._conn.protocol_rows self.description = self._conn.protocol_description else: self._rows = [] # 返回本次 SELECT 预设行的副本。 def fetchall(self): return list(self._rows) # 假连接:同一实例上的 cursor 共享 executed 列表。 class _FakeConn: # 可选带上表 4 起草行和表 3 只录协议行。 def __init__(self, *, draft_rows=None, protocol_rows=None): self.executed: list[tuple[str, dict]] = [] self.many: list[tuple[str, list]] = [] self.committed = False self.commits = 0 self.draft_rows = draft_rows or [] self.protocol_rows = protocol_rows or [] self.draft_description = _DRAFT_DESCRIPTION self.protocol_description = _PROTOCOL_DESCRIPTION # 新开一个录 SQL 的游标。 def cursor(self): return _FakeCursor(self) # 记下已提交。 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 _DRAFT_DESCRIPTION = ( ("FID",), ("OMS_ID",), ("ACTION_CODE",), ("CONTRACT_STATUS",), ("CONTRACT_FAIL_REASON",), ("CONTRACT_NO",), ("AGREEMENT_STATUS",), ("AGREEMENT_FAIL_REASON",), ("AGREEMENT_ID",), ("AGREEMENT_DTL_ID",), ("ORG_CODE",), ("PUSH_STATUS",), ("PROCESS_STATUS",), ) _PROTOCOL_DESCRIPTION = ( ("FID",), ("OMS_ID",), ("CONTRACT_NO",), ("CONTRACT_ENTRY_STATUS",), ("PROCESS_STATUS",), ("ORG_CODE",), ("PROCESS_RESULT",), ) # 构造表 4 领取查询返回的一行元组。 def _draft_fetched(fid: int = 11, oms_id: int = 2026100501, contract_no: str = "HT1"): """构造表 4 领取查询返回的一行元组。""" return ( fid, oms_id, 1, 1, None, contract_no, None, None, None, None, "901", 0, 0, ) # 构造表 3 只录协议领取查询返回的一行元组。 def _protocol_fetched(fid: int = 21, oms_id: int = 2026100503): return (fid, oms_id, "HT-OLD", 0, 0, "901", "") # 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领,同号未过租约整组排除。 def test_claim_sql_uses_skip_locked_and_lease(): draft_sql = " ".join(_SELECT_DRAFTS.split()).lower() assert "for update skip locked" in draft_sql assert "process_status = 0" in draft_sql assert "process_status = 3" in draft_sql assert "not exists" in draft_sql assert "numtodsinterval(:lease_minutes" in draft_sql assert "rownum" not in draft_sql assert "skip locked" in " ".join(_SELECT_DRAFTS_LIMIT.split()).lower() protocol_sql = " ".join(_SELECT_PROTOCOLS.split()).lower() assert "for update skip locked" in protocol_sql assert "process_status = 0" in protocol_sql assert "process_status = 3" in protocol_sql assert "process_time" in protocol_sql assert "numtodsinterval(:lease_minutes" in protocol_sql assert "skip locked" in " ".join(_SELECT_PROTOCOLS_LIMIT.split()).lower() assert LEASE_MINUTES == 30 # 领取一次借连接:SKIP LOCKED 选出后表 4、表 3 都改成 process_status=3。 def test_claim_pending_locks_and_stamps_in_one_acquire(): conn = _FakeConn( draft_rows=[_draft_fetched()], protocol_rows=[_protocol_fetched()], ) pool = _FakePool(conn) repo = OracleRebateRepo(pool) claimed = repo.claim_pending(limit=5) assert pool.acquires == 1 assert [row["fid"] for row in claimed.drafts] == [11] assert claimed.drafts[0]["process_status"] == 3 assert claimed.drafts[0]["update_user_name"] == USER_NAME assert [row["fid"] for row in claimed.protocols] == [21] assert claimed.protocols[0]["process_status"] == 3 assert conn.committed is True selects = [item for item in conn.executed if item[0].lstrip().lower().startswith("select")] updates = [item for item in conn.executed if item[0].lstrip().lower().startswith("update")] assert len(selects) == 2 assert len(updates) == 2 assert "skip locked" in selects[0][0].lower() assert selects[0][1]["lease_minutes"] == 30 assert "limit" not in selects[0][1] assert selects[1][1]["limit"] == 4 assert "skip locked" in selects[1][0].lower() draft_update = " ".join(updates[0][0].split()).lower() assert "process_status = 3" in draft_update assert "系统管理员" in updates[0][0] protocol_update = " ".join(updates[1][0].split()).lower() assert "process_status = 3" in protocol_update # 表 4 更新/释放/插入和表 3 更新各一次 executemany,同一连接同一事务。 def test_save_group_writes_in_one_acquire(): conn = _FakeConn() pool = _FakePool(conn) repo = OracleRebateRepo(pool) payload = GroupSave( table4_updates=[ {"fid": 1, "contract_status": 2, "process_status": 1}, { "fid": 3, "contract_status": 1, "process_status": 0, "agreement_fail_reason": "送审失败", }, ], table4_inserts=[ { "oms_id": 2026100501, "action_code": 2, "contract_status": None, "contract_fail_reason": None, "contract_no": "HT1", "agreement_status": 1, "agreement_fail_reason": None, "agreement_id": "AGR1", "agreement_dtl_id": "AGRD1", "process_status": 1, "org_code": "901", }, { "oms_id": 2026100502, "action_code": 2, "contract_status": None, "contract_fail_reason": None, "contract_no": "HT1", "agreement_status": 1, "agreement_fail_reason": None, "agreement_id": "AGR1", "agreement_dtl_id": "AGRD2", "process_status": 1, "org_code": "901", }, ], table4_release_fids=[2, 4], table3_updates=[ {"fid": 10, "process_status": 1, "process_result": "协议已录入"}, {"fid": 11, "process_status": 0, "process_result": "下一班再领"}, ], ) repo.save_group(payload) assert pool.acquires == 1 assert conn.committed is True assert conn.executed == [] assert len(conn.many) == 4 draft_sql, draft_binds = conn.many[0] release_sql, release_binds = conn.many[1] insert_sql, insert_binds = conn.many[2] protocol_sql, protocol_binds = conn.many[3] assert "agreement_fail_reason" in draft_sql.lower() assert [row["fid"] for row in draft_binds] == [1, 3] assert draft_binds[0]["agreement_fail_reason"] is None assert draft_binds[1]["agreement_fail_reason"] == "送审失败" assert [row["fid"] for row in release_binds] == [2, 4] assert "process_status = 0" in " ".join(release_sql.split()).lower() assert "insert into 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 protocol_sql.lower() assert [row["fid"] for row in protocol_binds] == [10, 11] assert [row["process_status"] for row in protocol_binds] == [1, 0] # 班次 session 内领取加落表只从池里借一次连接。 def test_session_claim_and_save_acquire_once(): conn = _FakeConn(draft_rows=[_draft_fetched()]) pool = _FakePool(conn) repo = OracleRebateRepo(pool) payload = GroupSave( table4_updates=[ {"fid": 11, "contract_status": 2, "process_status": 1}, ], table4_inserts=[], table4_release_fids=[], table3_updates=[], ) with repo.session(): claimed = repo.claim_pending() assert [row["fid"] for row in claimed.drafts] == [11] repo.save_group(payload) 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_one_contract_over_1000_rows_keeps_the_whole_group(): """一个合同号超过 1000 行仍整组领走,更新按 500 分段,不报数据库不可用。""" rows = [ _draft_fetched(fid, oms_id=2026100500 + fid, contract_no="HT-BIG") for fid in range(1, 1002) ] conn = _FakeConn(draft_rows=rows) pool = _FakePool(conn) repo = OracleRebateRepo(pool) claimed = repo.claim_pending(limit=10) assert pool.acquires == 1 assert len(claimed.drafts) == 1001 assert {row["contract_no"] for row in claimed.drafts} == {"HT-BIG"} assert all(row["process_status"] == 3 for row in claimed.drafts) assert claimed.protocols == [] 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 conn.committed is True