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