371 lines
13 KiB
Python
371 lines
13 KiB
Python
# 表 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)))
|