AutoRpa/rpa_rebate/repo.py

588 lines
21 KiB
Python
Raw Permalink 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.

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