# 表 4 起草行领取、表 3 只录协议行领取、表 4 写入。 # # 表 4 领取时 process_status 从 0 改成 3;表 3 只录协议行同样 0 改成 3。 # 进程中途退出时行停在 3,超过租约(表 4 update_time / 表 3 process_time 30 分钟)才能再领。 # 未过期把表 4 改回 0、表 3 改回 0 再跑。人名固定系统管理员。不推接口 4。 # 同一合同号整组领取,limit 不把一组切开。 from __future__ import annotations from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timedelta from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors from persist import USER_NAME, GroupSave ACTION_CONTRACT = 1 CONTRACT_DRAFT = 1 PROCESS_PENDING = 0 PROCESS_CLAIMING = 3 ENTRY_PROTOCOL_ONLY = 0 LEASE_MINUTES = 30 _SELECT_DRAFTS_BODY = """ SELECT 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 FROM rpa_contract_agreement_result src WHERE action_code = 1 AND contract_status = 1 AND contract_no IS NOT NULL AND LENGTH(TRIM(contract_no)) > 0 AND ( process_status = 0 OR ( process_status = 3 AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE') ) ) AND NOT EXISTS ( SELECT 1 FROM rpa_contract_agreement_result held WHERE held.action_code = 1 AND TRIM(held.contract_no) = TRIM(src.contract_no) AND held.process_status = 3 AND ( held.update_time IS NULL OR held.update_time >= SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE') ) ) ORDER BY fid """ _SELECT_DRAFTS = _SELECT_DRAFTS_BODY + "\nFOR UPDATE SKIP LOCKED" # 起草行按合同号在领取后截组,SQL 不按行 ROWNUM 切开。 _SELECT_DRAFTS_LIMIT = _SELECT_DRAFTS _CLAIM_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result SET process_status = 3, update_user_id = 0, update_user_name = '系统管理员', update_time = SYSTIMESTAMP WHERE fid IN ({placeholders}) """ _SELECT_PROTOCOLS_BODY = """ SELECT fid, oms_id, contract_no, contract_entry_status, process_status, org_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 NVL(contract_entry_status, -1) = 0 AND contract_no IS NOT NULL AND LENGTH(TRIM(contract_no)) > 0 ORDER BY fid """ _SELECT_PROTOCOLS = _SELECT_PROTOCOLS_BODY + "\nFOR UPDATE SKIP LOCKED" _SELECT_PROTOCOLS_LIMIT = f""" SELECT * FROM ( {_SELECT_PROTOCOLS_BODY} ) WHERE ROWNUM <= :limit FOR UPDATE SKIP LOCKED """ _CLAIM_PROTOCOL_SQL = """ UPDATE rpa_purchase_contract_data SET process_status = 3, process_time = SYSTIMESTAMP, update_time = SYSTIMESTAMP WHERE fid IN ({placeholders}) """ _UPDATE_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result SET contract_status = :contract_status, process_status = :process_status, agreement_fail_reason = :agreement_fail_reason, update_user_id = 0, update_user_name = '系统管理员', update_time = SYSTIMESTAMP WHERE fid = :fid AND process_status = 3 """ _RELEASE_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result SET process_status = 0, update_user_id = 0, update_user_name = '系统管理员', update_time = SYSTIMESTAMP WHERE fid = :fid AND process_status = 3 """ _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_PROTOCOL_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", ) _SELECT_CONTRACT_GOODS = """ SELECT fid, oms_id, supplier_id, supplier_name, supplier_mdm_code, planner_id, planner_name, purchaser_name, org_code, goods_id, qty, unit_price, goods_source_id, purchase_tax_rate, contract_start_date, contract_end_date, payment_term FROM rpa_purchase_contract_data WHERE TRIM(contract_no) = :contract_no OR oms_id IN ( SELECT oms_id FROM rpa_contract_agreement_result WHERE TRIM(contract_no) = :contract_no ) ORDER BY fid """ # 一次领取拿到的表 4 起草行和表 3 只录协议行。 @dataclass class Claimed: drafts: list[dict] = field(default_factory=list) protocols: list[dict] = field(default_factory=list) # 合同号去掉空白后非空才算有号。 def _has_contract_no(row: dict) -> bool: return bool((row.get("contract_no") or "").strip()) # 判断是否待查号起草行:action_code=1、contract_status=1、process_status=0、已有合同号。 def is_draft(row: dict) -> bool: if int(row.get("action_code") or 0) != ACTION_CONTRACT: return False if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: return False if int(row.get("process_status") or 0) != PROCESS_PENDING: return False return _has_contract_no(row) # 判断是否待领的只录协议行:process_status=0、contract_entry_status=0、已有合同号。 def is_protocol_only(row: dict) -> bool: if int(row.get("process_status") or 0) != PROCESS_PENDING: return False entry = row.get("contract_entry_status") if entry is None or int(entry) != ENTRY_PROTOCOL_ONLY: return False return _has_contract_no(row) # 合同号去掉空白,作为整组领取的键。 def _contract_key(row: dict) -> str: return (row.get("contract_no") or "").strip() # 领取时间已满租约才算过期。没有时间不算过期。 def _lease_expired(claimed_at: datetime | None, now: datetime) -> bool: if claimed_at is None: return False return now - claimed_at >= timedelta(minutes=LEASE_MINUTES) # 判断同合同号是否仍被未过租约的 process_status=3 占着。 def _is_fresh_hold(row: dict, now: datetime) -> bool: if int(row.get("action_code") or 0) != ACTION_CONTRACT: return False if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: return False if not _has_contract_no(row): return False if int(row.get("process_status") or 0) != PROCESS_CLAIMING: return False return not _lease_expired(row.get("update_time"), now) # 判断内存表 4 行是否可领:process_status=0,或 3 且 update_time 已过租约。 def _is_claimable_draft(row: dict, now: datetime) -> bool: if int(row.get("action_code") or 0) != ACTION_CONTRACT: return False if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: return False if not _has_contract_no(row): return False status = int(row.get("process_status") or 0) if status == PROCESS_PENDING: return True if status == PROCESS_CLAIMING: return _lease_expired(row.get("update_time"), now) return False # 按合同号成组截到 limit。第一组超限也整组拿走,之后装不下的整组留下。 def _take_draft_groups(rows: list[dict], limit: int | None) -> list[dict]: groups: dict[str, list[dict]] = {} order: list[str] = [] for row in sorted(rows, key=lambda item: int(item["fid"])): key = _contract_key(row) if key not in groups: order.append(key) groups[key] = [] groups[key].append(row) chosen: list[dict] = [] for key in order: group = groups[key] if limit is not None and chosen and len(chosen) + len(group) > limit: break chosen.extend(group) return chosen # 判断内存表 3 行是否可领:只录协议且状态 0,或 3 且 process_time 已过租约。 def _is_claimable_protocol(row: dict, now: datetime) -> bool: entry = row.get("contract_entry_status") if entry is None or int(entry) != ENTRY_PROTOCOL_ONLY: return False if not _has_contract_no(row): return False status = int(row.get("process_status") or 0) if status == PROCESS_PENDING: return True if status == PROCESS_CLAIMING: claimed_at = row.get("process_time") if claimed_at is None: return False return now - claimed_at >= timedelta(minutes=LEASE_MINUTES) return False # 回滚当前事务。回滚本身失败则忽略。 def _rollback(conn) -> None: try: conn.rollback() except Exception: pass # 把当前游标结果收成小写列名的字典列表。 def _records(cur) -> list[dict]: cols = [item[0].lower() for item in cur.description] return [dict(zip(cols, rec)) for rec in cur.fetchall()] # 库故障对外「数据库不可用」,已是该异常则原样抛出。 def _wrap_unavailable(exc: BaseException) -> RepoUnavailable: if isinstance(exc, RepoUnavailable): return exc return RepoUnavailable("数据库不可用") # 返利班次的内存仓库,测领取、合组和落表,不测 SQL。 class MemoryRebateRepo: # 用给定的表 4 / 表 3 行初始化。同一 fid 后写覆盖先写。 def __init__( self, table4: list[dict] | None = None, table3: list[dict] | None = None, ): self.table4: dict[int, dict] = { int(row["fid"]): dict(row) for row in (table4 or []) } self.table3: dict[int, dict] = { int(row["fid"]): dict(row) for row in (table3 or []) } self._next_fid = (max(self.table4) + 1) if self.table4 else 1 self.shift_errors: list[ShiftError] = [] # 领取待查号任务,表 4 process_status 改成 3,表 3 只录协议行改成 3。 # # 先领起草行,同一合同号整组进出,limit 有余再领协议行。 # 组内还有未过租约的 3 则整组不领。now 只给单测钉住时钟。 def claim_pending( self, limit: int | None = None, *, now: datetime | None = None ) -> Claimed: clock = now or datetime.now() held = { _contract_key(row) for row in self.table4.values() if _is_fresh_hold(row, clock) } claimable = [ row for row in self.table4.values() if _is_claimable_draft(row, clock) and _contract_key(row) not in held ] drafts = _take_draft_groups(claimable, limit) protocols = sorted( (row for row in self.table3.values() if _is_claimable_protocol(row, clock)), key=lambda row: int(row["fid"]), ) if limit is not None: remaining = limit - len(drafts) if remaining <= 0: protocols = [] else: protocols = protocols[:remaining] for row in drafts: row["process_status"] = PROCESS_CLAIMING row["update_user_id"] = 0 row["update_user_name"] = USER_NAME row["update_time"] = clock for row in protocols: row["process_status"] = PROCESS_CLAIMING row["process_time"] = clock return Claimed(drafts=list(drafts), protocols=list(protocols)) # 把一组查号/录协议结果写入内存表 4,并改表 3 的 process_status。 def save_group(self, payload: GroupSave) -> None: for item in payload.table4_updates: target = self.table4[int(item["fid"])] target["contract_status"] = item["contract_status"] target["process_status"] = item["process_status"] if "agreement_fail_reason" in item: target["agreement_fail_reason"] = item["agreement_fail_reason"] target["update_user_id"] = 0 target["update_user_name"] = USER_NAME for fid in payload.table4_release_fids: target = self.table4[int(fid)] target["process_status"] = PROCESS_PENDING target["update_user_id"] = 0 target["update_user_name"] = USER_NAME for row in payload.table4_inserts: item = dict(row) item["fid"] = self._next_fid self._next_fid += 1 self.table4[item["fid"]] = item for item in payload.table3_updates: target = self.table3[int(item["fid"])] target["process_status"] = item["process_status"] target["process_result"] = item["process_result"] # 记下未捕获异常原文。不改表 3 / 表 4。 def save_shift_errors(self, items: list[ShiftError]) -> None: self.shift_errors.extend(items) # 按合同号从表 3 取货品行;表 4 同号的 oms_id 也会对上。 def load_contract_goods(self, contract_no: str) -> list[dict]: number = (contract_no or "").strip() oms_ids = { int(row["oms_id"]) for row in self.table4.values() if (row.get("contract_no") or "").strip() == number and row.get("oms_id") is not None } return [ dict(row) for row in self.table3.values() if (row.get("contract_no") or "").strip() == number or int(row.get("oms_id") or 0) in oms_ids ] # 返利班次连 Oracle 的仓库,管表 4 / 表 3 领取和表 4 写入。 # # 连接从 rpa_oracle 连接池取。领取戳是表 4 / 表 3 的 process_status=3。 # 进程退出后行停在 3,超过租约才能再领。 class OracleRebateRepo: # 挂上连接池。方法里按次 acquire,班次 session 内复用一条。 def __init__(self, pool): self._pool = pool self._held = None # 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。 @classmethod def from_env(cls) -> "OracleRebateRepo": 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() # 领取待查号任务,表 4 process_status 改成 3,表 3 只录协议行改成 3。 # # 使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。 # 进程退出后行停在 3,超过租约才能再领。 # 先领起草行,同一合同号整组进出,limit 有余再领协议行。没有可领的行时返回空 Claimed。 def claim_pending(self, limit: int | None = None) -> Claimed: draft_binds: dict = {"lease_minutes": LEASE_MINUTES} protocol_binds: dict = {"lease_minutes": LEASE_MINUTES} try: with self._acquire() as conn: try: with conn.cursor() as cur: drafts = self._claim_drafts(cur, limit, draft_binds) remaining = None if limit is None else max(0, int(limit) - len(drafts)) protocols: list[dict] = [] if remaining is None or remaining > 0: protocols = self._claim_protocols(cur, remaining, protocol_binds) conn.commit() return Claimed(drafts=drafts, protocols=protocols) except Exception as exc: _rollback(conn) raise _wrap_unavailable(exc) from exc except RepoUnavailable: raise except Exception as exc: raise RepoUnavailable("数据库不可用") from exc # 按合同号从表 3 取货品行,供总单/细单页面填字段。 # # 表 4 同号的 oms_id 也会对上。空合同号返回空列表。库故障对外「数据库不可用」。 def load_contract_goods(self, contract_no: str) -> list[dict]: number = (contract_no or "").strip() if not number: return [] try: with self._acquire() as conn: try: with conn.cursor() as cur: cur.execute(_SELECT_CONTRACT_GOODS, {"contract_no": number}) return _records(cur) except Exception as exc: raise _wrap_unavailable(exc) from exc except RepoUnavailable: raise except Exception as exc: raise RepoUnavailable("数据库不可用") from exc # 把一组查号/录协议结果写入表 4,并改表 3 的 process_status。 # # 起草更新、释放、插入、表 3 回写各一次 executemany,同一连接同一事务。 # 某一侧没有行就跳过那一次调用。库故障对外「数据库不可用」,不含 ORA 原文。 def save_group(self, payload: GroupSave) -> None: draft_binds = [ { "fid": int(item["fid"]), "contract_status": item["contract_status"], "process_status": item["process_status"], "agreement_fail_reason": item.get("agreement_fail_reason"), } for item in payload.table4_updates ] release_binds = [{"fid": int(fid)} for fid in payload.table4_release_fids] insert_binds = [ {key: row.get(key) for key in _TABLE4_BIND_KEYS} for row in payload.table4_inserts ] protocol_binds = [ { "fid": int(item["fid"]), "process_status": item["process_status"], "process_result": item["process_result"], } for item in payload.table3_updates ] try: with self._acquire() as conn: try: with conn.cursor() as cur: if draft_binds: cur.executemany(_UPDATE_DRAFT_SQL, draft_binds) if release_binds: cur.executemany(_RELEASE_DRAFT_SQL, release_binds) if insert_binds: cur.executemany(_INSERT_TABLE4_SQL, insert_binds) if protocol_binds: cur.executemany(_COMPLETE_PROTOCOL_SQL, protocol_binds) conn.commit() except Exception as exc: _rollback(conn) raise _wrap_unavailable(exc) from exc except RepoUnavailable: raise except Exception as exc: raise RepoUnavailable("数据库不可用") from exc def _claim_drafts(self, cur, limit: int | None, binds: dict) -> list[dict]: """SKIP LOCKED 选出表 4 起草行,按合同号整组改成 process_status=3。 第一组超 limit 仍整组领走。更新按每段 500 个 fid 执行,不在这里提交。 """ cur.execute(_SELECT_DRAFTS, binds) fetched = _take_draft_groups(_records(cur), limit) if not fetched: return [] update_by_fid_chunks( cur, _CLAIM_DRAFT_SQL, [int(row["fid"]) for row in fetched], ) for row in fetched: row["process_status"] = PROCESS_CLAIMING row["update_user_id"] = 0 row["update_user_name"] = USER_NAME return fetched def _claim_protocols(self, cur, limit: int | None, binds: dict) -> list[dict]: """SKIP LOCKED 选出表 3 只录协议行并改成 process_status=3。 更新按每段 500 个 fid 执行,不在这里提交。 """ if limit is None: cur.execute(_SELECT_PROTOCOLS, binds) else: cur.execute(_SELECT_PROTOCOLS_LIMIT, {**binds, "limit": int(limit)}) fetched = _records(cur) if not fetched: return [] update_by_fid_chunks( cur, _CLAIM_PROTOCOL_SQL, [int(row["fid"]) for row in fetched], ) for row in fetched: row["process_status"] = PROCESS_CLAIMING return fetched