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