170 lines
6.5 KiB
Python
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()
|