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
+29 -1
View File
@@ -25,6 +25,7 @@ from app.models.catalog import (
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemReceiptAllocation,
ItemTermsheet,
LineItem,
LineItemProjects,
@@ -37,6 +38,7 @@ from app.models.procurement import (
Termsheet,
TermsheetScheduleStep,
)
from app.models.inventory import Warehouse
from app.services.eav_service import flatten_eav_values
from app.utils.serialization import to_date, to_iso
@@ -307,6 +309,7 @@ class _LineItemData:
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
self.decisions: dict[uuid.UUID, ItemDecision] = {}
self.receipts_by_decision: dict[uuid.UUID, list[ItemReceipt]] = {}
self.allocations_by_receipt: dict[uuid.UUID, list[dict]] = {}
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
@@ -380,6 +383,26 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
by_dec.setdefault(r.item_decision_id, []).append(r)
data.receipts_by_decision = by_dec
# Warehouse allocations per receipt — needed by the ledger's
# «رسید شده» split (target warehouse per received part).
alloc_rows = (
await db.execute(
select(ItemReceiptAllocation, Warehouse)
.join(Warehouse, Warehouse.id == ItemReceiptAllocation.warehouse_id)
.where(
ItemReceiptAllocation.receipt_id.in_([r.id for r in receipts])
if receipts
else False
)
)
).all()
allocs_by_receipt: dict[uuid.UUID, list[dict]] = {}
for alloc, wh in alloc_rows:
allocs_by_receipt.setdefault(alloc.receipt_id, []).append(
{"warehouse_id": str(wh.id), "warehouse_name": wh.name, "quantity": float(alloc.quantity)}
)
data.allocations_by_receipt = allocs_by_receipt
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
data.units = {u.id: u for u in units}
@@ -456,15 +479,20 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
if "receipt" in expand:
# 1:N partial deliveries — emit the aggregate (sum of received qty,
# latest receipt kept for the received_at contract).
# latest receipt kept for the received_at contract) + the warehouse
# allocations so the ledger can render the «رسید شده» split.
rs = data.receipts_by_decision.get(decision.id) if decision is not None else None
if rs:
latest = rs[-1]
total = sum((r.received_qty for r in rs), Decimal("0"))
allocs: list[dict] = []
for r in rs:
allocs.extend(data.allocations_by_receipt.get(r.id, []))
d["receipt"] = {
**receipt_brief(latest),
"received_qty": float(total),
"receipt_count": len(rs),
"allocations": allocs,
}
else:
d["receipt"] = None
+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,