# 选品判定 → 表 2 行 + 表 1 deal_flag。 # # 成功按拆出行落 status=1。凑不齐不落成功行。 # 系统故障原因列写短码,不写异常原文。 from __future__ import annotations from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from typing import Any from mall_config import MALL_PLATFORM_TYPE, YAOEX_MALL_NAME, YBM_MALL_NAME from pipeline import KIND_BUSINESS_FAIL, KIND_SUCCESS, KIND_SYSTEM_FAIL, SelectionResult from rules import Allocation, PurchaseLine OPERATE_TYPE_ADD_CART = 1 STATUS_OK = 1 STATUS_FAIL = 2 PUSH_NOT_SENT = 0 DEAL_OK = 1 DEAL_FAIL = 2 PLACEHOLDER_SUPPLIER = "无符合规则供应商" DEAL_RESULT_MAX = 200 FAIL_REASON_MAX = 500 MALL_REPORT_ORDER = (YBM_MALL_NAME, YAOEX_MALL_NAME) PEER_UNAVAILABLE = "系统故障:对端平台不可用,合计库存不足" # 一行选品要写入表 1 / 表 2 的结果。 @dataclass class SelectResult: oms_id: int deal_flag: int deal_result: str cart_rows: list[dict[str, Any]] # 金额四舍五入到四位小数。 def money4(value: object) -> Decimal: return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectResult: """把判定收成表 2 行和表 1 的 deal_flag / deal_result。缺 oms_id 则失败。 LOGIN_FAILED / QUERY_FAILED 且凑不齐时 deal_flag 改回 0,不写表 2。 库存不足、毛利、效期仍写 deal_flag=2。 """ if purchase.oms_id is None: raise ValueError("选品落表需要 oms_id") if selection.kind == KIND_SUCCESS: rows = [_success_row(purchase, item) for item in selection.allocations] _set_multi_flag(rows) malls = _malls_in_rows(rows) return SelectResult( oms_id=purchase.oms_id, deal_flag=DEAL_OK, deal_result=_clip_deal_result(f"选品成功:{'、'.join(malls)}"), cart_rows=rows, ) malls = selection.malls or list(MALL_PLATFORM_TYPE) if selection.kind == KIND_SYSTEM_FAIL: detail = "; ".join(selection.mall_errors.values()) or "未知错误" return SelectResult( oms_id=purchase.oms_id, deal_flag=0, deal_result=_clip_deal_result(f"系统故障:{detail}"), cart_rows=[], ) else: reason = _business_fail_reason(selection.reason) rows = [_fail_row(purchase, mall, reason) for mall in malls] deal_result = _clip_deal_result(reason) _set_multi_flag(rows) return SelectResult( oms_id=purchase.oms_id, deal_flag=DEAL_FAIL, deal_result=deal_result, cart_rows=rows, ) # 成功行出现两个 platform_type 时 multi_platform_flag=1。 def _set_multi_flag(rows: list[dict[str, Any]]) -> None: types = {row["platform_type"] for row in rows} flag = 1 if len(types) > 1 else 0 for row in rows: row["multi_platform_flag"] = flag # 从表 2 行还原平台名,药帮忙在前。 def _malls_in_rows(rows: list[dict[str, Any]]) -> list[str]: types = {row["platform_type"] for row in rows} names: list[str] = [] for mall in MALL_REPORT_ORDER: if MALL_PLATFORM_TYPE[mall] in types: names.append(mall) for mall, platform_type in MALL_PLATFORM_TYPE.items(): if platform_type in types and mall not in names: names.append(mall) return names # 按拆出仓构造一条 status=1 的表 2 行。 def _success_row(purchase: PurchaseLine, item: Allocation) -> dict[str, Any]: price = money4(item.candidate.price) qty = item.qty row = _base_row(purchase, item.candidate.mall_name) row.update( { "supplier_name": item.candidate.vendor_raw or PLACEHOLDER_SUPPLIER, "purchase_price": price, "purchase_qty": qty, "purchase_total": money4(price * qty), "status": STATUS_OK, "fail_reason": None, } ) return row # 构造一条 status=2 的表 2 行,供应商用占位名。 def _fail_row(purchase: PurchaseLine, mall_name: str, reason: str) -> dict[str, Any]: price = _fail_price(purchase) qty = _qty(purchase) row = _base_row(purchase, mall_name) row.update( { "supplier_name": PLACEHOLDER_SUPPLIER, "purchase_price": price, "purchase_qty": qty, "purchase_total": money4(price * qty), "status": STATUS_FAIL, "fail_reason": _clip(reason, FAIL_REASON_MAX), } ) return row # 表 2 公共字段。未知商城名直接失败。 def _base_row(purchase: PurchaseLine, mall_name: str) -> dict[str, Any]: if mall_name not in MALL_PLATFORM_TYPE: raise ValueError(f"未知商城:{mall_name}") return { "operate_type": OPERATE_TYPE_ADD_CART, "oms_id": purchase.oms_id, "platform_type": MALL_PLATFORM_TYPE[mall_name], "bar_code": purchase.bar_code, "org_code": purchase.org_code or None, "push_status": PUSH_NOT_SENT, } # 失败行采购量:计划量,缺或非正则 1。 def _qty(purchase: PurchaseLine) -> int: qty = purchase.qty if qty is None or qty <= 0: return 1 return int(qty) # 失败行单价用销售价;没有则 0.0100。 def _fail_price(purchase: PurchaseLine) -> Decimal: if purchase.sale_price is not None and purchase.sale_price > 0: return money4(purchase.sale_price) return money4("0.0100") _BUSINESS_PREFIX = "无符合规则商品," # 把规则短码收成表 1/表 2 可读的业务失败原因。多个短码都写上。 def _business_fail_reason(code: str) -> str: parts = [part for part in code.split(";") if part] if len(parts) <= 1: return _one_business_fail_reason(code) tails = [_reason_tail(_one_business_fail_reason(part)) for part in parts] return _BUSINESS_PREFIX + ";".join(tails) # 单个短码对应的文案。 def _one_business_fail_reason(code: str) -> str: mapping = { "NO_HIT": "无符合规则商品,平台无命中", "NO_BAR_CODE": "缺少条形码,无法检索平台", "NO_SALE_PRICE": "缺少销售价格", "NO_QTY": "缺少计划采购量", "STOCK_INSUFFICIENT": "无符合规则商品,库存不足", "MARGIN_LT_4PCT": "无符合规则商品,毛利不大于 4%", "EXPIRY_LT_12M": "无符合规则商品,效期不足 12 个月", "EXPIRY_UNPARSEABLE": "无符合规则商品,效期不足 12 个月", "NOT_SELF_WAREHOUSE": "无符合规则商品,非自营仓", "NO_CANDIDATE_AFTER_RULES": "无符合规则商品", } if code in mapping: return mapping[code] if code: return f"{_BUSINESS_PREFIX}{code}" return "无符合规则商品" # 拼多条原因时去掉重复的前缀。 def _reason_tail(text: str) -> str: if text.startswith(_BUSINESS_PREFIX): return text[len(_BUSINESS_PREFIX) :] return text # 本平台有错误短码则带上;没有则写对端不可用。 def _system_fail_reason(mall: str, mall_errors: dict[str, str]) -> str: if mall in mall_errors: return _clip(f"系统故障:{mall_errors[mall]}", FAIL_REASON_MAX) return PEER_UNAVAILABLE # 按字符截断,超出时末尾换成省略号。 def _clip(text: str, max_len: int) -> str: text = text.strip() if len(text) <= max_len: return text return text[: max_len - 1] + "…" # 表 1 deal_result 是 VARCHAR2(200 BYTE) + ZHS16GBK,按字节截。 def _clip_deal_result(text: str) -> str: return _clip_encoded(text, DEAL_RESULT_MAX, "gbk") # 按指定编码的字节数截断,避免切断多字节字符。 def _clip_encoded(text: str, max_bytes: int, encoding: str) -> str: text = text.strip() raw = text.encode(encoding, errors="replace") if len(raw) <= max_bytes: return text ellipsis = "…" ell = ellipsis.encode(encoding, errors="replace") budget = max(0, max_bytes - len(ell)) cut = raw[:budget] while cut: try: return cut.decode(encoding) + ellipsis except UnicodeDecodeError: cut = cut[:-1] return ellipsis if len(ell) <= max_bytes else ""