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)
This commit is contained in:
ali_mohaghegh
2026-09-02 18:15:11 +03:30
parent d56cd13cb9
commit ca84d8664e
3 changed files with 523 additions and 2 deletions
+47 -1
View File
@@ -36,7 +36,7 @@ from app.models.inventory import (
Warehouse,
)
from app.schemas.inventory import InventoryTransactionCreate
from app.utils.errors import NotFoundError, ValidationError
from app.utils.errors import AppError, NotFoundError, ValidationError
def canonical_specs_hash(specs: dict | None) -> str:
@@ -79,6 +79,39 @@ async def _find_inventory(
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,
@@ -233,6 +266,19 @@ async def apply_transaction(
# 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,