AutoRpa/rpa_selection/persist.py

248 lines
8.1 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.

# 选品判定 → 表 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 ""