334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Inventory business logic — applying transactions to stock quantities.
|
||
|
||
Merged from the software team's shipped inventory-dashboard fork, with the
|
||
v2.6 concurrency fix: every read-modify-write of `inventory.quantity`
|
||
locks the row with SELECT ... FOR UPDATE so concurrent PM entries cannot
|
||
lose updates.
|
||
|
||
Single source of truth for how each transaction type changes inventory:
|
||
|
||
- PURCHASE / RETURN / FOUND / TRANSFER_IN → increase (find-or-create row)
|
||
- WITHDRAWAL / DAMAGE / WASTE / LOSS / TRANSFER_OUT → decrease (row must exist,
|
||
quantity must be sufficient)
|
||
- STOCK_COUNT → records physical count; difference vs. recorded is applied
|
||
(increase / decrease / no difference) and logged as the transaction quantity.
|
||
- TRANSFER_OUT additionally creates the linked TRANSFER_IN row in the
|
||
destination warehouse (shared transfer_pair_id).
|
||
|
||
Inventory rows are uniquely identified by
|
||
(warehouse_id, material_id, brand_id, specs_hash) where specs_hash is the
|
||
SHA256 of the sorted canonical specs JSON (or 'default' when empty).
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.catalog import Brand, Material, Unit
|
||
from app.models.inventory import (
|
||
DECREASING_TYPES,
|
||
INCREASING_TYPES,
|
||
InventoryItem,
|
||
InventoryTransaction,
|
||
Warehouse,
|
||
)
|
||
from app.schemas.inventory import InventoryTransactionCreate
|
||
from app.utils.errors import AppError, NotFoundError, ValidationError
|
||
|
||
|
||
def canonical_specs_hash(specs: dict | None) -> str:
|
||
"""SHA256 of the sorted canonical specs JSON — 'default' when empty."""
|
||
if not specs:
|
||
return "default"
|
||
canonical = json.dumps(specs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
|
||
|
||
async def get_warehouse_or_404(db: AsyncSession, warehouse_id) -> Warehouse:
|
||
warehouse = await db.get(Warehouse, warehouse_id)
|
||
if warehouse is None:
|
||
raise NotFoundError("انبار یافت نشد", field="warehouse_id")
|
||
return warehouse
|
||
|
||
|
||
async def _find_inventory(
|
||
db: AsyncSession,
|
||
warehouse_id,
|
||
material_id,
|
||
brand_id,
|
||
specs_hash: str,
|
||
for_update: bool = False,
|
||
) -> InventoryItem | None:
|
||
"""Find the inventory row for (warehouse, material, brand, specs).
|
||
|
||
With for_update=True the row is locked with SELECT ... FOR UPDATE so
|
||
concurrent transactions cannot lose updates on quantity.
|
||
"""
|
||
stmt = select(InventoryItem).where(
|
||
InventoryItem.warehouse_id == warehouse_id,
|
||
InventoryItem.material_id == material_id,
|
||
InventoryItem.brand_id.is_(None) if brand_id is None
|
||
else InventoryItem.brand_id == brand_id,
|
||
InventoryItem.specs_hash == specs_hash,
|
||
)
|
||
if for_update:
|
||
stmt = stmt.with_for_update()
|
||
return await db.scalar(stmt)
|
||
|
||
|
||
async def _missing_balance(
|
||
db: AsyncSession,
|
||
warehouse_id,
|
||
material_id,
|
||
brand_id,
|
||
specs_hash: str,
|
||
) -> Decimal:
|
||
"""Remaining missing balance for (warehouse, material, brand, specs):
|
||
Σ|LOSS| − ΣFOUND over the transactions of the matching inventory rows."""
|
||
from sqlalchemy import func
|
||
|
||
rows = (
|
||
await db.execute(
|
||
select(
|
||
InventoryTransaction.transaction_type,
|
||
func.sum(InventoryTransaction.quantity),
|
||
)
|
||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||
.where(
|
||
InventoryItem.warehouse_id == warehouse_id,
|
||
InventoryItem.material_id == material_id,
|
||
InventoryItem.brand_id.is_(None) if brand_id is None
|
||
else InventoryItem.brand_id == brand_id,
|
||
InventoryItem.specs_hash == specs_hash,
|
||
InventoryTransaction.transaction_type.in_(["LOSS", "FOUND"]),
|
||
)
|
||
.group_by(InventoryTransaction.transaction_type)
|
||
)
|
||
).all()
|
||
totals = {t: (abs(q) if q is not None else Decimal("0")) for t, q in rows}
|
||
return totals.get("LOSS", Decimal("0")) - totals.get("FOUND", Decimal("0"))
|
||
|
||
|
||
async def _get_or_create_inventory(
|
||
db: AsyncSession,
|
||
warehouse_id,
|
||
material_id,
|
||
brand_id,
|
||
unit_id: int,
|
||
specs: dict | None,
|
||
specs_hash: str,
|
||
) -> InventoryItem:
|
||
"""Return the inventory row for (warehouse, material, brand, specs), creating it if missing."""
|
||
row = await _find_inventory(db, warehouse_id, material_id, brand_id, specs_hash, for_update=True)
|
||
if row is not None:
|
||
if row.unit_id != unit_id:
|
||
raise ValidationError(
|
||
f"واحد انتخابشده با واحد موجودی فعلی ({row.unit_id}) متفاوت است — از همان واحد استفاده کنید",
|
||
field="unit_id",
|
||
)
|
||
return row
|
||
row = InventoryItem(
|
||
warehouse_id=warehouse_id,
|
||
material_id=material_id,
|
||
brand_id=brand_id,
|
||
quantity=Decimal("0"),
|
||
unit_id=unit_id,
|
||
specs=specs or {},
|
||
specs_hash=specs_hash,
|
||
)
|
||
db.add(row)
|
||
await db.flush()
|
||
return row
|
||
|
||
|
||
async def _validate_refs(db: AsyncSession, payload: InventoryTransactionCreate):
|
||
"""Validate material/brand/unit references before any write."""
|
||
material = await db.get(Material, payload.material_id)
|
||
if material is None:
|
||
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
|
||
brand = None
|
||
if payload.brand_id is not None:
|
||
brand = await db.get(Brand, payload.brand_id)
|
||
if brand is None:
|
||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||
if payload.unit_id is not None:
|
||
unit = await db.get(Unit, payload.unit_id)
|
||
if unit is None:
|
||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||
return material, brand
|
||
|
||
|
||
async def apply_transaction(
|
||
db: AsyncSession,
|
||
payload: InventoryTransactionCreate,
|
||
performed_by: str | None = None,
|
||
) -> list[InventoryTransaction]:
|
||
"""Apply one inventory operation atomically. Returns the created transaction row(s).
|
||
|
||
Caller is responsible for the surrounding transaction (async with db.begin()).
|
||
"""
|
||
await _validate_refs(db, payload)
|
||
await get_warehouse_or_404(db, payload.warehouse_id)
|
||
t = payload.transaction_type
|
||
specs = payload.specs or {}
|
||
specs_hash = canonical_specs_hash(specs)
|
||
tx_date = payload.transaction_date or datetime.now(timezone.utc)
|
||
|
||
# STOCK_COUNT: apply the difference between counted and recorded quantity.
|
||
if t == "STOCK_COUNT":
|
||
row = await _find_inventory(
|
||
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
|
||
)
|
||
if row is None:
|
||
raise ValidationError(
|
||
"برای این ماده هیچ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
|
||
field="material_id",
|
||
)
|
||
recorded = row.quantity
|
||
diff = payload.physical_qty - recorded
|
||
row.quantity = payload.physical_qty
|
||
tx = InventoryTransaction(
|
||
inventory_id=row.id,
|
||
warehouse_id=row.warehouse_id,
|
||
transaction_type="STOCK_COUNT",
|
||
quantity=diff,
|
||
recorded_qty=recorded,
|
||
physical_qty=payload.physical_qty,
|
||
performed_by=performed_by,
|
||
transaction_date=tx_date,
|
||
note=payload.note,
|
||
)
|
||
db.add(tx)
|
||
await db.flush()
|
||
return [tx]
|
||
|
||
quantity = payload.quantity
|
||
if quantity is None or quantity <= 0:
|
||
raise ValidationError("تعداد باید بزرگتر از صفر باشد", field="quantity")
|
||
if payload.unit_id is None:
|
||
raise ValidationError("واحد (unit_id) الزامی است", field="unit_id")
|
||
|
||
created: list[InventoryTransaction] = []
|
||
|
||
# TRANSFER_OUT: source decreases; destination (linked) increases.
|
||
if t == "TRANSFER_OUT":
|
||
source = await _find_inventory(
|
||
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
|
||
)
|
||
if source is None:
|
||
raise ValidationError(
|
||
"برای این ماده در انبار مبدأ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
|
||
field="warehouse_id",
|
||
)
|
||
if source.quantity < quantity:
|
||
raise ValidationError(
|
||
f"موجودی کافی نیست — موجودی فعلی: {source.quantity}",
|
||
field="quantity",
|
||
)
|
||
await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||
source.quantity -= quantity
|
||
|
||
pair_id = uuid.uuid4()
|
||
out_tx = InventoryTransaction(
|
||
inventory_id=source.id,
|
||
warehouse_id=source.warehouse_id,
|
||
transaction_type="TRANSFER_OUT",
|
||
quantity=-quantity,
|
||
transfer_pair_id=pair_id,
|
||
performed_by=performed_by,
|
||
transaction_date=tx_date,
|
||
note=payload.note,
|
||
)
|
||
db.add(out_tx)
|
||
created.append(out_tx)
|
||
|
||
dest = await _get_or_create_inventory(
|
||
db, payload.dest_warehouse_id, payload.material_id, payload.brand_id,
|
||
payload.unit_id, specs, specs_hash,
|
||
)
|
||
dest.quantity += quantity
|
||
in_tx = InventoryTransaction(
|
||
inventory_id=dest.id,
|
||
warehouse_id=dest.warehouse_id,
|
||
transaction_type="TRANSFER_IN",
|
||
quantity=quantity,
|
||
transfer_pair_id=pair_id,
|
||
performed_by=performed_by,
|
||
transaction_date=tx_date,
|
||
note=payload.note,
|
||
)
|
||
db.add(in_tx)
|
||
created.append(in_tx)
|
||
await db.flush()
|
||
return created
|
||
|
||
# Increasing types: find-or-create the row, then add.
|
||
if t in INCREASING_TYPES:
|
||
# FOUND («پیدا شده») ceiling: recovery must not exceed the recorded
|
||
# missing balance for the same (warehouse, material, brand, specs).
|
||
if t == "FOUND":
|
||
missing = await _missing_balance(
|
||
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash
|
||
)
|
||
if quantity > missing:
|
||
raise AppError(
|
||
422,
|
||
"VALIDATION_ERROR",
|
||
f"تعداد پیداشده نمیتواند بیشتر از مقدار مفقودی باشد (ماندهی مفقودی: {missing})",
|
||
field="quantity",
|
||
)
|
||
row = await _get_or_create_inventory(
|
||
db, payload.warehouse_id, payload.material_id, payload.brand_id,
|
||
payload.unit_id, specs, specs_hash,
|
||
)
|
||
row.quantity += quantity
|
||
tx = InventoryTransaction(
|
||
inventory_id=row.id,
|
||
warehouse_id=row.warehouse_id,
|
||
transaction_type=t,
|
||
quantity=quantity,
|
||
performed_by=performed_by,
|
||
delivered_to=payload.delivered_to,
|
||
transaction_date=tx_date,
|
||
note=payload.note,
|
||
)
|
||
db.add(tx)
|
||
await db.flush()
|
||
return [tx]
|
||
|
||
# Decreasing types: row must exist and have enough quantity.
|
||
if t in DECREASING_TYPES:
|
||
row = await _find_inventory(
|
||
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
|
||
)
|
||
if row is None:
|
||
raise ValidationError(
|
||
"برای این ماده موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
|
||
field="material_id",
|
||
)
|
||
if row.quantity < quantity:
|
||
raise ValidationError(
|
||
f"موجودی کافی نیست — موجودی فعلی: {row.quantity}",
|
||
field="quantity",
|
||
)
|
||
row.quantity -= quantity
|
||
tx = InventoryTransaction(
|
||
inventory_id=row.id,
|
||
warehouse_id=row.warehouse_id,
|
||
transaction_type=t,
|
||
quantity=-quantity,
|
||
performed_by=performed_by,
|
||
delivered_to=payload.delivered_to,
|
||
transaction_date=tx_date,
|
||
note=payload.note,
|
||
)
|
||
db.add(tx)
|
||
await db.flush()
|
||
return [tx]
|
||
|
||
raise ValidationError(f"نوع تراکنش نامعتبر است: {t}", field="transaction_type")
|