265 lines
8.5 KiB
Python
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
|