AutoRpa/rpa_selection/job.py

297 lines
10 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.

# 选品班次:领取表 1 → 按条形码搜平台 → 统一拆量写表 2。
#
# 凑齐在内存里定。传入 cart_finish 时,新插入的成功行标成「加购中」,
# 落库后用这次拆定的 sku 加车,不再去表 2 重捞。
# 崩在 deal_flag=3:超过租约可再领,未过期需把 3 改回 0。
# 查询失败原因列只写 QUERY_FAILED,原文进 rpa_selection_query_audit。
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable
from mall_config import MALL_PLATFORM_TYPE
from persist import DEAL_OK, STATUS_OK, build_result
from pipeline import KIND_BUSINESS_FAIL, MallOutcome, SelectionResult, run_selection
from repo import STAMP_SELECTION, FinishWrite, MappingRow, QueryAudit
from rules import Allocation, Candidate, PurchaseLine, validate_purchase
STAMP_CLAIMING = "加购中"
Searcher = Callable[[str], list[Candidate]]
logger = logging.getLogger("rpa_selection")
# 凑齐后交给加车的一行。sku 和 raw 来自这次搜索,不从表 2 反查。
@dataclass
class CartLine:
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[str, Any] = field(default_factory=dict)
# cart_finish(lines) -> (加购成功行数, 失败行数)。未传入则只落表。
CartFinish = Callable[[list[CartLine]], tuple[int, int]]
# 一个班次的领取和落表计数。
@dataclass
class JobSummary:
claimed: int = 0
added: int = 0
skipped: int = 0
failed: int = 0
cart_added: int = 0
cart_failed: int = 0
# 把表 1 行收成规则层的采购行。缺价格或数量保持 None。
def mapping_to_purchase(row: MappingRow) -> PurchaseLine:
sale_price = float(row.sale_price) if row.sale_price is not None else None
qty = float(row.plan_purchase_qty) if row.plan_purchase_qty is not None else None
return PurchaseLine(
approval_no=row.approval_no or "",
sale_price=sale_price,
qty=qty,
row_no=row.fid,
generic_name=row.goods_name or "",
spec=row.goods_spec or "",
manufacturer=row.manufacturer or "",
oms_id=row.oms_id,
bar_code=(row.bar_code or "").strip(),
org_code=row.org_code or "",
)
def run_batch(
repo,
*,
platforms: list[str],
searchers: dict[str, Searcher],
login_errors: dict[str, str],
limit: int | None = None,
cart_finish: CartFinish | None = None,
) -> JobSummary:
"""领取待选品行,按行搜平台、拆量,一行搜完就落这一行。
每个平台开搜前刷新 deal_time。落库前由仓库再确认仍是 deal_flag=3,
且表 2 还没有该 oms_id。limit 交给领取层。查询失败原文在该行落表后另写。
cart_finish 只在这一行新插入了凑齐的成功行之后调用,参数是这次拆定的 sku。
表 2 已有该 oms_id 时不调用,避免再加一份。
"""
rows = repo.claim_pending(limit=limit)
summary = JobSummary(claimed=len(rows))
for row in rows:
purchase = mapping_to_purchase(row)
def renew(_fid: int = row.fid) -> None:
"""这一行还在搜时,把租约续到现在。"""
touch = getattr(repo, "touch_claim", None)
if touch is not None:
touch(_fid)
selection, row_audits = _select_row(
purchase,
platforms,
searchers,
login_errors,
before_search=renew,
)
result = build_result(purchase, selection)
_count(summary, result.cart_rows)
_mark_cart_stamp(result, cart_finish)
saved = repo.save_results(
[
FinishWrite(
oms_id=result.oms_id,
fid=row.fid,
deal_flag=result.deal_flag,
deal_result=result.deal_result,
cart_rows=result.cart_rows,
)
]
)
fids = list(saved[0]) if saved else []
if cart_finish is not None and result.deal_flag == DEAL_OK:
_finish_cart(summary, cart_finish, purchase, selection, result.cart_rows, fids)
if row_audits:
repo.save_query_audits(row_audits)
return summary
# 要马上加车的成功行先标成加购中,补偿班才不会在加车完成前领走。其余行保持系统管理员。
def _mark_cart_stamp(result, cart_finish: CartFinish | None) -> None:
claim = cart_finish is not None and result.deal_flag == DEAL_OK and bool(result.cart_rows)
for row in result.cart_rows:
if claim and row.get("status") == STATUS_OK:
row["update_user_name"] = STAMP_CLAIMING
else:
row["update_user_name"] = STAMP_SELECTION
# 新插入的凑齐行交给加车。行数对不上就留下加购中,不拿错 sku 去加。
def _finish_cart(
summary: JobSummary,
cart_finish: CartFinish | None,
purchase: PurchaseLine,
selection: SelectionResult,
cart_rows: list[dict],
fids: list[int],
) -> None:
if cart_finish is None or not fids:
return
if purchase.oms_id is None:
return
lines = _cart_lines(purchase, selection.allocations, cart_rows, fids)
if not lines:
logger.error(
"选品加购行数对不上 oms_id=%s allocations=%s fids=%s",
purchase.oms_id,
len(selection.allocations),
len(fids),
)
return
try:
added, failed = cart_finish(lines)
except Exception:
logger.exception("选品后加购未盖章 oms_id=%s", purchase.oms_id)
return
summary.cart_added += int(added)
summary.cart_failed += int(failed)
# 按拆量顺序配上刚插入的 fid。顺序或状态不一致就返回空,调用方不加车。
def _cart_lines(
purchase: PurchaseLine,
allocations: list[Allocation],
cart_rows: list[dict],
fids: list[int],
) -> list[CartLine]:
if len(allocations) != len(fids) or len(cart_rows) != len(fids):
return []
if any(row.get("status") != STATUS_OK for row in cart_rows):
return []
lines: list[CartLine] = []
for item, fid in zip(allocations, fids):
candidate = item.candidate
platform = MALL_PLATFORM_TYPE.get(candidate.mall_name)
if platform is None:
return []
raw = candidate.extra.get("raw") if isinstance(candidate.extra, dict) else None
lines.append(
CartLine(
fid=int(fid),
oms_id=int(purchase.oms_id or 0),
platform_type=int(platform),
supplier_name=candidate.vendor_raw,
bar_code=purchase.bar_code,
qty=int(item.qty),
sku_id=candidate.sku_id,
vendor_raw=candidate.vendor_raw,
vendor_norm=candidate.vendor_norm,
stock=candidate.stock,
price=candidate.price,
raw=raw if isinstance(raw, dict) else {},
)
)
return lines
def _select_row(
purchase: PurchaseLine,
platforms: list[str],
searchers: dict[str, Searcher],
login_errors: dict[str, str],
before_search: Callable[[], None] | None = None,
) -> tuple[SelectionResult, list[QueryAudit]]:
"""校验采购行并搜各平台,交给统一拆量。校验不过不搜。
before_search 在每个平台开搜前调用,用来续租。
"""
row_reason = validate_purchase(purchase)
if row_reason:
return (
SelectionResult(
kind=KIND_BUSINESS_FAIL,
reason=row_reason,
allocations=[],
rejected=[],
malls=list(platforms),
),
[],
)
outcomes: list[MallOutcome] = []
audits: list[QueryAudit] = []
for mall in platforms:
if before_search is not None:
before_search()
outcome, audit = _search_one(purchase, mall, searchers, login_errors)
outcomes.append(outcome)
if audit is not None:
audits.append(audit)
return run_selection(purchase, outcomes), audits
# 搜一个平台。登录失败写 LOGIN_FAILED,查询抛错写 QUERY_FAILED。
#
# 异常原文进审计行,不写进失败原因列。
def _search_one(
purchase: PurchaseLine,
mall: str,
searchers: dict[str, Searcher],
login_errors: dict[str, str],
) -> tuple[MallOutcome, QueryAudit | None]:
login_error = login_errors.get(mall)
if login_error:
logger.warning("选品登录失败 mall=%s: %s", mall, login_error)
return MallOutcome(mall, failed_reason="LOGIN_FAILED"), None
searcher = searchers.get(mall)
if searcher is None:
return MallOutcome(mall, failed_reason="LOGIN_FAILED"), None
try:
candidates = searcher(purchase.bar_code)
except Exception as exc:
logger.exception("选品查询失败 mall=%s bar_code=%s", mall, purchase.bar_code)
return MallOutcome(mall, failed_reason="QUERY_FAILED"), _query_audit(
purchase, mall, exc
)
return MallOutcome(mall, candidates=candidates), None
# 有 oms_id 和表 1 fid 时收成一条审计。缺主键则只留日志。
def _query_audit(
purchase: PurchaseLine, mall: str, exc: Exception
) -> QueryAudit | None:
if purchase.oms_id is None or purchase.row_no is None:
return None
return QueryAudit(
oms_id=purchase.oms_id,
mapping_fid=purchase.row_no,
mall_name=mall,
bar_code=purchase.bar_code,
error_text=str(exc),
)
# 按表 2 行状态累加成功、系统故障、业务跳过。
def _count(summary: JobSummary, cart_rows: list[dict]) -> None:
for row in cart_rows:
if row.get("status") == STATUS_OK:
summary.added += 1
continue
reason = str(row.get("fail_reason") or "")
if reason.startswith("系统故障"):
summary.failed += 1
else:
summary.skipped += 1