AutoRpa/rpa_cart/cart_repo.py

371 lines
13 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.

# 表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。
#
# 领取时盖「加购中」。进程中途退出时行停在这一戳,超过租约(update_time 30 分钟)才能再领。
# 未过期的「加购中」需改回「系统管理员」再跑。
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta
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
OPERATE_TYPE_ADD_CART = 1
STATUS_OK = 1
STATUS_FAIL = 2
PUSH_NOT_SENT = 0
STAMP_SELECTION = "系统管理员"
STAMP_CLAIMING = "加购中"
STAMP_DONE = "加购班"
FAIL_REASON_MAX = 500
LEASE_MINUTES = 30
_CLAIM_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
update_time = SYSDATE
WHERE fid IN ({placeholders})
"""
_SUCCESS_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
fail_reason = NULL,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
_FAIL_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET status = :status,
fail_reason = :fail_reason,
update_user_name = :stamp,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
_RELEASE_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
fail_reason = :fail_reason,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
# 表 2 一行待加购或处理中的结算货品。
@dataclass
class CartRow:
fid: int
oms_id: int
platform_type: int
supplier_name: str
bar_code: str
purchase_qty: int
status: int
fail_reason: str | None
push_status: int
update_user_name: str
operate_type: int = OPERATE_TYPE_ADD_CART
# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。
def clip_reason(text: str) -> str:
text = (text or "").strip()
if len(text) <= FAIL_REASON_MAX:
return text
return text[: FAIL_REASON_MAX - 1] + "…"
# 判断内存行是否可领:系统管理员,或加购中且 update_time 已过租约。
def _is_pending(row: dict[str, Any], now: datetime) -> bool:
stamp = row.get("update_user_name") or STAMP_SELECTION
if (
int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART
or int(row.get("status") or 0) != STATUS_OK
or int(row.get("push_status") or 0) != PUSH_NOT_SENT
):
return False
if stamp == STAMP_SELECTION:
return True
if stamp == STAMP_CLAIMING:
claimed_at = row.get("update_time")
if claimed_at is None:
return False
return now - claimed_at >= timedelta(minutes=LEASE_MINUTES)
return False
# 把字典收成 CartRow。缺数量当 0。
def _row_from_dict(data: dict[str, Any]) -> CartRow:
qty = data.get("purchase_qty")
return CartRow(
fid=int(data["fid"]),
oms_id=int(data["oms_id"]),
platform_type=int(data["platform_type"]),
supplier_name=str(data.get("supplier_name") or ""),
bar_code=str(data.get("bar_code") or ""),
purchase_qty=int(qty) if qty is not None else 0,
status=int(data.get("status") or 0),
fail_reason=data.get("fail_reason"),
push_status=int(data.get("push_status") or 0),
update_user_name=str(data.get("update_user_name") or ""),
operate_type=int(data.get("operate_type") or OPERATE_TYPE_ADD_CART),
)
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass
# 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。
def _pending_select_sql(
*, limit: int | None, platform_types: list[int] | None
) -> tuple[str, dict[str, Any]]:
params: dict[str, Any] = {
"selection_stamp": STAMP_SELECTION,
"claim_stamp": STAMP_CLAIMING,
"lease_minutes": LEASE_MINUTES,
}
filters = [
"operate_type = 1",
"status = 1",
"push_status = 0",
"""(
NVL(update_user_name, :selection_stamp) = :selection_stamp
OR (
update_user_name = :claim_stamp
AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')
)
)""",
]
if platform_types is not None:
if not platform_types:
filters.append("1 = 0")
else:
keys: list[str] = []
for index, platform in enumerate(platform_types):
key = f"p{index}"
keys.append(f":{key}")
params[key] = int(platform)
filters.append(f"platform_type IN ({', '.join(keys)})")
sql = f"""
SELECT fid, oms_id, platform_type, supplier_name, bar_code,
purchase_qty, status, fail_reason, push_status, update_user_name,
operate_type
FROM rpa_cart_settlement_goods_data
WHERE {' AND '.join(filters)}
ORDER BY oms_id, fid
"""
if limit is not None:
params["limit"] = int(limit)
sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit"
return sql + "\nFOR UPDATE SKIP LOCKED", params
# 加购班次的内存仓库,测规则和班次编排,不测 SQL。
class MemoryCartRepo:
# 用给定的表 2 行初始化。同一 fid 后写覆盖先写。
def __init__(self, rows: list[dict[str, Any]]):
self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows}
self.shift_errors: list[ShiftError] = []
# 领取待加购任务,把戳从系统管理员或过期加购中改成加购中。
#
# 未过租约的加购中不领。limit 为空表示不限制条数。now 只给单测钉住时钟。
def claim_pending(
self,
limit: int | None = None,
platform_types: list[int] | None = None,
*,
now: datetime | None = None,
) -> list[CartRow]:
clock = now or datetime.now()
pending = sorted(
(row for row in self.rows.values() if _is_pending(row, clock)),
key=lambda row: (int(row["oms_id"]), int(row["fid"])),
)
if platform_types is not None:
allowed = set(platform_types)
pending = [row for row in pending if int(row["platform_type"]) in allowed]
if limit is not None:
pending = pending[:limit]
claimed: list[CartRow] = []
for row in pending:
row["update_user_name"] = STAMP_CLAIMING
row["update_time"] = clock
claimed.append(_row_from_dict(row))
return claimed
# 成功盖加购班,清失败原因,不改 status。
def mark_success(self, fid: int) -> None:
row = self.rows[fid]
row["update_user_name"] = STAMP_DONE
row["fail_reason"] = None
# 失败改 status=2,写下截断后的原因,戳改成加购班。
def mark_fail(self, fid: int, reason: str) -> None:
row = self.rows[fid]
row["status"] = STATUS_FAIL
row["fail_reason"] = clip_reason(reason)
row["update_user_name"] = STAMP_DONE
def mark_release(self, fid: int, reason: str) -> None:
"""登录或查询失败放回待领。戳改回系统管理员,status 保持 1。"""
row = self.rows[fid]
row["update_user_name"] = STAMP_SELECTION
row["fail_reason"] = clip_reason(reason)
# 记下未捕获异常原文。不改表 2。
def save_shift_errors(self, items: list[ShiftError]) -> None:
self.shift_errors.extend(items)
# 加购班次连 Oracle 的仓库,管表 2 领取和盖章。
#
# 连接从 rpa_oracle 连接池取。领取戳是加购中。
# 进程退出后行停在加购中,超过租约才能再领。
class OracleCartRepo:
# 挂上连接池。方法里按次 acquire,班次 session 内复用一条。
def __init__(self, pool):
self._pool = pool
self._held = None
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
@classmethod
def from_env(cls) -> "OracleCartRepo":
return cls(pool=job_pool())
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
def save_shift_errors(self, items: list[ShiftError]) -> None:
write_shift_errors(self._pool, items)
# 班次内借一条连接,领取和盖章共用。每笔盖章单独提交。
@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, platform_types: list[int] | None = None
) -> list[CartRow]:
"""领取待加购任务,把表 2 的戳从系统管理员改成加购中。
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
进程退出后行停在加购中,超过租约(update_time)才能再领。
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
"""
sql, params = _pending_select_sql(limit=limit, platform_types=platform_types)
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
cur.execute(sql, params)
fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()]
if fetched:
update_by_fid_chunks(
cur,
_CLAIM_SQL,
[row.fid for row in fetched],
{"stamp": STAMP_CLAIMING},
)
for row in fetched:
row.update_user_name = STAMP_CLAIMING
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
# 成功盖加购班。只改仍停在加购中的那一行。
def mark_success(self, fid: int) -> None:
self._execute(
_SUCCESS_SQL,
{"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING},
)
# 失败改 status=2 并写原因。只改仍停在加购中的那一行。
def mark_fail(self, fid: int, reason: str) -> None:
self._execute(
_FAIL_SQL,
{
"fid": int(fid),
"status": STATUS_FAIL,
"fail_reason": clip_reason(reason),
"stamp": STAMP_DONE,
"claim_stamp": STAMP_CLAIMING,
},
)
def mark_release(self, fid: int, reason: str) -> None:
"""登录或查询失败放回待领。只改仍停在加购中的那一行,status 不动。"""
self._execute(
_RELEASE_SQL,
{
"fid": int(fid),
"fail_reason": clip_reason(reason),
"stamp": STAMP_SELECTION,
"claim_stamp": STAMP_CLAIMING,
},
)
# 在当前连接上执行一条 UPDATE 并提交。库故障对外「数据库不可用」。
def _execute(self, sql: str, params: dict[str, Any]) -> None:
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
cur.execute(sql, params)
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
# 把查询结果行收成 CartRow。
def _row_from_cursor(cur, rec) -> CartRow:
cols = [item[0].lower() for item in cur.description]
return _row_from_dict(dict(zip(cols, rec)))