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,
+447
View File
@@ -0,0 +1,447 @@
"""Seed the procurement DB with realistic demo data (v1.3).
Wipes the app tables and inserts a clean, relational dataset:
8 topic categories (already bootstrapped at API startup), base units,
realistic brands/materials/suppliers, 3 projects with 2 warehouses each,
4 quotes covering every lifecycle state (offered / accepted-in-transit /
partially-received / arrived-completed) with termsheets, decisions,
partial receipts + warehouse allocations, and matching inventory rows.
Run AFTER the stack is up (api image has this script + the ORM):
docker compose run --rm api python tools/seed.py
User roles are linked to the Casdoor users by username (razaei = admin,
mousavi = مدیر تدارکات, mohaghegh/tester = مدیر پروژه). The Casdoor org
must be seeded first (python backend/tools/seed_casdoor.py); if a user is
missing there, the row is created with a pending casdoor_id and the role
can be assigned later from the Organization panel.
The existing junk/test rows (random names) are replaced — the dumps in
backups/ should be refreshed after running (see README).
"""
import asyncio
import sys
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import select, text
from app.db.engine import AsyncSessionLocal
from app.db.init_db import BASE_UNITS, TOPIC_CATEGORIES
from app.models.catalog import (
Brand,
BrandMaterial,
Material,
MaterialCategory,
Unit,
)
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemReceiptAllocation,
ItemTermsheet,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
SupplierMaterial,
Termsheet,
TermsheetScheduleStep,
User,
)
NOW = datetime.now(timezone.utc)
def d(days_ago: int) -> datetime:
return NOW - timedelta(days=days_ago)
# ---------------------------------------------------------------------------
# Casdoor user ids (username -> id) — used to bind roles to real identities
# ---------------------------------------------------------------------------
async def casdoor_user_ids() -> dict[str, str]:
import httpx
from app.config import settings
try:
async with httpx.AsyncClient(timeout=15) as client:
login = await client.post(
f"{settings.CASDOOR_ENDPOINT}/api/login",
json={
"type": "login",
"organization": "built-in",
"username": settings.CASDOOR_ADMIN_USER,
"password": settings.CASDOOR_ADMIN_PASSWORD,
"application": "app-built-in",
},
)
if login.status_code != 200 or (login.json() or {}).get("status") != "ok":
print("!! Casdoor admin login failed — roles will use pending ids")
return {}
resp = await client.get(
f"{settings.CASDOOR_ENDPOINT}/api/get-users?owner={settings.CASDOOR_ORGANIZATION}"
)
body = resp.json()
if body.get("status") != "ok":
return {}
return {str(u.get("name")): str(u.get("id")) for u in (body.get("data") or []) if u.get("id")}
except Exception as exc: # noqa: BLE001 — best effort
print(f"!! Casdoor unreachable ({exc}) — roles will use pending ids")
return {}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def get_or_create_unit(db, code: str, name_fa: str, dimension: str, si_factor=1, is_base=False) -> int:
unit = await db.scalar(select(Unit).where(Unit.code == code))
if unit:
return unit.id
unit = Unit(code=code, name_fa=name_fa, dimension=dimension, si_factor=si_factor, is_base=is_base)
db.add(unit)
await db.flush()
return unit.id
async def get_category(db, slug: str) -> MaterialCategory:
cat = await db.scalar(select(MaterialCategory).where(MaterialCategory.slug == slug))
if cat:
return cat
meta = next(c for c in TOPIC_CATEGORIES if c["slug"] == slug)
cat = MaterialCategory(slug=slug, name=meta["name"])
db.add(cat)
await db.flush()
return cat
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
USER_PLAN = [
# (casdoor username, first, last, role)
("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"),
("tester", "کاربر", "تست", "pm"),
]
MATERIALS = [
# (name, category_slug, unit_code, brand_name)
("کابل مسی افشان ۲×۲٫۵", "electrical-equipment-supplies", "m", "کابل البرز"),
("کلید و پریز آرین مدل سفید", "electrical-equipment-supplies", "piece", None),
("میلگرد آجدار A3 سایز ۱۴", "metals-steel-sections", "ton", "ذوب آهن اصفهان"),
("تیرآهن IPE ۱۴", "metals-steel-sections", "ton", "ذوب آهن اصفهان"),
("سیمان تیپ ۲", "construction-materials", "bag", "سیمان تهران"),
("بلوک سیمانی ۲۰×۲۰×۴۰", "construction-materials", "piece", None),
("لوله پلی‌اتیلن PE100 سایز ۱۶", "mechanical-installations-pipes", "m", "پارس لوله"),
("لوله پلی‌اتیلن PE100 سایز ۲۵", "mechanical-installations-pipes", "m", "پارس لوله"),
("چسب بتن", "construction-chemicals", "kg", "شیمی ساختمان"),
("پنجره UPVC دو جداره", "doors-windows-glass", "piece", None),
("سرامیک پرسلان ۶۰×۶۰", "finishing-interior-decoration", "piece", None),
("ویبراتور بتن", "construction-tools-equipment", "piece", None),
]
SUPPLIERS = [
# (name, category_slug, location, phone, contact_person, tax_id)
("شرکت صنایع کابل البرز", "electrical-equipment-supplies", "تهران — شهرک صنعتی", "۰۲۱-۳۳۹۹۱۲۳۴", "مهندس کریمی", "10102345678"),
("فولاد ناب آریا", "metals-steel-sections", "اصفهان — ذوب‌آهن", "۰۳۱-۳۴۵۶۷۸۹۰", "آقای صادقی", "10203456789"),
("پخش لوله و اتصالات پارس", "mechanical-installations-pipes", "تهران — بازار آهن", "۰۲۱-۵۵۹۹۸۷۶۵", "آقای نادری", "10304567890"),
("بازرگانی مصالح تهران", "construction-materials", "تهران — خیابان شوش", "۰۲۱-۶۶۷۷۸۸۹۹", "خانم احمدی", "10405678901"),
]
PROJECTS = [
# (name, description, warehouse names)
("مجتمع مسکونی آرام", "ساخت ۲۴۰ واحد مسکونی — منطقه ۲۲ تهران", ["انبار مرکزی آرام", "انبار مصالح آرام"]),
("برج اداری سپهر", "برج اداری ۱۸ طبقه — سعادت‌آباد", ["انبار سپهر یک", "انبار سپهر دو"]),
("پروژه زیرساخت شرق تهران", "شبکه معابر و زیرساخت — منطقه ۱۳", ["انبار شرق اصلی", "انبار سیار شرق"]),
]
# quote index -> (supplier idx, project idx, status, days since quote_date,
# days since accepted, [(material idx, brand?, qty, unit_price), ...])
QUOTES = [
{
"supplier": 0, "project": 0, "status": "offered", "quote_days": 12, "accepted_days": None,
"items": [(0, 500, "120000"), (1, 300, "65000")],
},
{
"supplier": 1, "project": 0, "status": "accepted", "quote_days": 20, "accepted_days": 13,
"items": [(2, 20, "38000000"), (3, 15, "52000000")],
},
{
"supplier": 2, "project": 1, "status": "accepted", "quote_days": 15, "accepted_days": 9,
"items": [(6, 1000, "45000"), (7, 600, "145000")],
# partial receipts: item0 1000/1000 (400→wh0, 600→wh1), item1 200/600 (→wh0)
"receipts": [
{"item": 0, "qty": 400, "wh": 0, "days": 7},
{"item": 0, "qty": 600, "wh": 1, "days": 4},
{"item": 1, "qty": 200, "wh": 0, "days": 2},
],
},
{
"supplier": 3, "project": 2, "status": "arrived", "quote_days": 30, "accepted_days": 24,
"items": [(4, 400, "220000"), (5, 2000, "28000")],
"receipts": [
{"item": 0, "qty": 400, "wh": 0, "days": 3},
{"item": 1, "qty": 2000, "wh": 1, "days": 3},
],
},
]
UNITS_BY_CODE = {u["code"]: u for u in BASE_UNITS}
async def run() -> None:
casdoor_ids = await casdoor_user_ids()
async with AsyncSessionLocal() as db:
# ---- wipe previous (junk) data, keep categories/units bootstrap ----
await db.execute(text(
"TRUNCATE TABLE quote_attachments, quote_events, quote_projects, line_item_projects, "
"item_receipt_allocations, item_receipts, inventory_transactions, inventory, "
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions "
"RESTART IDENTITY CASCADE"
))
# ---- users (roles bound to Casdoor identities by username) ----
users: dict[str, User] = {}
for name, first, last, role in USER_PLAN:
cid = casdoor_ids.get(name)
if not cid:
print(f"!! Casdoor user '{name}' not found — created with pending id (run seed_casdoor.py)")
user = User(
casdoor_id=cid or f"pending-{name}",
first_name=first,
last_name=last,
role=role,
)
db.add(user)
users[name] = user
await db.flush()
# ---- units (base set; fetch-or-create) ----
unit_ids: dict[str, int] = {}
for meta in BASE_UNITS:
unit_ids[meta["code"]] = await get_or_create_unit(
db, meta["code"], meta["name_fa"], meta["dimension"], meta["si_factor"], meta["is_base"]
)
# ---- brands ----
brand_ids: dict[str, object] = {}
brand_names = sorted({b for _, _, _, b in MATERIALS if b})
for bname in brand_names:
brand = Brand(name=bname, country="ایران")
db.add(brand)
await db.flush()
brand_ids[bname] = brand.id
# ---- materials (one per category at least) + brand links ----
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
category_id=cat.id,
name=name,
canonical_unit_id=unit_ids[unit_code],
status="active",
)
db.add(material)
await db.flush()
material_by_name[name] = material
if brand_name:
bm = BrandMaterial(brand_id=brand_ids[brand_name], material_id=material.id, is_active=True)
db.add(bm)
await db.flush()
brand_material_by_material[name] = bm.id
# ---- suppliers + supplier-material links ----
suppliers: list[Supplier] = []
for name, cat_slug, location, phone, contact, tax in SUPPLIERS:
cat = await get_category(db, cat_slug)
sup = Supplier(
name=name, category_id=cat.id, location=location,
phone=phone, contact_person=contact, tax_id=tax,
)
db.add(sup)
await db.flush()
suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category
for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
for m in cat_materials[:2]:
db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True,
last_quoted_price=Decimal("1000000"), last_quoted_at=d(6),
))
await db.flush()
# ---- projects + warehouses ----
project_rows: list[Project] = []
warehouse_rows: list[list[Warehouse]] = []
pm_names = {"مجتمع مسکونی آرام": "tester", "برج اداری سپهر": "mohaghegh", "پروژه زیرساخت شرق تهران": None}
for name, desc, wh_names in PROJECTS:
proj = Project(name=name, description=desc, start_date=date.today().replace(year=date.today().year))
pm = pm_names.get(name)
if pm:
proj.pm_user_id = users[pm].id
db.add(proj)
await db.flush()
project_rows.append(proj)
whs: list[Warehouse] = []
for wn in wh_names:
wh = Warehouse(project_id=proj.id, name=wn)
db.add(wh)
await db.flush()
whs.append(wh)
warehouse_rows.append(whs)
await db.flush()
# ---- quotes ----
for qidx, qdef in enumerate(QUOTES):
sup = suppliers[qdef["supplier"]]
proj = project_rows[qdef["project"]]
accepted_at = d(qdef["accepted_days"]) if qdef["accepted_days"] is not None else None
quote = Quote(
supplier_id=sup.id,
project_id=proj.id,
quote_date=d(qdef["quote_days"]),
expiry_date=(d(qdef["quote_days"]) + timedelta(days=30)).date(),
accepted_date=accepted_at,
arrived_date=d(1) if qdef["status"] == "arrived" else None,
status=qdef["status"],
is_official=True,
notes="پیشنهاد نمونه — داده دمو",
)
db.add(quote)
await db.flush()
db.add(QuoteProjects(quote_id=quote.id, project_id=proj.id))
db.add(QuoteEvent(quote_id=quote.id, event_type="offered", occurred_at=d(qdef["quote_days"]), actor=sup.name))
if accepted_at:
db.add(QuoteEvent(quote_id=quote.id, event_type="accepted", occurred_at=accepted_at, actor=users["razaei"].first_name))
if qdef["status"] == "arrived":
db.add(QuoteEvent(quote_id=quote.id, event_type="arrived", occurred_at=d(1), actor=users["razaei"].first_name))
ts = Termsheet(quote_id=quote.id, label="گزینه نقدی — دمو", status="accepted" if accepted_at else "offered")
db.add(ts)
await db.flush()
total_all = Decimal("0")
line_items: list[LineItem] = []
for item_idx, (mat_idx, qty, price) in enumerate(qdef["items"]):
mat_name = MATERIALS[mat_idx][0]
mat = material_by_name[mat_name]
li = LineItem(
quote_id=quote.id,
material_id=mat.id,
brand_material_id=brand_material_by_material.get(mat_name),
unit_id=mat.canonical_unit_id,
quantity=Decimal(str(qty)),
)
db.add(li)
await db.flush()
line_items.append(li)
db.add(LineItemProjects(line_item_id=li.id, project_id=proj.id))
its = ItemTermsheet(line_item_id=li.id, termsheet_id=ts.id, unit_price=Decimal(price))
db.add(its)
await db.flush()
total_all += Decimal(str(qty)) * Decimal(price)
if accepted_at:
decision = ItemDecision(
line_item_id=li.id,
status="chosen",
project_id=proj.id,
chosen_item_termsheet_id=its.id,
adjusted_unit_price=Decimal(price),
adjusted_notes="تأیید قیمت — دمو",
decided_at=accepted_at,
)
db.add(decision)
# schedule steps: ۳۰٪ پیش، ۴۰٪ حین تحویل، ۳۰٪ پس از تحویل
p1 = (total_all * Decimal("30") / 100).quantize(Decimal("1"))
p2 = (total_all * Decimal("40") / 100).quantize(Decimal("1"))
p3 = total_all - p1 - p2
for step_no, amount in enumerate([p1, p2, p3], start=1):
db.add(TermsheetScheduleStep(
termsheet_id=ts.id, step_no=step_no,
due_date=(d(qdef["quote_days"]) + timedelta(days=step_no * 20)).date(),
amount=amount,
))
# ---- receipts + allocations + inventory (only for quotes with receipts) ----
for rdef in qdef.get("receipts", []):
li = line_items[rdef["item"]]
mat = material_by_name[MATERIALS[qdef["items"][rdef["item"]][0]][0]]
wh = warehouse_rows[qdef["project"]][rdef["wh"]]
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
confirmer = users["mohaghegh"] if qdef["project"] == 1 else users["tester"] if qdef["project"] == 0 else users["razaei"]
receipt = ItemReceipt(
item_decision_id=decision.id,
confirmed_by=confirmer.id,
received_at=d(rdef["days"]),
received_qty=Decimal(str(rdef["qty"])),
on_time=True,
)
db.add(receipt)
await db.flush()
alloc = ItemReceiptAllocation(receipt_id=receipt.id, warehouse_id=wh.id, quantity=Decimal(str(rdef["qty"])))
db.add(alloc)
await db.flush()
inv = await db.scalar(select(InventoryItem).where(
InventoryItem.warehouse_id == wh.id,
InventoryItem.material_id == mat.id,
InventoryItem.specs_hash == "default",
))
if inv is None:
inv = InventoryItem(
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
specs={}, specs_hash="default",
)
db.add(inv)
await db.flush()
inv.quantity += Decimal(str(rdef["qty"]))
db.add(InventoryTransaction(
inventory_id=inv.id,
warehouse_id=wh.id,
receipt_allocation_id=alloc.id,
transaction_type="PURCHASE",
quantity=Decimal(str(rdef["qty"])),
performed_by=f"{confirmer.first_name} {confirmer.last_name}",
note=f"رسید {str(receipt.id)[:8]} — {qdef['status']}",
transaction_date=d(rdef["days"]),
))
await db.flush()
await db.commit()
# summary
print("\nSeed complete ✓")
print("Projects : 3 (2 warehouses each)")
print("Materials :", len(MATERIALS), "across all 8 categories")
print("Suppliers :", len(SUPPLIERS))
print("Quotes :", len(QUOTES), "(offered / accepted-in-transit / partial / arrived)")
print("Roles : razaei=admin · mousavi=مدیر تدارکات · mohaghegh/tester=مدیر پروژه")
print("\nLogin: http://localhost — dev-mode admin: razaei / 123456")
if __name__ == "__main__":
try:
asyncio.run(run())
except KeyboardInterrupt:
sys.exit(130)