baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user