"""P0 #1 批次/效期管理集成测试。 覆盖:批次入库聚合、FEFO 跨批次分摊、批次不足拒绝、近效期预警去重、 采购带批次入库、非批次商品路径不受影响。 """ import pytest from datetime import date, timedelta from decimal import Decimal from model_bakery import baker from apps.catalog.models import Product from apps.inventory import services as inv_services from apps.inventory.models import Stock, StockBatch, StockMovement from apps.notify.models import Notification, AlertRule from apps.notify import services as notify_services from apps.purchase import services as purchase_services from apps.partner.models import Supplier @pytest.fixture def warehouse(db, tenant): return baker.make(Warehouse := __import__( "apps.inventory.models", fromlist=["Warehouse"] ).Warehouse, tenant=tenant, code="WH01", name="主仓") @pytest.fixture def batch_product(db, tenant): return baker.make( Product, tenant=tenant, code="MILK01", name="鲜牛奶", is_batch_managed=True, shelf_life_days=7, sale_price=Decimal("50"), cost_price=Decimal("30"), ) @pytest.fixture def normal_product(db, tenant): return baker.make(Product, tenant=tenant, code="P001", name="普通商品") def test_batch_inbound_requires_batch_no(tenant, warehouse, batch_product): with pytest.raises(ValueError): inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("10"), ) def test_batch_inbound_updates_batch_and_stock(tenant, warehouse, batch_product): inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("10"), unit_cost=Decimal("30"), batch_no="B20260901", production_date=date(2026, 9, 1), expiry_date=date(2026, 9, 8), ) stock = Stock.objects.get(tenant=tenant, warehouse=warehouse, product=batch_product) batch = StockBatch.objects.get( tenant=tenant, warehouse=warehouse, product=batch_product, batch_no="B20260901" ) assert stock.on_hand == Decimal("10") assert batch.on_hand == Decimal("10") assert batch.expiry_date == date(2026, 9, 8) def test_batch_inbound_auto_expiry_from_shelf_life(tenant, warehouse, batch_product): inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("5"), batch_no="B1", production_date=date(2026, 9, 1), ) batch = StockBatch.objects.get(batch_no="B1") assert batch.expiry_date == date(2026, 9, 1) + timedelta(days=7) def test_outbound_fefo_allocates_nearest_expiry_first(tenant, warehouse, batch_product): inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("10"), batch_no="B_LATE", expiry_date=date(2026, 12, 31), ) inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("10"), batch_no="B_SOON", expiry_date=date(2026, 10, 1), ) result = inv_services.outbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("12"), source_type="sale", source_ref="XS001", ) soon = StockBatch.objects.get(batch_no="B_SOON") late = StockBatch.objects.get(batch_no="B_LATE") assert soon.on_hand == Decimal("0") # 近效期先出清 assert late.on_hand == Decimal("8") assert result.movement.batch_detail == [ {"batch_no": "B_SOON", "quantity": "10.0000", "expiry_date": "2026-10-01"}, {"batch_no": "B_LATE", "quantity": "2.0000", "expiry_date": "2026-12-31"}, ] stock = Stock.objects.get(product=batch_product) assert stock.on_hand == Decimal("8") def test_outbound_batch_insufficient_rejects(tenant, warehouse, batch_product): inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("5"), batch_no="B1", expiry_date=date(2026, 12, 31), ) from apps.inventory.services import InsufficientStock with pytest.raises(InsufficientStock): inv_services.outbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("6"), ) def test_normal_product_unchanged(tenant, warehouse, normal_product): """非批次商品路径回归:无批次账产生,行为与改造前一致。""" inv_services.inbound( tenant=tenant, warehouse=warehouse, product=normal_product, quantity=Decimal("10"), unit_cost=Decimal("5"), ) inv_services.outbound( tenant=tenant, warehouse=warehouse, product=normal_product, quantity=Decimal("4"), ) assert StockBatch.objects.filter(product=normal_product).count() == 0 stock = Stock.objects.get(product=normal_product) assert stock.on_hand == Decimal("6") def test_batch_expiry_alert_dedup(tenant, warehouse, batch_product): AlertRule.objects.create( tenant=tenant, code="exp30", name="近效期30天", rule_type="batch_expiry", threshold=30, is_enabled=True, ) inv_services.inbound( tenant=tenant, warehouse=warehouse, product=batch_product, quantity=Decimal("10"), batch_no="B1", expiry_date=date.today() + timedelta(days=10), ) count1 = notify_services.check_batch_expiry_alerts(tenant) count2 = notify_services.check_batch_expiry_alerts(tenant) # 当日去重 assert count1 == 1 assert count2 == 0 n = Notification.objects.filter( tenant=tenant, extra_data__batch_no__isnull=True ).first() assert Notification.objects.filter(tenant=tenant, category="warning").count() == 1 assert "B1" in n.content def test_run_all_includes_batch_alerts(tenant, warehouse, batch_product): res = notify_services.run_all_alert_checks(tenant) assert "batch_expiry_alerts_count" in res def test_purchase_bill_with_batch_end_to_end(tenant, warehouse, batch_product): supplier = baker.make(Supplier, tenant=tenant, code="S001", name="奶厂") bill = purchase_services.create_purchase_bill( tenant=tenant, supplier=supplier, warehouse=warehouse, lines=[{ "product": batch_product, "quantity": 20, "unit_price": 30, "batch_no": "PB001", "production_date": date.today(), "expiry_date": date.today() + timedelta(days=7), }], ) purchase_services.confirm_purchase_bill(bill) batch = StockBatch.objects.get(batch_no="PB001") assert batch.on_hand == Decimal("20") assert batch.production_date == date.today()