184 lines
5.9 KiB
Python
184 lines
5.9 KiB
Python
# 选品班次:领取表 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
|