feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user