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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user