AutoRpa/rpa_selection/rules.py

324 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.

# 选品规则:自营仓全称匹配、毛利严格大于 4%、效期不少于 12 个月,按仓序贪心拆量。
#
# 凑齐计划量才给出拆量行。凑不齐时 allocations 为空,调用方不落部分成功。
# 湖南壹号、甘肃壹号云不在自营集合。
from __future__ import annotations
import re
from calendar import monthrange
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Any
# 仓序:级内由近到远。全称不在这张表里的商家不是自营仓。
WAREHOUSE_ORDER = [
"杭州小药药自营旗舰店",
"长沙小药药自营旗舰店",
"湖北小药药自营旗舰店",
"广东壹号药业有限公司",
"昆山亿方药业有限公司",
"湖北亿昊药业有限公司",
"济南小药药自营旗舰店",
"天津壹号药业有限公司",
"重庆亿昊药业有限公司",
"辽宁亿昊药业有限公司",
"陕西壹号药业有限公司",
"河南壹号医药有限公司",
"重庆壹号医药有限公司",
"重庆小药药自营旗舰店",
]
WAREHOUSE_RANK = {name: index for index, name in enumerate(WAREHOUSE_ORDER)}
WAREHOUSE_ALIASES: dict[str, str] = {}
SELF_OPERATED_PREFIX = "[自营]"
MIN_MARGIN = Decimal("0.04")
MIN_EXPIRY_MONTHS = 12.0
DAYS_PER_MONTH = 30.4375
REASON_NOT_SELF = "NOT_SELF_WAREHOUSE"
REASON_MARGIN = "MARGIN_LT_4PCT"
REASON_EXPIRY = "EXPIRY_LT_12M"
REASON_EXPIRY_UNPARSEABLE = "EXPIRY_UNPARSEABLE"
REASON_PRICE_UNPARSEABLE = "PRICE_UNPARSEABLE"
# 一条商城搜索结果:商家、价格、效期、库存。后面按仓序拆量。
@dataclass
class Candidate:
mall_name: str
sku_id: str
name: str
spec: str
price: float | None
expiry_raw: str
expiry_months: float | None
vendor_raw: str
vendor_norm: str
stock: float | None
extra: dict[str, Any] = field(default_factory=dict)
# 表 1 的一条待选品需求。按条形码搜,计划量是 qty。
@dataclass
class PurchaseLine:
approval_no: str
sale_price: float | None
qty: float | None
row_no: int | None = None
generic_name: str = ""
spec: str = ""
manufacturer: str = ""
extra: dict[str, Any] = field(default_factory=dict)
oms_id: int | None = None
bar_code: str = ""
org_code: str = ""
# 没通过自营、毛利或效期的候选,以及原因码。
@dataclass
class Rejected:
candidate: Candidate
reason: str
# 一个仓分到的数量。同仓多条 sku 都留,合计才是该仓吃掉的量。
@dataclass
class Allocation:
candidate: Candidate
qty: int
# 拆量结果。凑齐时 allocations 合计等于计划量;凑不齐时 allocations 为空。
@dataclass
class AllocationResult:
allocations: list[Allocation]
rejected: list[Rejected]
unmatched_vendors: list[str]
passed_before_fold: list[Candidate]
# 去掉「[自营]」前缀和多余空白,收成仓序表里的商家全称。
def normalize_vendor(raw: Any) -> str:
text = "" if raw is None else str(raw)
text = text.replace("\u3000", " ").strip()
if text.startswith(SELF_OPERATED_PREFIX):
text = text[len(SELF_OPERATED_PREFIX) :]
text = " ".join(text.split())
return WAREHOUSE_ALIASES.get(text, text)
# 商家全称是否在自营仓序里。
def is_self_warehouse(vendor_norm: str) -> bool:
return vendor_norm in WAREHOUSE_RANK
# (销售价 - 平台价) / 销售价。用十进制,避免 104 与 99.84 漂成不等于 4%。
def margin(sale_price: float, platform_price: float) -> Decimal:
sale = Decimal(str(sale_price))
return (sale - Decimal(str(platform_price))) / sale
# 毛利必须严格大于 4%。刚好 4% 不通过。
def margin_ok(sale_price: float, platform_price: float) -> bool:
return margin(sale_price, platform_price) > MIN_MARGIN
# 把效期原文收成剩余月数。日期按今天算;「长期」按 120 个月;认不出返回 None。
def parse_expiry_months(raw: Any, today: date | None = None) -> float | None:
text = "" if raw is None else str(raw).strip()
if not text or text in {"-", "—", "/", "无", "null", "None"}:
return None
text = text.split()[0].strip()
today = today or date.today()
if "长期" in text:
return 120.0
chinese = re.fullmatch(r"(\d{4})年(\d{1,2})月(?:(\d{1,2})日?)?", text)
if chinese:
year, month = int(chinese.group(1)), int(chinese.group(2))
day = int(chinese.group(3) or 0)
return remaining_months(today, _safe_date(year, month, day))
if "月" in text:
matched = re.search(r"(\d+(?:\.\d+)?)", text)
if matched is None:
return None
return float(matched.group(1))
dotted = re.fullmatch(r"(\d{4})[./-](\d{1,2})(?:[./-](\d{1,2}))?", text)
if dotted:
year, month = int(dotted.group(1)), int(dotted.group(2))
day = int(dotted.group(3) or 0)
return remaining_months(today, _safe_date(year, month, day))
if re.fullmatch(r"\d{13}", text):
expiry_date = datetime.fromtimestamp(int(text) / 1000).date()
return remaining_months(today, expiry_date)
if re.fullmatch(r"\d{8}", text):
parsed = datetime.strptime(text, "%Y%m%d").date()
return remaining_months(today, parsed)
if re.fullmatch(r"\d{6}", text):
parsed = datetime.strptime(text, "%Y%m")
last_day = monthrange(parsed.year, parsed.month)[1]
return remaining_months(today, date(parsed.year, parsed.month, last_day))
if re.fullmatch(r"\d+(?:\.\d+)?", text):
value = float(text)
if value > 240:
return None
return value
expiry_date = _parse_expiry_date(text)
if expiry_date is None:
return None
return remaining_months(today, expiry_date)
# 只有年月、或日超出当月时,落到该月最后一天。
def _safe_date(year: int, month: int, day: int) -> date:
last_day = monthrange(year, month)[1]
if day <= 0 or day > last_day:
day = last_day
return date(year, month, day)
# 从今天到失效日还剩几个月。日差按 30.4375 天折成月。
def remaining_months(today: date, expiry: date) -> float:
months = (expiry.year - today.year) * 12 + (expiry.month - today.month)
months += (expiry.day - today.day) / DAYS_PER_MONTH
return months
# 解析 YYYY-MM-DD 或 YYYY-MM。只有年月时用该月最后一天。
def _parse_expiry_date(text: str) -> date | None:
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
for fmt in ("%Y-%m", "%Y/%m"):
try:
parsed = datetime.strptime(text, fmt)
last_day = monthrange(parsed.year, parsed.month)[1]
return date(parsed.year, parsed.month, last_day)
except ValueError:
continue
return None
# 需求本身能不能选:要有销售价、计划量和条形码。过了返回 None。
def validate_purchase(purchase: PurchaseLine) -> str | None:
if purchase.sale_price is None or purchase.sale_price <= 0:
return "NO_SALE_PRICE"
if purchase.qty is None or purchase.qty <= 0:
return "NO_QTY"
if not purchase.bar_code:
return "NO_BAR_CODE"
return None
# 单条候选过不过:自营仓、价格能解析、毛利严格大于 4%、效期不少于 12 个月。过了返回 None。
def evaluate_candidate(
candidate: Candidate,
purchase: PurchaseLine,
today: date | None = None,
) -> str | None:
if not is_self_warehouse(candidate.vendor_norm):
return REASON_NOT_SELF
if candidate.price is None:
return REASON_PRICE_UNPARSEABLE
if purchase.sale_price is None or purchase.sale_price <= 0:
return "NO_SALE_PRICE"
if not margin_ok(purchase.sale_price, candidate.price):
return REASON_MARGIN
expiry_months = candidate.expiry_months
if expiry_months is None:
expiry_months = parse_expiry_months(candidate.expiry_raw, today=today)
if expiry_months is None:
return REASON_EXPIRY_UNPARSEABLE
if expiry_months < MIN_EXPIRY_MONTHS:
return REASON_EXPIRY
return None
# 库存向下取整。没有或非正数当 0,不参与拆量。
def stock_units(stock: float | None) -> int:
if stock is None or stock <= 0:
return 0
return int(stock)
# 计划采购量向下取整。没有或非正数当 0。
def plan_qty(purchase: PurchaseLine) -> int:
qty = purchase.qty
if qty is None or qty <= 0:
return 0
return int(qty)
# 按仓序贪心拆计划量。同仓多个 sku 都参与,低价先吃。
#
# 凑齐才返回拆量行,合计等于计划量。凑不齐清空 allocations,不落部分成功。
def allocate(
candidates: list[Candidate],
purchase: PurchaseLine,
today: date | None = None,
) -> AllocationResult:
rejected: list[Rejected] = []
passed: list[Candidate] = []
unmatched: list[str] = []
seen_unmatched: set[str] = set()
for candidate in candidates:
if not is_self_warehouse(candidate.vendor_norm):
raw = candidate.vendor_raw
if raw and raw not in seen_unmatched:
unmatched.append(raw)
seen_unmatched.add(raw)
reason = evaluate_candidate(candidate, purchase, today=today)
if reason is None:
passed.append(candidate)
else:
rejected.append(Rejected(candidate, reason))
folded = _fold_same_warehouse(passed)
folded.sort(key=_sort_key)
remaining = plan_qty(purchase)
lines: list[Allocation] = []
if remaining > 0:
for candidate in folded:
take = min(stock_units(candidate.stock), remaining)
if take <= 0:
continue
lines.append(Allocation(candidate, take))
remaining -= take
if remaining == 0:
break
if remaining != 0:
lines = []
return AllocationResult(
allocations=lines,
rejected=rejected,
unmatched_vendors=unmatched,
passed_before_fold=passed,
)
# 同仓多个 sku 都留下。低价先拆由后面的排序决定,这里不再丢掉贵的那条。
def _fold_same_warehouse(passed: list[Candidate]) -> list[Candidate]:
return list(passed)
# 仓序在前,同仓低价在前,同价库存多的在前。
def _sort_key(candidate: Candidate) -> tuple[int, float, int]:
rank = WAREHOUSE_RANK[candidate.vendor_norm]
price = candidate.price if candidate.price is not None else float("inf")
return (rank, price, -stock_units(candidate.stock))