AutoRpa/rpa_selection/tests/test_rules.py

265 lines
8.5 KiB
Python

from __future__ import annotations
from datetime import date
from rules import (
Candidate,
PurchaseLine,
WAREHOUSE_ORDER,
allocate,
normalize_vendor,
parse_expiry_months,
validate_purchase,
)
TODAY = date(2026, 9, 14)
HANGZHOU = "杭州小药药自营旗舰店"
CHANGSHA = "长沙小药药自营旗舰店"
GUANGDONG = "广东壹号药业有限公司"
HUNAN = "湖南壹号药业股份有限公司"
GANSU = "甘肃壹号云医药有限责任公司企业店"
def _candidate(**overrides) -> Candidate:
data = {
"mall_name": "1号药城",
"sku_id": "SKU1",
"name": "测试药品",
"spec": "0.25g",
"price": 90.0,
"expiry_raw": "13",
"expiry_months": 13.0,
"vendor_raw": GUANGDONG,
"vendor_norm": GUANGDONG,
"stock": 100.0,
"extra": {},
}
data.update(overrides)
if "vendor_raw" in overrides and "vendor_norm" not in overrides:
data["vendor_norm"] = normalize_vendor(overrides["vendor_raw"])
if "expiry_raw" in overrides and "expiry_months" not in overrides:
data["expiry_months"] = parse_expiry_months(overrides["expiry_raw"], today=TODAY)
return Candidate(**data)
def _purchase(**overrides) -> PurchaseLine:
data = {
"approval_no": "国药准字H20100001",
"sale_price": 100.0,
"qty": 10.0,
"row_no": 2,
}
data.update(overrides)
return PurchaseLine(**data)
def _alloc(candidates, purchase=None):
return allocate(candidates, purchase or _purchase(), today=TODAY)
def _qtys(result):
return [(item.candidate.sku_id, item.qty) for item in result.allocations]
def test_warehouse_order_is_four_tier_mixed_malls():
assert WAREHOUSE_ORDER == [
"杭州小药药自营旗舰店",
"长沙小药药自营旗舰店",
"湖北小药药自营旗舰店",
"广东壹号药业有限公司",
"昆山亿方药业有限公司",
"湖北亿昊药业有限公司",
"济南小药药自营旗舰店",
"天津壹号药业有限公司",
"重庆亿昊药业有限公司",
"辽宁亿昊药业有限公司",
"陕西壹号药业有限公司",
"河南壹号医药有限公司",
"重庆壹号医药有限公司",
"重庆小药药自营旗舰店",
]
# 正好 4% 拒绝。96/100 与 99.84/104 在 Decimal 下都是 0.04。
def test_margin_exactly_4_percent_passes():
result = _alloc([_candidate(price=96.0)])
assert result.allocations == []
assert result.rejected[0].reason == "MARGIN_LT_4PCT"
result = _alloc([_candidate(price=99.84)], _purchase(sale_price=104.0))
assert result.allocations == []
assert result.rejected[0].reason == "MARGIN_LT_4PCT"
def test_margin_just_over_4_percent_passes():
result = _alloc([_candidate(price=95.99)])
assert result.allocations
assert result.allocations[0].qty == 10
def test_margin_3_9_percent_rejected():
result = _alloc([_candidate(price=96.1)])
assert result.allocations == []
assert result.rejected[0].reason == "MARGIN_LT_4PCT"
def test_vendor_not_in_warehouse_list_rejected():
result = _alloc([_candidate(vendor_raw="某第三方药店", vendor_norm="某第三方药店")])
assert result.allocations == []
assert result.rejected[0].reason == "NOT_SELF_WAREHOUSE"
assert "某第三方药店" in result.unmatched_vendors
def test_hunan_and_gansu_are_not_self_warehouses():
result = _alloc(
[
_candidate(sku_id="HN", vendor_raw=HUNAN),
_candidate(sku_id="GS", vendor_raw=GANSU),
]
)
assert result.allocations == []
reasons = {item.reason for item in result.rejected}
assert reasons == {"NOT_SELF_WAREHOUSE"}
def test_self_operated_prefix_normalized():
raw = "[自营] 广东壹号药业有限公司"
result = _alloc([_candidate(vendor_raw=raw)])
assert result.allocations
assert result.allocations[0].candidate.vendor_norm == GUANGDONG
def test_hangzhou_beats_guangdong():
result = _alloc(
[
_candidate(sku_id="GD", vendor_raw=GUANGDONG, mall_name="1号药城", price=90.0),
_candidate(
sku_id="HZ",
vendor_raw=HANGZHOU,
mall_name="药帮忙",
price=94.0,
),
]
)
assert _qtys(result) == [("HZ", 10)]
def test_split_near_warehouse_then_next():
result = _alloc(
[
_candidate(
sku_id="HZ",
vendor_raw=HANGZHOU,
mall_name="药帮忙",
stock=3.0,
),
_candidate(sku_id="GD", vendor_raw=GUANGDONG, stock=20.0),
]
)
assert _qtys(result) == [("HZ", 3), ("GD", 7)]
def test_total_stock_short_returns_no_allocation():
result = _alloc(
[
_candidate(sku_id="HZ", vendor_raw=HANGZHOU, mall_name="药帮忙", stock=4.0),
_candidate(sku_id="GD", vendor_raw=GUANGDONG, stock=5.0),
]
)
assert result.allocations == []
assert result.passed_before_fold
# 同仓两个 sku 都参与拆量。低价先吃,不够再吃同仓另一条,不把余量让给下一仓。
def test_same_warehouse_keeps_both_skus():
result = _alloc(
[
_candidate(sku_id="CHEAP", vendor_raw=HANGZHOU, mall_name="药帮忙", price=90.0, stock=5.0),
_candidate(sku_id="DEAR", vendor_raw=HANGZHOU, mall_name="药帮忙", price=92.0, stock=100.0),
_candidate(sku_id="GD", vendor_raw=GUANGDONG, price=94.0, stock=20.0),
]
)
assert _qtys(result) == [("CHEAP", 5), ("DEAR", 5)]
assert not any(item.reason == "SAME_WAREHOUSE_NOT_BEST" for item in result.rejected)
def test_stock_fraction_floors_to_int():
result = _alloc(
[
_candidate(sku_id="HZ", vendor_raw=HANGZHOU, mall_name="药帮忙", stock=3.9),
_candidate(sku_id="GD", vendor_raw=GUANGDONG, stock=20.0),
]
)
assert _qtys(result) == [("HZ", 3), ("GD", 7)]
def test_none_stock_does_not_fill():
result = _alloc([_candidate(stock=None)], _purchase(qty=10.0))
assert result.allocations == []
assert result.passed_before_fold
def test_expiry_11_months_rejected():
result = _alloc([_candidate(expiry_raw="11", expiry_months=11.0)])
assert result.allocations == []
assert result.rejected[0].reason == "EXPIRY_LT_12M"
def test_expiry_date_one_year_later_passes():
result = _alloc(
[
_candidate(
expiry_raw="2027-09-14",
expiry_months=parse_expiry_months("2027-09-14", today=TODAY),
)
]
)
assert result.allocations
def test_unparseable_expiry_rejected():
result = _alloc([_candidate(expiry_raw="即将到期", expiry_months=None)])
assert result.allocations == []
assert result.rejected[0].reason == "EXPIRY_UNPARSEABLE"
def test_year_month_expiry_uses_last_day_of_month():
september = parse_expiry_months("2027-09", today=TODAY)
august = parse_expiry_months("2027-08", today=TODAY)
assert september is not None and september >= 12.0
assert august is not None and august < 12.0
def test_datetime_string_strips_time():
assert parse_expiry_months("2028-06-16 00:00:00", today=TODAY) >= 12.0
assert parse_expiry_months("2027-11-30 00:00:00", today=TODAY) >= 12.0
def test_dotted_and_chinese_expiry_dates():
assert parse_expiry_months("2027.09.14", today=TODAY) >= 12.0
assert parse_expiry_months("2027.09", today=TODAY) >= 12.0
assert parse_expiry_months("2027年09月14日", today=TODAY) >= 12.0
assert parse_expiry_months("2027年9月", today=TODAY) >= 12.0
assert parse_expiry_months("20270914", today=TODAY) >= 12.0
assert parse_expiry_months("长期", today=TODAY) >= 12.0
def test_eight_digit_date_is_not_treated_as_month_count():
months = parse_expiry_months("20270914", today=TODAY)
assert months is not None
assert 11 < months < 14
def test_gansu_alias_no_longer_maps_into_self_set():
assert normalize_vendor("甘肃壹号云医药有限责任公司") == "甘肃壹号云医药有限责任公司"
def test_validate_purchase_reasons():
assert validate_purchase(_purchase(sale_price=None)) == "NO_SALE_PRICE"
assert validate_purchase(_purchase(sale_price=0)) == "NO_SALE_PRICE"
assert validate_purchase(_purchase(qty=None)) == "NO_QTY"
assert validate_purchase(_purchase(qty=0)) == "NO_QTY"
assert validate_purchase(_purchase(bar_code="")) == "NO_BAR_CODE"
assert validate_purchase(_purchase(bar_code="06901234567890")) is None