Files
ERP/backend/app/services/inventory_service.py
T

284 lines
10 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 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 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 _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)
# 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=payload.transaction_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=payload.transaction_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=payload.transaction_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:
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,
transaction_date=payload.transaction_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,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
raise ValidationError(f"نوع تراکنش نامعتبر است: {t}", field="transaction_type")