AutoRpa/rpa_contract/repo.py

351 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 表 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