Files

398 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""库存原子化服务。
核心规则:
1. 所有库存变动必须走这里的函数,**禁止业务层直接 update Stock**
2. 每个变动包 transaction.atomic + select_for_update 锁行
3. 同步写 StockMovement 流水
4. raise InsufficientStock 当可用库存不足
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from django.db import IntegrityError, transaction
from django.db.models import F
from .models import Stock, StockBatch, StockMovement, Warehouse
class InsufficientStock(Exception):
"""可用库存不足。"""
pass
class WarehouseNotFound(Exception):
"""仓库不存在。"""
@dataclass
class StockChangeResult:
stock: Stock
movement: StockMovement
batch_allocations: list = field(default_factory=list)
def _get_locked(tenant, warehouse, product):
"""select_for_update 取 stock;不存在则创建。返回刷新过的 stock。
**并发安全**:`select_for_update()` 对"不存在的行"无法上锁——两个事务同时
发现无记录时会各自 create,撞唯一约束(PG 实测复现)。
因此创建路径用 `get_or_create` 并捕获 IntegrityError 重取:
竞态失败方在对手提交后重查即可拿到同一行。
"""
stock = (
Stock.objects.select_for_update()
.filter(tenant=tenant, warehouse=warehouse, product=product)
.first()
)
if stock is not None:
return stock
# 创建路径:允许"被并发抢先"——用保存点隔离失败,避免整个外层事务被标记 aborted
try:
with transaction.atomic(): # 保存点:回滚只影响本段
stock, _created = Stock.objects.get_or_create(
tenant=tenant,
warehouse=warehouse,
product=product,
defaults={"on_hand": Decimal("0"), "locked": Decimal("0"),
"avg_cost": Decimal("0")},
)
except IntegrityError:
# 竞态:对手已插入并提交 → 重取(此时行一定存在)
stock = (
Stock.objects.select_for_update()
.filter(tenant=tenant, warehouse=warehouse, product=product)
.first()
)
if stock is None: # 对手回滚了 → 自己创建
stock = Stock.objects.create(
tenant=tenant, warehouse=warehouse, product=product,
on_hand=Decimal("0"), locked=Decimal("0"), avg_cost=Decimal("0"),
)
return stock
def get_or_create_stock(*, tenant, warehouse, product) -> Stock:
"""非事务环境下的便捷版(用于初始化)。"""
stock, _ = Stock.objects.get_or_create(
tenant=tenant,
warehouse=warehouse,
product=product,
defaults={"on_hand": Decimal("0"), "locked": Decimal("0"), "avg_cost": Decimal("0")},
)
return stock
def _get_locked_batch(tenant, warehouse, product, batch_no, defaults: dict):
"""select_for_update 取批次账;不存在则按 defaults 创建。
并发安全同 `_get_locked`:创建路径用 get_or_create + IntegrityError 重取,
避免"首次并发入库同一批次"撞唯一约束。
"""
batch = (
StockBatch.objects.select_for_update()
.filter(tenant=tenant, warehouse=warehouse, product=product, batch_no=batch_no)
.first()
)
if batch is None:
try:
with transaction.atomic(): # 保存点:隔离并发冲突
batch, _created = StockBatch.objects.get_or_create(
tenant=tenant, warehouse=warehouse, product=product,
batch_no=batch_no,
defaults={
"production_date": defaults.get("production_date"),
"expiry_date": defaults.get("expiry_date"),
"on_hand": Decimal("0"),
"locked": Decimal("0"),
"unit_cost": defaults.get("unit_cost", Decimal("0")),
},
)
except IntegrityError:
batch = (
StockBatch.objects.select_for_update()
.filter(tenant=tenant, warehouse=warehouse,
product=product, batch_no=batch_no)
.first()
)
if batch is None:
batch = StockBatch.objects.create(
tenant=tenant, warehouse=warehouse, product=product,
batch_no=batch_no,
production_date=defaults.get("production_date"),
expiry_date=defaults.get("expiry_date"),
on_hand=Decimal("0"), locked=Decimal("0"),
unit_cost=defaults.get("unit_cost", Decimal("0")),
)
else:
# 批次信息允许补录(生产日期/到期日为空时回填)
fill = {}
if batch.production_date is None and defaults.get("production_date"):
fill["production_date"] = defaults["production_date"]
if batch.expiry_date is None and defaults.get("expiry_date"):
fill["expiry_date"] = defaults["expiry_date"]
if fill:
StockBatch.objects.filter(pk=batch.pk).update(**fill)
batch.refresh_from_db()
return batch
def inbound(
*,
tenant,
warehouse,
product,
quantity: Decimal,
unit_cost: Decimal = Decimal("0"),
source_type: str = "manual",
source_ref: str = "",
batch_no: str = "",
production_date=None,
expiry_date=None,
) -> StockChangeResult:
"""入库:on_hand += quantity;写流水 + 加权平均成本。
批次管理商品(product.is_batch_managed)必须带 batch_no,同时维护批次账。
"""
if quantity <= 0:
raise ValueError("quantity must be positive")
is_batch = getattr(product, "is_batch_managed", False)
if is_batch and not batch_no:
raise ValueError(f"product {product.code} is batch-managed: batch_no required")
with transaction.atomic():
stock = _get_locked(tenant, warehouse, product)
# 用 F() 表达式做增量 update,避免 race condition
Stock.objects.filter(pk=stock.pk).update(on_hand=F("on_hand") + quantity)
# 加权平均成本
if unit_cost and unit_cost > 0:
old_qty = stock.on_hand
old_cost = stock.avg_cost
new_cost = (
(old_cost * old_qty + unit_cost * quantity) / (old_qty + quantity)
if (old_qty + quantity) > 0
else unit_cost
)
Stock.objects.filter(pk=stock.pk).update(avg_cost=new_cost)
# 刷新 in-memory
stock.refresh_from_db()
movement = StockMovement.objects.create(
tenant=tenant,
warehouse=warehouse,
product=product,
movement_type=StockMovement.MOVEMENT_INBOUND,
source_type=source_type,
source_ref=source_ref,
quantity=quantity,
unit_cost=unit_cost,
)
batch_allocations = []
if is_batch:
if expiry_date is None and product.shelf_life_days:
base = production_date or date.today()
expiry_date = base + __import__("datetime").timedelta(
days=product.shelf_life_days
)
batch = _get_locked_batch(
tenant, warehouse, product, batch_no,
defaults={
"production_date": production_date,
"expiry_date": expiry_date,
"unit_cost": unit_cost,
},
)
StockBatch.objects.filter(pk=batch.pk).update(
on_hand=F("on_hand") + quantity,
unit_cost=unit_cost if unit_cost > 0 else batch.unit_cost,
)
batch.refresh_from_db()
batch_allocations.append({"batch_no": batch_no, "quantity": str(quantity)})
return StockChangeResult(
stock=stock, movement=movement, batch_allocations=batch_allocations
)
def _fefo_allocate(tenant, warehouse, product, quantity: Decimal) -> list:
"""批次商品出库分摊:FEFO(近效期优先,空到期日最后,再按先入先出 id)。
返回 [{batch_no, quantity, expiry_date}];总量不足抛 InsufficientStock。
调用方须处于事务中。
"""
batches = list(
StockBatch.objects.select_for_update()
.filter(tenant=tenant, warehouse=warehouse, product=product)
.order_by(F("expiry_date").asc(nulls_last=True), "id")
)
remaining = quantity
allocations = []
for b in batches:
if remaining <= 0:
break
available = b.on_hand - b.locked
if available <= 0:
continue
take = min(available, remaining)
allocations.append({"batch": b, "take": take})
remaining -= take
if remaining > 0:
total_available = sum((b.on_hand - b.locked) for b in batches)
raise InsufficientStock(
f"insufficient batch stock for {warehouse.code}/{product.code}: "
f"available={total_available}, requested={quantity}"
)
return allocations
def outbound(
*,
tenant,
warehouse,
product,
quantity: Decimal,
source_type: str = "manual",
source_ref: str = "",
) -> StockChangeResult:
"""出库:available = on_hand - locked 必须 >= quantity;on_hand -= quantity。
批次管理商品自动按 FEFO 分摊到批次账(近效期优先)。
"""
if quantity <= 0:
raise ValueError("quantity must be positive")
is_batch = getattr(product, "is_batch_managed", False)
with transaction.atomic():
stock = _get_locked(tenant, warehouse, product)
available = stock.on_hand - stock.locked
if available < quantity:
raise InsufficientStock(
f"insufficient stock for {warehouse.code}/{product.code}: "
f"available={available}, requested={quantity}"
)
batch_detail = []
if is_batch:
for alloc in _fefo_allocate(tenant, warehouse, product, quantity):
b = alloc["batch"]
take = alloc["take"]
StockBatch.objects.filter(pk=b.pk).update(on_hand=F("on_hand") - take)
batch_detail.append(
{
"batch_no": b.batch_no,
"quantity": str(take),
"expiry_date": b.expiry_date.isoformat() if b.expiry_date else None,
}
)
Stock.objects.filter(pk=stock.pk).update(on_hand=F("on_hand") - quantity)
stock.refresh_from_db()
movement = StockMovement.objects.create(
tenant=tenant,
warehouse=warehouse,
product=product,
movement_type=StockMovement.MOVEMENT_OUTBOUND,
source_type=source_type,
source_ref=source_ref,
quantity=quantity,
unit_cost=stock.avg_cost,
batch_detail=batch_detail,
)
return StockChangeResult(
stock=stock, movement=movement, batch_allocations=batch_detail
)
def lock(
*,
tenant,
warehouse,
product,
quantity: Decimal,
source_type: str = "manual",
source_ref: str = "",
) -> StockChangeResult:
"""锁定:locked += quantity(不直接出库)。"""
if quantity <= 0:
raise ValueError("quantity must be positive")
with transaction.atomic():
stock = _get_locked(tenant, warehouse, product)
available = stock.on_hand - stock.locked
if available < quantity:
raise InsufficientStock(
f"insufficient available stock for {warehouse.code}/{product.code}: "
f"available={available}, requested={quantity}"
)
Stock.objects.filter(pk=stock.pk).update(locked=F("locked") + quantity)
stock.refresh_from_db()
movement = StockMovement.objects.create(
tenant=tenant,
warehouse=warehouse,
product=product,
movement_type=StockMovement.MOVEMENT_LOCK,
source_type=source_type,
source_ref=source_ref,
quantity=quantity,
)
return StockChangeResult(stock=stock, movement=movement)
def unlock(
*,
tenant,
warehouse,
product,
quantity: Decimal,
source_type: str = "manual",
source_ref: str = "",
) -> StockChangeResult:
"""释放锁定:locked -= quantity。"""
if quantity <= 0:
raise ValueError("quantity must be positive")
with transaction.atomic():
stock = _get_locked(tenant, warehouse, product)
if stock.locked < quantity:
raise InsufficientStock(
f"locked less than unlock for {warehouse.code}/{product.code}: "
f"locked={stock.locked}, requested={quantity}"
)
Stock.objects.filter(pk=stock.pk).update(locked=F("locked") - quantity)
stock.refresh_from_db()
movement = StockMovement.objects.create(
tenant=tenant,
warehouse=warehouse,
product=product,
movement_type=StockMovement.MOVEMENT_UNLOCK,
source_type=source_type,
source_ref=source_ref,
quantity=quantity,
)
return StockChangeResult(stock=stock, movement=movement)
def adjust(
*,
tenant,
warehouse,
product,
new_on_hand: Decimal,
source_type: str = "adjust",
source_ref: str = "",
) -> StockChangeResult:
"""盘点调整:把 on_hand 改成 new_on_hand,差值记一条 adjust 流水。"""
with transaction.atomic():
stock = _get_locked(tenant, warehouse, product)
diff = new_on_hand - stock.on_hand
Stock.objects.filter(pk=stock.pk).update(on_hand=new_on_hand)
stock.refresh_from_db()
movement = StockMovement.objects.create(
tenant=tenant,
warehouse=warehouse,
product=product,
movement_type=StockMovement.MOVEMENT_ADJUST,
source_type=source_type,
source_ref=source_ref,
quantity=abs(diff),
unit_cost=stock.avg_cost,
)
return StockChangeResult(stock=stock, movement=movement)