Files
ERP/backend/app/services/inventory_service.py
ali_mohaghegh ca84d8664e feat(backend): demo seeder, receipt allocations in expand, FOUND ceiling guard
- tools/seed.py: wipes app tables and seeds a clean realistic demo (3
  projects x 2 warehouses, 4 suppliers, 12 materials across all 8 topic
  categories, 4 quotes covering offered / in-transit / partial / completed
  with receipts + matching stock); user roles bound to Casdoor identities
  by username (seed_casdoor.py first)
- expand_service: the line-item receipt aggregate now carries the warehouse
  allocations (target warehouse per received part) for the ledger's
  «رسید شده» split
- inventory_service: FOUND («پیدا شده») is guarded so recovery cannot exceed
  the missing balance for the same (warehouse, material, brand, specs) —
  422 with the remaining balance; STOCK_COUNT semantics unchanged (absolute
  counted qty, auto-diff)
2026-09-02 18:15:11 +03:30

330 lines
12 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.
"""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 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)
# 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:
# 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,
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")