284 lines
10 KiB
Python
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")
|