309 lines
11 KiB
Python
309 lines
11 KiB
Python
# 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。
|
||
#
|
||
# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。
|
||
# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections import OrderedDict
|
||
from contextlib import nullcontext
|
||
from dataclasses import dataclass
|
||
from typing import Callable
|
||
|
||
from cart_add import ( # noqa: F401
|
||
KIND_BUSINESS,
|
||
KIND_OK,
|
||
KIND_SYSTEM,
|
||
AddOutcome,
|
||
)
|
||
from cart_match import CartOffer, match_offer
|
||
from cart_repo import CartRow, clip_reason
|
||
from rpa_oracle import ShiftError
|
||
|
||
Searcher = Callable[[str], list[CartOffer]]
|
||
Adder = Callable[[CartOffer, int], AddOutcome]
|
||
CartReader = Callable[[str, str], int]
|
||
_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED")
|
||
logger = logging.getLogger("rpa_cart")
|
||
INCOMPLETE_MARK = "未加齐"
|
||
|
||
|
||
# 一个加购班次的领取和盖章计数。
|
||
@dataclass
|
||
class CartSummary:
|
||
claimed: int = 0
|
||
added: int = 0
|
||
failed: int = 0
|
||
|
||
|
||
# 选品已经拆定的一行。加这行时不再搜、不再按商家名重对。
|
||
@dataclass
|
||
class KnownLine:
|
||
fid: int
|
||
oms_id: int
|
||
platform_type: int
|
||
supplier_name: str
|
||
bar_code: str
|
||
qty: int
|
||
sku_id: str
|
||
vendor_raw: str
|
||
vendor_norm: str
|
||
stock: float | None
|
||
price: float | None
|
||
raw: dict
|
||
|
||
|
||
def _session(repo):
|
||
"""有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。"""
|
||
session = getattr(repo, "session", None)
|
||
if session is None:
|
||
return nullcontext()
|
||
return session()
|
||
|
||
|
||
def run_cart_batch(
|
||
repo,
|
||
*,
|
||
searchers: dict[int, Searcher],
|
||
adders: dict[int, Adder],
|
||
login_errors: dict[int, str],
|
||
limit: int | None = None,
|
||
platform_types: list[int] | None = None,
|
||
cart_readers: dict[int, CartReader] | None = None,
|
||
) -> CartSummary:
|
||
"""领取待加购行,按行搜仓、加车、盖章。
|
||
|
||
session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。
|
||
登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。
|
||
limit 交给领取层。
|
||
"""
|
||
audits: list[ShiftError] = []
|
||
readers = cart_readers or {}
|
||
with _session(repo):
|
||
rows = repo.claim_pending(limit=limit, platform_types=platform_types)
|
||
summary = CartSummary(claimed=len(rows))
|
||
for oms_id, group in _by_oms(rows).items():
|
||
outcomes = [
|
||
(row, *_add_one(row, searchers, adders, login_errors, readers))
|
||
for row in group
|
||
]
|
||
_apply_outcomes(repo, oms_id, outcomes, summary, audits)
|
||
repo.save_shift_errors(audits)
|
||
return summary
|
||
|
||
|
||
def add_known_lines(
|
||
repo,
|
||
lines: list[KnownLine],
|
||
*,
|
||
adders: dict[int, Adder],
|
||
login_errors: dict[int, str],
|
||
cart_readers: dict[int, CartReader] | None = None,
|
||
) -> CartSummary:
|
||
"""按调用方已经拆定的 sku 加车并盖章。不再搜条形码,不再按商家名重对。
|
||
|
||
同一 oms_id 先全部加完再盖章。成功只改戳。业务失败改 status=2。
|
||
同组有成功又有失败时,失败原因带「未加齐」。登录失败把戳放回系统管理员。
|
||
行必须已经是「加购中」,否则盖章 SQL 不会改到它。
|
||
"""
|
||
audits: list[ShiftError] = []
|
||
readers = cart_readers or {}
|
||
summary = CartSummary(claimed=len(lines))
|
||
for oms_id, group in _by_known(lines).items():
|
||
outcomes = [
|
||
(line, *_add_known(line, adders, login_errors, readers)) for line in group
|
||
]
|
||
_apply_outcomes(repo, oms_id, outcomes, summary, audits)
|
||
repo.save_shift_errors(audits)
|
||
return summary
|
||
|
||
|
||
# 按盖章结果改表 2。加车必须发生在这个 session 外面。
|
||
def _apply_outcomes(repo, oms_id, outcomes, summary: CartSummary, audits: list) -> None:
|
||
incomplete = _is_incomplete(outcomes)
|
||
if incomplete:
|
||
logger.warning("加购未加齐 oms_id=%s", oms_id)
|
||
with _session(repo):
|
||
for item, reason, code, text in outcomes:
|
||
if reason is None:
|
||
repo.mark_success(item.fid)
|
||
summary.added += 1
|
||
continue
|
||
if incomplete:
|
||
reason = _incomplete_reason(reason)
|
||
if _retryable(code, reason):
|
||
repo.mark_release(item.fid, reason)
|
||
else:
|
||
repo.mark_fail(item.fid, reason)
|
||
summary.failed += 1
|
||
if code and text:
|
||
audits.append(ShiftError("加购", str(item.fid), code, text))
|
||
|
||
|
||
# 按 oms_id 保序分组。同组先加完再盖章,才能看出有没有加齐。
|
||
def _by_known(lines: list[KnownLine]) -> OrderedDict[int, list[KnownLine]]:
|
||
groups: OrderedDict[int, list[KnownLine]] = OrderedDict()
|
||
for line in lines:
|
||
groups.setdefault(int(line.oms_id), []).append(line)
|
||
return groups
|
||
|
||
|
||
def _add_known(
|
||
line: KnownLine,
|
||
adders: dict[int, Adder],
|
||
login_errors: dict[int, str],
|
||
cart_readers: dict[int, CartReader],
|
||
) -> tuple[str | None, str | None, str | None]:
|
||
"""对一条已经拆定的行加车。返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
|
||
|
||
不看选品当时的库存。库存以商城加车回包为准。购物车里已经不少于本行时直接成功。
|
||
没有加车函数时放回待领,让补偿班再处理。
|
||
"""
|
||
platform = line.platform_type
|
||
if platform in login_errors:
|
||
text = (login_errors[platform] or "").strip()
|
||
logger.warning("加购登录失败 platform=%s: %s", platform, login_errors[platform])
|
||
return _system("LOGIN_FAILED"), ("LOGIN_FAILED" if text else None), (text or None)
|
||
adder = adders.get(platform)
|
||
if adder is None:
|
||
return _system("LOGIN_FAILED"), "LOGIN_FAILED", "未配置加车"
|
||
bar_code = (line.bar_code or "").strip()
|
||
if not bar_code:
|
||
return "缺少条形码", None, None
|
||
qty = int(line.qty)
|
||
if qty <= 0:
|
||
return "采购数量无效", None, None
|
||
reader = cart_readers.get(platform)
|
||
if reader is not None:
|
||
try:
|
||
already = int(reader(bar_code, line.supplier_name) or 0)
|
||
except Exception as exc:
|
||
logger.exception("加购查购物车失败 platform=%s bar_code=%s", platform, bar_code)
|
||
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
|
||
if already >= qty:
|
||
return None, None, None
|
||
offer = CartOffer(
|
||
sku_id=line.sku_id,
|
||
vendor_raw=line.vendor_raw,
|
||
vendor_norm=line.vendor_norm,
|
||
stock=line.stock,
|
||
price=line.price,
|
||
extra={"raw": line.raw} if isinstance(line.raw, dict) else {},
|
||
)
|
||
try:
|
||
outcome = adder(offer, qty)
|
||
except Exception as exc:
|
||
logger.exception("加购加车失败 platform=%s fid=%s", platform, line.fid)
|
||
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
|
||
if outcome.kind == KIND_OK:
|
||
return None, None, None
|
||
reason = (outcome.reason or "加购失败").strip()
|
||
if outcome.kind == KIND_SYSTEM:
|
||
return _system(reason), None, None
|
||
return clip_reason(reason), None, None
|
||
|
||
|
||
# 按 oms_id 保序分组。同组先加完再盖章,才能看出有没有加齐。
|
||
def _by_oms(rows: list[CartRow]) -> OrderedDict[int, list[CartRow]]:
|
||
groups: OrderedDict[int, list[CartRow]] = OrderedDict()
|
||
for row in rows:
|
||
groups.setdefault(int(row.oms_id), []).append(row)
|
||
return groups
|
||
|
||
|
||
# 同组里既有成功又有失败,才算这单没加齐。
|
||
def _is_incomplete(outcomes: list[tuple]) -> bool:
|
||
flags = [reason is None for _row, reason, _code, _text in outcomes]
|
||
return any(flags) and not all(flags)
|
||
|
||
|
||
# 失败原因前加上未加齐,已带前缀的不再套一层。
|
||
def _incomplete_reason(reason: str) -> str:
|
||
text = (reason or "").strip()
|
||
if text.startswith(INCOMPLETE_MARK):
|
||
return text
|
||
return f"{INCOMPLETE_MARK}:{text}" if text else INCOMPLETE_MARK
|
||
|
||
|
||
def _retryable(code: str | None, reason: str | None) -> bool:
|
||
"""登录失败和查询失败放回队列。加车失败、对不上仓、库存不足不放。"""
|
||
blob = f"{code or ''} {reason or ''}"
|
||
return any(token in blob for token in _RETRY_CODES)
|
||
|
||
|
||
def _add_one(
|
||
row: CartRow,
|
||
searchers: dict[int, Searcher],
|
||
adders: dict[int, Adder],
|
||
login_errors: dict[int, str],
|
||
cart_readers: dict[int, CartReader],
|
||
) -> tuple[str | None, str | None, str | None]:
|
||
"""对一行查购物车、搜仓并加车。
|
||
|
||
返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
|
||
购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。
|
||
业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。
|
||
"""
|
||
platform = row.platform_type
|
||
if platform in login_errors:
|
||
text = (login_errors[platform] or "").strip()
|
||
logger.warning("加购登录失败 platform=%s: %s", platform, login_errors[platform])
|
||
return _system("LOGIN_FAILED"), ("LOGIN_FAILED" if text else None), (text or None)
|
||
searcher = searchers.get(platform)
|
||
if searcher is None:
|
||
return _system("LOGIN_FAILED"), None, None
|
||
adder = adders.get(platform)
|
||
if adder is None:
|
||
return _system("ADD_FAILED"), None, None
|
||
bar_code = (row.bar_code or "").strip()
|
||
if not bar_code:
|
||
return "缺少条形码", None, None
|
||
qty = int(row.purchase_qty)
|
||
if qty <= 0:
|
||
return "采购数量无效", None, None
|
||
reader = cart_readers.get(platform)
|
||
if reader is not None:
|
||
try:
|
||
already = int(reader(bar_code, row.supplier_name) or 0)
|
||
except Exception as exc:
|
||
logger.exception("加购查购物车失败 platform=%s bar_code=%s", platform, bar_code)
|
||
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
|
||
if already >= qty:
|
||
return None, None, None
|
||
try:
|
||
offers = searcher(bar_code)
|
||
except Exception as exc:
|
||
logger.exception("加购查询失败 platform=%s bar_code=%s", platform, bar_code)
|
||
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
|
||
offer = match_offer(offers, row.supplier_name)
|
||
if offer is None:
|
||
return "未找到对应仓", None, None
|
||
if offer.stock is not None and offer.stock < qty:
|
||
return "库存不足", None, None
|
||
try:
|
||
outcome = adder(offer, qty)
|
||
except Exception as exc:
|
||
logger.exception("加购加车失败 platform=%s fid=%s", platform, row.fid)
|
||
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
|
||
if outcome.kind == KIND_OK:
|
||
return None, None, None
|
||
reason = (outcome.reason or "加购失败").strip()
|
||
if outcome.kind == KIND_SYSTEM:
|
||
return _system(reason), None, None
|
||
return clip_reason(reason), None, None
|
||
|
||
|
||
# 取出异常原文。空串不当审计正文。
|
||
def _text(exc: BaseException) -> str | None:
|
||
text = str(exc).strip()
|
||
return text or None
|
||
|
||
|
||
# 把系统故障收成「系统故障:短码」并截断。已带前缀的不再套一层。
|
||
def _system(detail: str) -> str:
|
||
text = (detail or "未知错误").strip()
|
||
if text.startswith("系统故障"):
|
||
return clip_reason(text)
|
||
return clip_reason(f"系统故障:{text}")
|