# 表 3 领取、表 4 写入。 # # 从表 3 领取 process_status=0 的行改成 3,合单成功后写表 4、表 3 改成 1。 # 进程中途退出时行停在 3,超过租约(process_time 30 分钟)才能再领。 # 跳过协议重录行。不推接口 4。 from __future__ import annotations from contextlib import contextmanager from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors LEASE_MINUTES = 30 _SELECT_BODY = """ SELECT 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, NVL(process_result, '') AS process_result FROM rpa_purchase_contract_data WHERE ( process_status = 0 OR (process_status = 3 AND process_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')) ) AND NOT (NVL(contract_entry_status, -1) = 0 AND contract_no IS NOT NULL AND LENGTH(TRIM(contract_no)) > 0) ORDER BY fid """ _SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED" _SELECT_PENDING_LIMIT = f""" SELECT * FROM ( {_SELECT_BODY} ) WHERE ROWNUM <= :limit FOR UPDATE SKIP LOCKED """ _CLAIM_SQL = """ UPDATE rpa_purchase_contract_data SET process_status = 3, process_time = SYSDATE, update_time = SYSDATE WHERE fid IN ({placeholders}) """ _INSERT_TABLE4_SQL = """ INSERT INTO rpa_contract_agreement_result ( oms_id, action_code, contract_status, contract_fail_reason, contract_no, agreement_status, agreement_fail_reason, agreement_id, agreement_dtl_id, push_status, process_status, create_time, update_time, create_user_id, create_user_name, update_user_id, update_user_name, org_code ) VALUES ( :oms_id, :action_code, :contract_status, :contract_fail_reason, :contract_no, :agreement_status, :agreement_fail_reason, :agreement_id, :agreement_dtl_id, 0, :process_status, SYSTIMESTAMP, SYSTIMESTAMP, 0, '系统管理员', 0, '系统管理员', :org_code ) """ _COMPLETE_SQL = """ UPDATE rpa_purchase_contract_data SET process_status = :process_status, process_result = :process_result, process_time = SYSTIMESTAMP, update_time = SYSTIMESTAMP WHERE fid = :fid AND process_status = 3 """ _TABLE4_BIND_KEYS = ( "oms_id", "action_code", "contract_status", "contract_fail_reason", "contract_no", "agreement_status", "agreement_fail_reason", "agreement_id", "agreement_dtl_id", "process_status", "org_code", ) # 表 3 一行待录或处理中的采购合同。 @dataclass class PurchaseContractRow: fid: int oms_id: int supplier_id: str supplier_name: str contract_sign_date: date | None contract_start_date: date | None contract_end_date: date | None payment_term: int goods_id: str goods_name: str reg_no: str spec: str unit: str qty: Decimal | float | int unit_price: Decimal | float | int purchaser_id: str purchaser_name: str planner_id: str planner_name: str contract_entry_status: int | None contract_no: str | None process_status: int org_code: str | None goods_mdm_code: str | None supplier_mdm_code: str | None process_result: str = "" process_time: datetime | None = None # 判断是否只录协议行:contract_entry_status=0 且已有合同号。 def _is_protocol_rerun(row: PurchaseContractRow) -> bool: if row.contract_entry_status is not None and int(row.contract_entry_status) == 0: return bool((row.contract_no or "").strip()) return False # 判断内存行是否可领:0,或 3 且 process_time 已过租约。协议重录行永不领。 def _is_claimable(row: PurchaseContractRow, now: datetime) -> bool: if _is_protocol_rerun(row): return False if int(row.process_status or 0) == 0: return True if int(row.process_status or 0) == 3 and row.process_time is not None: return now - row.process_time >= timedelta(minutes=LEASE_MINUTES) return False # 回滚当前事务。回滚本身失败则忽略。 def _rollback(conn) -> None: try: conn.rollback() except Exception: pass # 把查询结果行收成 PurchaseContractRow。 def _row_from_record(cols: list[str], rec) -> PurchaseContractRow: data = dict(zip(cols, rec)) return PurchaseContractRow( fid=int(data["fid"]), oms_id=int(data["oms_id"]), supplier_id=data["supplier_id"], supplier_name=data["supplier_name"], contract_sign_date=data["contract_sign_date"], contract_start_date=data["contract_start_date"], contract_end_date=data["contract_end_date"], payment_term=int(data["payment_term"]), goods_id=data["goods_id"], goods_name=data["goods_name"], reg_no=data["reg_no"], spec=data["spec"], unit=data["unit"], qty=data["qty"], unit_price=data["unit_price"], purchaser_id=data["purchaser_id"], purchaser_name=data["purchaser_name"], planner_id=data["planner_id"], planner_name=data["planner_name"], contract_entry_status=data["contract_entry_status"], contract_no=data["contract_no"], process_status=int(data["process_status"]), org_code=data["org_code"], goods_mdm_code=data["goods_mdm_code"], supplier_mdm_code=data["supplier_mdm_code"], process_result=data.get("process_result") or "", ) # 合同班次的内存仓库,测合单和班次编排,不测 SQL。 class MemoryContractRepo: # 用给定的表 3 行初始化。同一 fid 后写覆盖先写。 def __init__(self, rows: list[PurchaseContractRow]): self.table3 = {row.fid: row for row in rows} self.table4: list[dict] = [] self.shift_errors: list[ShiftError] = [] self._next_fid = 1 # 领取待录合同任务,把 process_status 从 0 或过期 3 改成 3。 # # 未过租约的 3 不领。协议重录行不领。limit 为空表示不限制条数。now 只给单测钉住时钟。 def claim_pending( self, limit: int | None = None, *, now: datetime | None = None ) -> list[PurchaseContractRow]: clock = now or datetime.now() pending = sorted( (row for row in self.table3.values() if _is_claimable(row, clock)), key=lambda row: row.fid, ) if limit is not None: pending = pending[:limit] for row in pending: row.process_status = 3 row.process_time = clock return pending # 把合单结果写入内存表 4,并改表 3 的 process_status。 def save_group(self, payload: Any) -> None: for row in payload.table4_rows: item = dict(row) item["fid"] = self._next_fid self._next_fid += 1 self.table4.append(item) for fid in payload.fids: target = self.table3[fid] target.process_status = payload.process_status target.process_result = payload.process_result # 记下未捕获异常原文。不改表 3 / 表 4。 def save_shift_errors(self, items: list[ShiftError]) -> None: self.shift_errors.extend(items) # 合同班次连 Oracle 的仓库,管表 3 领取和表 4 写入。 # # 连接从 rpa_oracle 连接池取。领取戳是 process_status=3。 # 进程退出后行停在 3,超过租约才能再领。 class OracleContractRepo: # 挂上连接池。方法里按次 acquire,班次 session 内复用一条。 def __init__(self, pool): self._pool = pool self._held = None # 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。 @classmethod def from_env(cls) -> "OracleContractRepo": return cls(pool=job_pool()) # 未捕获异常原文进共用审计表。独立事务,失败只记日志。 def save_shift_errors(self, items: list[ShiftError]) -> None: write_shift_errors(self._pool, items) # 班次内借一条连接,领取和落表共用。每笔 save_group 单独提交。 @contextmanager def session(self): if self._held is not None: yield return with self._pool.acquire() as conn: self._held = conn try: yield finally: self._held = None # 有 session 时交出已借连接,否则从池里新借一条。 def _acquire(self): if self._held is not None: # 交出班次已借的那条连接,退出时不归还。 @contextmanager def _held(): yield self._held return _held() return self._pool.acquire() def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]: """领取待录合同任务,把表 3 的 process_status 从 0 改成 3。 使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。 更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。 进程退出后行停在 3,超过租约(process_time)才能再领。 limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。 """ binds: dict = {"lease_minutes": LEASE_MINUTES} try: with self._acquire() as conn: try: with conn.cursor() as cur: if limit is None: cur.execute(_SELECT_PENDING, binds) else: cur.execute( _SELECT_PENDING_LIMIT, {**binds, "limit": int(limit)}, ) cols = [item[0].lower() for item in cur.description] fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()] if fetched: update_by_fid_chunks( cur, _CLAIM_SQL, [row.fid for row in fetched], ) for row in fetched: row.process_status = 3 conn.commit() return fetched except Exception as exc: _rollback(conn) if isinstance(exc, RepoUnavailable): raise raise RepoUnavailable("数据库不可用") from exc except RepoUnavailable: raise except Exception as exc: raise RepoUnavailable("数据库不可用") from exc # 把合单结果写入表 4,并把表 3 的 process_status 改成 1 或 2。 # # 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。 # 某一侧没有行就跳过那一次调用。库故障对外「数据库不可用」,不含 ORA 原文。 def save_group(self, payload: Any) -> None: table4_binds = [ {key: row.get(key) for key in _TABLE4_BIND_KEYS} for row in payload.table4_rows ] complete_binds = [ { "process_status": payload.process_status, "process_result": payload.process_result, "fid": fid, } for fid in payload.fids ] try: with self._acquire() as conn: try: with conn.cursor() as cur: if table4_binds: cur.executemany(_INSERT_TABLE4_SQL, table4_binds) if complete_binds: cur.executemany(_COMPLETE_SQL, complete_binds) conn.commit() except Exception as exc: _rollback(conn) if isinstance(exc, RepoUnavailable): raise raise RepoUnavailable("数据库不可用") from exc except RepoUnavailable: raise except Exception as exc: raise RepoUnavailable("数据库不可用") from exc