Files
dealerhub/backend/tests/test_p0_batch.py
T

170 lines
6.5 KiB
Python

"""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()