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:
@@ -25,6 +25,7 @@ from app.models.catalog import (
|
|||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
ItemReceipt,
|
ItemReceipt,
|
||||||
|
ItemReceiptAllocation,
|
||||||
ItemTermsheet,
|
ItemTermsheet,
|
||||||
LineItem,
|
LineItem,
|
||||||
LineItemProjects,
|
LineItemProjects,
|
||||||
@@ -37,6 +38,7 @@ from app.models.procurement import (
|
|||||||
Termsheet,
|
Termsheet,
|
||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
|
from app.models.inventory import Warehouse
|
||||||
from app.services.eav_service import flatten_eav_values
|
from app.services.eav_service import flatten_eav_values
|
||||||
from app.utils.serialization import to_date, to_iso
|
from app.utils.serialization import to_date, to_iso
|
||||||
|
|
||||||
@@ -307,6 +309,7 @@ class _LineItemData:
|
|||||||
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
|
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
|
||||||
self.decisions: dict[uuid.UUID, ItemDecision] = {}
|
self.decisions: dict[uuid.UUID, ItemDecision] = {}
|
||||||
self.receipts_by_decision: dict[uuid.UUID, list[ItemReceipt]] = {}
|
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_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
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)
|
by_dec.setdefault(r.item_decision_id, []).append(r)
|
||||||
data.receipts_by_decision = by_dec
|
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:
|
if unit_ids:
|
||||||
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
|
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
|
||||||
data.units = {u.id: u for u in units}
|
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)
|
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
|
||||||
if "receipt" in expand:
|
if "receipt" in expand:
|
||||||
# 1:N partial deliveries — emit the aggregate (sum of received qty,
|
# 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
|
rs = data.receipts_by_decision.get(decision.id) if decision is not None else None
|
||||||
if rs:
|
if rs:
|
||||||
latest = rs[-1]
|
latest = rs[-1]
|
||||||
total = sum((r.received_qty for r in rs), Decimal("0"))
|
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"] = {
|
d["receipt"] = {
|
||||||
**receipt_brief(latest),
|
**receipt_brief(latest),
|
||||||
"received_qty": float(total),
|
"received_qty": float(total),
|
||||||
"receipt_count": len(rs),
|
"receipt_count": len(rs),
|
||||||
|
"allocations": allocs,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
d["receipt"] = None
|
d["receipt"] = None
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from app.models.inventory import (
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
)
|
)
|
||||||
from app.schemas.inventory import InventoryTransactionCreate
|
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:
|
def canonical_specs_hash(specs: dict | None) -> str:
|
||||||
@@ -79,6 +79,39 @@ async def _find_inventory(
|
|||||||
return await db.scalar(stmt)
|
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(
|
async def _get_or_create_inventory(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
warehouse_id,
|
warehouse_id,
|
||||||
@@ -233,6 +266,19 @@ async def apply_transaction(
|
|||||||
|
|
||||||
# Increasing types: find-or-create the row, then add.
|
# Increasing types: find-or-create the row, then add.
|
||||||
if t in INCREASING_TYPES:
|
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(
|
row = await _get_or_create_inventory(
|
||||||
db, payload.warehouse_id, payload.material_id, payload.brand_id,
|
db, payload.warehouse_id, payload.material_id, payload.brand_id,
|
||||||
payload.unit_id, specs, specs_hash,
|
payload.unit_id, specs, specs_hash,
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user