# 选品班次:领取表 1 → 按条形码搜平台 → 统一拆量写表 2。 # # 崩在 deal_flag=3:超过租约可再领,未过期需把 3 改回 0。 # 查询失败原因列只写 QUERY_FAILED,原文进 rpa_selection_query_audit。 from __future__ import annotations import logging from dataclasses import dataclass from typing import Callable from persist import STATUS_OK, build_result from pipeline import KIND_BUSINESS_FAIL, MallOutcome, SelectionResult, run_selection from repo import FinishWrite, MappingRow, QueryAudit from rules import Candidate, PurchaseLine, validate_purchase Searcher = Callable[[str], list[Candidate]] logger = logging.getLogger("rpa_selection") # 一个班次的领取和落表计数。 @dataclass class JobSummary: claimed: int = 0 added: int = 0 skipped: int = 0 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, ) -> JobSummary: """领取待选品行,按行搜平台、拆量,一行搜完就落这一行。 每个平台开搜前刷新 deal_time。落库前由仓库再确认仍是 deal_flag=3, 且表 2 还没有该 oms_id。limit 交给领取层。查询失败原文在该行落表后另写。 """ 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) 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, ) ] ) if row_audits: repo.save_query_audits(row_audits) return summary 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