- tools/seed_mock.py: idempotent additive mock data (uuid5 ids) covering all tables (units, brands, specs, suppliers, materials + EAV/brand/unit links, projects, warehouses, inventory, quotes with termsheets/line items/decisions, receipts) so phases 6-7 can be exercised end-to-end - tools/calibrate_matching.py: A/B difflib vs Jev on 35 scenarios (31 positive + 4 negative); collects RAW picks with widened bands, classifies against the configured thresholds, sweeps EXACT 0.55-0.95 (negatives count as binds), recommends FLOOR strictly above the highest wrong pick with margin for ±0.05 run-to-run confidence jitter; markdown report + real traces - matching.py: below-FLOOR picks no longer suppress difflib — they log a routing.decision event (source=jev-lowtrust) and leave no result so the existing difflib path keeps its chance (0.54 «پردیس» was lost to «new») - config.py: JEV_CONF_FLOOR 0.60 -> 0.65 (removes the جعبه→بسته @0.61 wrong candidate offer; correct sub-floor picks باق اسپارت/کیلو are difflib-covered) - test_matching.py: +2 low-trust cases (13/13 mock, 16/16 live) - docker-compose.yml: mount bot/tools into the bot container
534 lines
28 KiB
Python
534 lines
28 KiB
Python
"""Mock-data seeder (ADDITIVE + idempotent) for JeV testing and phase-6 calibration.
|
|
|
|
docker compose exec bot python tools/seed_mock.py
|
|
|
|
Rules:
|
|
- Never deletes or modifies existing user data; existing rows (by name/key/id)
|
|
are skipped, so re-runs are safe and the team's own test data is untouched.
|
|
- Deterministic ids (uuid5) for every created entity → stable cross-references.
|
|
- Covers: units, brands, attribute definitions + material EAV values, materials,
|
|
brand/material links, material unit conversions, suppliers (+categories),
|
|
supplier↔material edges, projects, warehouses, inventory + ledger, and three
|
|
full quotes: offered / accepted multi-project / arrived (receipt + allocation
|
|
+ PURCHASE ledger row + decisions + events).
|
|
- All numeric parameters use Decimal (asyncpg is strict about numeric columns).
|
|
"""
|
|
import asyncio
|
|
import sys
|
|
import uuid
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import SessionLocal
|
|
|
|
NS = uuid.UUID("6d3ed6a0-9e11-4b2a-8f7e-000000000000")
|
|
|
|
|
|
def uid(*parts: str) -> uuid.UUID:
|
|
return uuid.uuid5(NS, "|".join(parts))
|
|
|
|
|
|
def D(v) -> Decimal:
|
|
return Decimal(str(v))
|
|
|
|
|
|
STATS: dict[str, list[int]] = {}
|
|
|
|
|
|
def _bump(kind: str, created: int, skipped: int) -> None:
|
|
c, s = STATS.get(kind, [0, 0])
|
|
STATS[kind] = [c + created, s + skipped]
|
|
|
|
|
|
UNITS_EXTRA = [
|
|
("sh", "شاخه", "count"),
|
|
("m2", "متر مربع", "area"),
|
|
("pack", "بسته", "count"),
|
|
("roll", "رول", "count"),
|
|
("sheet", "ورق", "count"),
|
|
]
|
|
|
|
BRANDS = [
|
|
"فولاد مبارکه", "ذوب آهن اصفهان", "سیمان تهران", "سیمان سپاهان",
|
|
"پلیکا", "البرز", "سرامیک آسیا",
|
|
]
|
|
|
|
ATTR_DEFS = [
|
|
("spec_قطر", "قطر"),
|
|
("spec_سایز", "سایز"),
|
|
("spec_گرید", "گرید"),
|
|
("spec_ضخامت", "ضخامت"),
|
|
("spec_بستهبندی", "بستهبندی"),
|
|
]
|
|
|
|
# name, canonical unit, supplier, brand|None, specs[(key,value)], conversions[(unit, factor)]
|
|
MATERIALS = [
|
|
("میلگرد آجدار", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
|
[("spec_سایز", "سایز ۱۴"), ("spec_گرید", "آجدار A3")], [("شاخه", 12), ("تن", 1000)]),
|
|
("تیرآهن", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
|
[("spec_سایز", "سایز ۱۸")], [("شاخه", 226)]),
|
|
("ورق سیاه", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
|
[("spec_ضخامت", "۲ میل")], [("ورق", "24.6")]),
|
|
("پروفیل قوطی", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
|
[("spec_سایز", "۴۰×۴۰"), ("spec_ضخامت", "۲ میل")], []),
|
|
("سیمان پرتلند", "kg", "بازرگانی مصطفوی", "سیمان تهران",
|
|
[("spec_بستهبندی", "۵۰ کیلویی")], [("کیسه", 50), ("تن", 1000)]),
|
|
("گچ و خاک", "kg", "بازرگانی مصطفوی", None,
|
|
[("spec_بستهبندی", "۳۰ کیلویی")], [("کیسه", 30)]),
|
|
("گچ ساختمانی", "kg", "بازرگانی مصطفوی", None, [], [("کیسه", 30)]),
|
|
("شن و ماسه", "kg", "بازرگانی مصطفوی", None, [], []),
|
|
("لوله پلیکا", "m", "پلیکا فروش البرز", "پلیکا",
|
|
[("spec_قطر", "۶ اینچ")], [("شاخه", 6)]),
|
|
("لوله مانیسمان", "m", "تجهیزات آبی پارس", None,
|
|
[("spec_قطر", "۲ اینچ")], []),
|
|
("سه راهی پلیکا", "piece", "پلیکا فروش البرز", "پلیکا",
|
|
[("spec_قطر", "۶ اینچ")], []),
|
|
("شیر فلکه", "piece", "تجهیزات آبی پارس", None,
|
|
[("spec_سایز", "۲ اینچ")], []),
|
|
("کاشی سرامیک", "m2", "سرامیک آسیا", "سرامیک آسیا",
|
|
[("spec_سایز", "۶۰×۶۰")], []),
|
|
("رنگ روغنی", "kg", "شیمی صنعت سپاهان", None,
|
|
[("spec_بستهبندی", "گالن ۳.۶ کیلویی")], []),
|
|
("الکترود جوشکاری", "kg", "ابزار سازان اصفهان", None,
|
|
[("spec_سایز", "۳.۲")], []),
|
|
("نایلون شیرینک", "kg", "شیمی صنعت سپاهان", None, [], []),
|
|
]
|
|
|
|
SUPPLIERS = [
|
|
("بازرگانی مصطفوی", "construction-materials", "اصفهان، خیابان امام", "031-32223344", "حاج آقا مصطفوی"),
|
|
("نمایندگی فولاد مبارکه", "metals-steel-sections", "اصفهان، شهرک صنعتی", "031-33445566", "مهندس کریمی"),
|
|
("پلیکا فروش البرز", "mechanical-installations-pipes", "کرج، جاده مخصوص", "026-34567890", "آقای رضایی"),
|
|
("تجهیزات آبی پارس", "mechanical-installations-pipes", "تهران، بازار آهن", "021-55667788", "آقای شریفی"),
|
|
("سرامیک آسیا", "finishing-interior-decoration", "یزد، میبد", "035-32345678", "آقای دهقان"),
|
|
("ابزار سازان اصفهان", "construction-tools-equipment", "اصفهان، بازار", "031-32112233", "آقای نوری"),
|
|
("شیمی صنعت سپاهان", "construction-chemicals", "اصفهان، شهرک علمی", "031-33889900", "خانم احمدی"),
|
|
]
|
|
|
|
PROJECTS = [
|
|
("دفتر", "دفتر مرکزی شرکت"),
|
|
("منشانه", "پروژه منشانه"),
|
|
("باغ اسپارت", "پروژه باغ اسپارت"),
|
|
("پردیس کاشان", "پروژه پردیس کاشان"),
|
|
("بابک", "پروژه بابک"),
|
|
]
|
|
|
|
|
|
async def _name_id(s, table: str, name: str):
|
|
row = (await s.execute(
|
|
text(f"SELECT id FROM {table} WHERE name = :n LIMIT 1"), {"n": name}
|
|
)).scalar()
|
|
return str(row) if row else None
|
|
|
|
|
|
async def seed() -> None:
|
|
now = datetime.now(timezone.utc)
|
|
async with SessionLocal() as s:
|
|
# ---------------- units ----------------
|
|
created = skipped = 0
|
|
for code, name_fa, dim in UNITS_EXTRA:
|
|
r = await s.execute(text(
|
|
"INSERT INTO units (code, name_fa, dimension, si_factor, is_base) "
|
|
"VALUES (:c, :n, :d, 1, false) ON CONFLICT (code) DO NOTHING"
|
|
), {"c": code, "n": name_fa, "d": dim})
|
|
created += r.rowcount
|
|
skipped += 1 - r.rowcount
|
|
_bump("units", created, skipped)
|
|
_unit_rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).all()
|
|
unit_map = {r.code: r.id for r in _unit_rows}
|
|
unit_fa = {r.name_fa: r.id for r in _unit_rows}
|
|
|
|
def unit_of(ref) -> int:
|
|
return unit_map.get(ref) or unit_fa[ref]
|
|
|
|
# ---------------- brands ----------------
|
|
created = skipped = 0
|
|
brand_id: dict[str, str] = {}
|
|
for b in BRANDS:
|
|
existing = await _name_id(s, "brands", b)
|
|
if existing:
|
|
brand_id[b] = existing
|
|
skipped += 1
|
|
continue
|
|
bid = uid("brand", b)
|
|
await s.execute(text(
|
|
"INSERT INTO brands (id, name, country) VALUES (:id, :name, 'ایران') ON CONFLICT (id) DO NOTHING"
|
|
), {"id": str(bid), "name": b})
|
|
brand_id[b] = str(bid)
|
|
created += 1
|
|
_bump("brands", created, skipped)
|
|
|
|
# ---------------- attribute definitions ----------------
|
|
created = skipped = 0
|
|
attr_id: dict[str, str] = {}
|
|
for key, label in ATTR_DEFS:
|
|
row = (await s.execute(
|
|
text("SELECT id FROM attribute_definitions WHERE attribute_key=:k"), {"k": key}
|
|
)).scalar()
|
|
if row:
|
|
attr_id[key] = str(row)
|
|
skipped += 1
|
|
continue
|
|
aid = uid("attr", key)
|
|
await s.execute(text(
|
|
"INSERT INTO attribute_definitions (id, attribute_key, label, data_type, enum_values) "
|
|
"VALUES (:id, :k, :l, 'string', NULL) ON CONFLICT (id) DO NOTHING"
|
|
), {"id": str(aid), "k": key, "l": label})
|
|
attr_id[key] = str(aid)
|
|
created += 1
|
|
_bump("attribute_definitions", created, skipped)
|
|
|
|
# ---------------- suppliers ----------------
|
|
created = skipped = 0
|
|
cat_map = {r.slug: str(r.id) for r in
|
|
(await s.execute(text("SELECT id, slug FROM material_categories"))).all()}
|
|
supplier_id: dict[str, str] = {}
|
|
for name, slug, location, phone, contact in SUPPLIERS:
|
|
existing = await _name_id(s, "suppliers", name)
|
|
if existing:
|
|
supplier_id[name] = existing
|
|
skipped += 1
|
|
continue
|
|
sid = uid("supplier", name)
|
|
await s.execute(text(
|
|
"INSERT INTO suppliers (id, name, category_id, location, phone, contact_person) "
|
|
"VALUES (:id, :name, :cat, :loc, :phone, :contact) ON CONFLICT (id) DO NOTHING"
|
|
), {"id": str(sid), "name": name, "cat": cat_map.get(slug), "loc": location,
|
|
"phone": phone, "contact": contact})
|
|
supplier_id[name] = str(sid)
|
|
created += 1
|
|
_bump("suppliers", created, skipped)
|
|
|
|
# ---------------- materials (+ links, EAV, conversions) ----------------
|
|
created = skipped = 0
|
|
material_id: dict[str, str] = {}
|
|
brand_material_of: dict[str, str | None] = {}
|
|
brand_of: dict[str, str | None] = {}
|
|
for name, unit_code, sup_name, brand, specs, convs in MATERIALS:
|
|
mid = await _name_id(s, "materials", name)
|
|
if mid is None:
|
|
mid = str(uid("material", name))
|
|
await s.execute(text(
|
|
"INSERT INTO materials (id, name, canonical_unit_id, status) "
|
|
"VALUES (:id, :name, :unit, 'active') ON CONFLICT (id) DO NOTHING"
|
|
), {"id": mid, "name": name, "unit": unit_map[unit_code]})
|
|
created += 1
|
|
else:
|
|
skipped += 1
|
|
material_id[name] = mid
|
|
|
|
for key, value in specs:
|
|
await s.execute(text(
|
|
"INSERT INTO material_attribute_values "
|
|
"(id, material_id, attribute_definition_id, value_string) "
|
|
"VALUES (:id, :m, :a, :v) "
|
|
"ON CONFLICT (material_id, attribute_definition_id) DO NOTHING"
|
|
), {"id": str(uid("mav", name, key)), "m": mid, "a": attr_id[key], "v": value})
|
|
|
|
if brand:
|
|
bmid = str(uid("bm", brand, name))
|
|
await s.execute(text(
|
|
"INSERT INTO brand_materials (id, brand_id, material_id, is_active) "
|
|
"VALUES (:id, :b, :m, true) ON CONFLICT (brand_id, material_id) DO NOTHING"
|
|
), {"id": bmid, "b": brand_id[brand], "m": mid})
|
|
brand_material_of[name] = bmid
|
|
brand_of[name] = brand_id[brand]
|
|
else:
|
|
brand_material_of[name] = None
|
|
brand_of[name] = None
|
|
|
|
for conv_code, factor in convs:
|
|
exists = (await s.execute(text(
|
|
"SELECT 1 FROM material_units WHERE material_id=:m AND brand_material_id IS NULL "
|
|
"AND unit_id=:u LIMIT 1"
|
|
), {"m": mid, "u": unit_of(conv_code)})).scalar()
|
|
if exists:
|
|
continue
|
|
await s.execute(text(
|
|
"INSERT INTO material_units (id, material_id, brand_material_id, unit_id, "
|
|
"conversion_factor, is_default) VALUES (:id, :m, NULL, :u, :f, false)"
|
|
), {"id": str(uid("mu", name, conv_code)), "m": mid,
|
|
"u": unit_of(conv_code), "f": D(factor)})
|
|
|
|
await s.execute(text(
|
|
"INSERT INTO supplier_materials (id, supplier_id, material_id, is_active, "
|
|
"last_quoted_price, last_quoted_at) VALUES (:id, :sup, :m, true, :price, :at) "
|
|
"ON CONFLICT (supplier_id, material_id) DO NOTHING"
|
|
), {"id": str(uid("sm", sup_name, name)), "sup": supplier_id[sup_name],
|
|
"m": mid, "price": D("100000"), "at": now})
|
|
_bump("materials", created, skipped)
|
|
|
|
# ---------------- projects + warehouses ----------------
|
|
created = skipped = 0
|
|
project_id: dict[str, str] = {}
|
|
for name, desc in PROJECTS:
|
|
pid = await _name_id(s, "projects", name)
|
|
if pid is None:
|
|
pid = str(uid("project", name))
|
|
await s.execute(text(
|
|
"INSERT INTO projects (id, name, description) VALUES (:id, :name, :desc) "
|
|
"ON CONFLICT (id) DO NOTHING"
|
|
), {"id": pid, "name": name, "desc": desc})
|
|
created += 1
|
|
else:
|
|
skipped += 1
|
|
project_id[name] = pid
|
|
_bump("projects", created, skipped)
|
|
|
|
created = skipped = 0
|
|
warehouse_id: dict[tuple[str, str], str] = {}
|
|
for pname, pid in project_id.items():
|
|
for wname in ("انبار مرکزی", "انبار کارگاه"):
|
|
wid = str(uid("wh", pname, wname))
|
|
exists = (await s.execute(text(
|
|
"SELECT 1 FROM warehouses WHERE id=:id LIMIT 1"), {"id": wid})).scalar()
|
|
if exists:
|
|
skipped += 1
|
|
else:
|
|
await s.execute(text(
|
|
"INSERT INTO warehouses (id, project_id, name, description) "
|
|
"VALUES (:id, :p, :n, :d)"
|
|
), {"id": wid, "p": pid, "n": wname, "d": f"{wname} پروژه {pname}"})
|
|
created += 1
|
|
warehouse_id[(pname, wname)] = wid
|
|
_bump("warehouses", created, skipped)
|
|
|
|
# ---------------- inventory + ledger ----------------
|
|
inv_plan = [
|
|
("منشانه", "انبار مرکزی", "گچ و خاک", None, "200", "kg"),
|
|
("منشانه", "انبار کارگاه", "سیمان پرتلند", "سیمان تهران", "150", "kg"),
|
|
("بابک", "انبار مرکزی", "میلگرد آجدار", "فولاد مبارکه", "800", "kg"),
|
|
("پردیس کاشان", "انبار مرکزی", "لوله پلیکا", "پلیکا", "60", "m"),
|
|
]
|
|
created = skipped = 0
|
|
for pname, wname, mname, bname, qty, unit_code in inv_plan:
|
|
iid = str(uid("inv", pname, wname, mname))
|
|
exists = (await s.execute(text(
|
|
"SELECT 1 FROM inventory WHERE id=:id LIMIT 1"), {"id": iid})).scalar()
|
|
if exists:
|
|
skipped += 1
|
|
continue
|
|
await s.execute(text(
|
|
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, unit_id, "
|
|
"specs, specs_hash) VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
|
|
), {"id": iid, "w": warehouse_id[(pname, wname)], "m": material_id[mname],
|
|
"b": brand_id.get(bname) if bname else None, "q": D(qty), "u": unit_map[unit_code]})
|
|
await s.execute(text(
|
|
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, transaction_type, "
|
|
"quantity, performed_by, transaction_date, note) "
|
|
"VALUES (:id, :inv, :w, 'PURCHASE', :q, 'کاربر آزمایشی', :at, 'موجودی اولیه (seed)')"
|
|
), {"id": str(uid("tx", iid, "purchase")), "inv": iid,
|
|
"w": warehouse_id[(pname, wname)], "q": D(qty), "at": now - timedelta(days=20)})
|
|
created += 1
|
|
_bump("inventory", created, skipped)
|
|
|
|
# ---------------- quotes ----------------
|
|
created = skipped = 0
|
|
admin_uid = (await s.execute(text(
|
|
"SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1"
|
|
))).scalar()
|
|
admin_uid = str(admin_uid) if admin_uid else None
|
|
|
|
async def insert_quote_block(qkey, supplier, projects, status, quote_date, expiry,
|
|
accepted, arrived, is_official, notes, items, termsheets,
|
|
decisions_map, events, receipts=None, legacy_project=None):
|
|
qid = str(uid("quote", qkey))
|
|
exists = (await s.execute(text("SELECT 1 FROM quotes WHERE id=:id"), {"id": qid})).scalar()
|
|
if exists:
|
|
return False
|
|
await s.execute(text(
|
|
"INSERT INTO quotes (id, supplier_id, project_id, quote_date, expiry_date, "
|
|
"accepted_date, arrived_date, status, is_official, notes) VALUES "
|
|
"(:id, :sup, :proj, :qd, :ed, :ad, :ar, CAST(:st AS quote_status), :off, :notes)"
|
|
), {"id": qid, "sup": supplier_id[supplier],
|
|
"proj": project_id.get(legacy_project) if legacy_project else None,
|
|
"qd": quote_date, "ed": expiry, "ad": accepted, "ar": arrived,
|
|
"st": status, "off": is_official, "notes": notes})
|
|
for p in projects:
|
|
await s.execute(text(
|
|
"INSERT INTO quote_projects (quote_id, project_id) VALUES (:q, :p) "
|
|
"ON CONFLICT DO NOTHING"), {"q": qid, "p": project_id[p]})
|
|
|
|
ts_ids: dict[str, str] = {}
|
|
for ts in termsheets:
|
|
tid = str(uid("ts", qkey, ts["label"]))
|
|
ts_ids[ts["label"]] = tid
|
|
await s.execute(text(
|
|
"INSERT INTO termsheets (id, quote_id, label, status, discount_type, discount_value) "
|
|
"VALUES (:id, :q, :l, CAST(:st AS termsheet_status), CAST(:dt AS discount_type), :dv)"
|
|
), {"id": tid, "q": qid, "l": ts["label"], "st": ts["status"],
|
|
"dt": ts.get("discount_type", "none"), "dv": D(ts.get("discount_value", 0))})
|
|
for i, (due, amt, note) in enumerate(ts["steps"], start=1):
|
|
await s.execute(text(
|
|
"INSERT INTO termsheet_schedule_steps (id, termsheet_id, step_no, due_date, "
|
|
"amount, note) VALUES (:id, :ts, :no, :due, :amt, :note)"
|
|
), {"id": str(uid("step", qkey, ts["label"], str(i))), "ts": tid, "no": i,
|
|
"due": due, "amt": D(amt), "note": note})
|
|
|
|
line_ids: dict[str, str] = {}
|
|
its_ids: dict[tuple[str, str], str] = {}
|
|
for it in items:
|
|
lid = str(uid("li", qkey, it["material"]))
|
|
line_ids[it["material"]] = lid
|
|
await s.execute(text(
|
|
"INSERT INTO line_items (id, quote_id, material_id, brand_material_id, unit_id, "
|
|
"quantity, notes) VALUES (:id, :q, :m, :bm, :u, :qty, NULL)"
|
|
), {"id": lid, "q": qid, "m": material_id[it["material"]],
|
|
"bm": brand_material_of.get(it["material"]), "u": unit_map[it["unit"]],
|
|
"qty": D(it["qty"])})
|
|
for p in it.get("projects", projects):
|
|
await s.execute(text(
|
|
"INSERT INTO line_item_projects (line_item_id, project_id) VALUES (:li, :p) "
|
|
"ON CONFLICT DO NOTHING"), {"li": lid, "p": project_id[p]})
|
|
if it.get("price") is not None:
|
|
tlabel = it.get("termsheet", termsheets[0]["label"])
|
|
iid = str(uid("it", qkey, it["material"], tlabel))
|
|
its_ids[(it["material"], tlabel)] = iid
|
|
await s.execute(text(
|
|
"INSERT INTO item_termsheets (id, line_item_id, termsheet_id, unit_price, "
|
|
"min_order_qty) VALUES (:id, :li, :ts, :price, NULL)"
|
|
), {"id": iid, "li": lid, "ts": ts_ids[tlabel], "price": D(it["price"])})
|
|
|
|
dec_ids: dict[str, str] = {}
|
|
for mname, dec in decisions_map.items():
|
|
did = str(uid("dec", qkey, mname))
|
|
chosen_its = (its_ids.get((mname, dec.get("termsheet")))
|
|
if dec.get("status") == "chosen" else None)
|
|
await s.execute(text(
|
|
"INSERT INTO item_decisions (id, line_item_id, chosen_item_termsheet_id, status, "
|
|
"project_id, adjusted_unit_price, decided_at) VALUES "
|
|
"(:id, :li, :its, CAST(:st AS item_decision_status), :proj, :price, :at)"
|
|
), {"id": did, "li": line_ids[mname], "its": chosen_its, "st": dec["status"],
|
|
"proj": project_id.get(dec.get("project")) if dec.get("project") else None,
|
|
"price": D(dec["price"]) if dec.get("price") is not None else None,
|
|
"at": accepted if dec.get("status") == "chosen" else None})
|
|
dec_ids[mname] = did
|
|
|
|
for ev in events:
|
|
at = {"offered": quote_date, "accepted": accepted, "arrived": arrived}.get(ev) or quote_date
|
|
await s.execute(text(
|
|
"INSERT INTO quote_events (id, quote_id, event_type, payload, occurred_at, actor) "
|
|
"VALUES (:id, :q, CAST(:ev AS quote_event_type), '{}'::jsonb, :at, 'کاربر آزمایشی (seed)')"
|
|
), {"id": str(uid("ev", qkey, ev)), "q": qid, "ev": ev, "at": at})
|
|
|
|
for rc in receipts or []:
|
|
rid = str(uid("rc", qkey, rc["material"]))
|
|
await s.execute(text(
|
|
"INSERT INTO item_receipts (id, item_decision_id, confirmed_by, received_at, "
|
|
"received_qty, on_time, quality_note) VALUES (:id, :d, :by, :at, :qty, true, NULL)"
|
|
), {"id": rid, "d": dec_ids[rc["material"]], "by": admin_uid,
|
|
"at": arrived, "qty": D(rc["qty"])})
|
|
for wname, alloc_qty in rc["allocations"]:
|
|
aid = str(uid("alloc", qkey, rc["material"], wname))
|
|
wid = warehouse_id[(rc["project"], wname)]
|
|
await s.execute(text(
|
|
"INSERT INTO item_receipt_allocations (id, receipt_id, warehouse_id, quantity) "
|
|
"VALUES (:id, :r, :w, :q)"
|
|
), {"id": aid, "r": rid, "w": wid, "q": D(alloc_qty)})
|
|
inv_row = (await s.execute(text(
|
|
"SELECT id FROM inventory WHERE warehouse_id=:w AND material_id=:m "
|
|
"AND COALESCE(brand_id, '00000000-0000-0000-0000-000000000000') = "
|
|
"COALESCE(CAST(:b AS uuid), '00000000-0000-0000-0000-000000000000') "
|
|
"AND specs_hash='default' LIMIT 1"
|
|
), {"w": wid, "m": material_id[rc["material"]],
|
|
"b": brand_of.get(rc["material"])})).scalar()
|
|
qty_canonical = D(alloc_qty) * D(rc.get("factor", 1))
|
|
if inv_row is None:
|
|
inv_id = str(uid("inv", rc["project"], wname, rc["material"]))
|
|
await s.execute(text(
|
|
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, "
|
|
"unit_id, specs, specs_hash) "
|
|
"VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
|
|
), {"id": inv_id, "w": wid, "m": material_id[rc["material"]],
|
|
"b": brand_of.get(rc["material"]), "q": qty_canonical,
|
|
"u": unit_map[rc.get("canonical", "kg")]})
|
|
else:
|
|
inv_id = str(inv_row)
|
|
await s.execute(text(
|
|
"UPDATE inventory SET quantity = quantity + :q WHERE id=:id"
|
|
), {"q": qty_canonical, "id": inv_id})
|
|
await s.execute(text(
|
|
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, "
|
|
"receipt_allocation_id, transaction_type, quantity, performed_by, "
|
|
"transaction_date, note) VALUES (:id, :inv, :w, :alloc, 'PURCHASE', :q, "
|
|
"'کاربر آزمایشی', :at, :note)"
|
|
), {"id": str(uid("tx", qkey, rc["material"], wname)), "inv": inv_id,
|
|
"w": wid, "alloc": aid, "q": qty_canonical, "at": arrived,
|
|
"note": f"ورود از رسید خرید {rc['material']}"})
|
|
return True
|
|
|
|
d_today = date(2026, 10, 12)
|
|
|
|
ok1 = await insert_quote_block(
|
|
"q1", "پلیکا فروش البرز", ["پردیس کاشان"], "offered",
|
|
now - timedelta(days=2), d_today + timedelta(days=12), None, None, True,
|
|
"[mock] استعلام لوله و اتصالات پردیس",
|
|
items=[
|
|
{"material": "لوله پلیکا", "unit": "sh", "qty": "120", "price": "450000"},
|
|
{"material": "سه راهی پلیکا", "unit": "piece", "qty": "40", "price": "85000"},
|
|
],
|
|
termsheets=[{"label": "نقد امروز (۵٪ تخفیف)", "status": "offered",
|
|
"discount_type": "percent", "discount_value": "5",
|
|
"steps": [(d_today, "60000000", "تسویه نقدی یکباره")]}],
|
|
decisions_map={"لوله پلیکا": {"status": "pending"}, "سه راهی پلیکا": {"status": "pending"}},
|
|
events=["offered"],
|
|
legacy_project="پردیس کاشان",
|
|
)
|
|
ok2 = await insert_quote_block(
|
|
"q2", "بازرگانی مصطفوی", ["منشانه", "دفتر"], "accepted",
|
|
now - timedelta(days=9), d_today + timedelta(days=5),
|
|
now - timedelta(days=6), None, False,
|
|
"[mock] خرید گچ و سیمان پروژههای منشانه و دفتر",
|
|
items=[
|
|
{"material": "گچ و خاک", "unit": "bag", "qty": "100", "price": "85000",
|
|
"termsheet": "سه قسط ماهانه", "projects": ["منشانه"]},
|
|
{"material": "سیمان پرتلند", "unit": "bag", "qty": "50", "price": "185000",
|
|
"termsheet": "سه قسط ماهانه", "projects": ["دفتر"]},
|
|
],
|
|
termsheets=[{"label": "سه قسط ماهانه", "status": "accepted",
|
|
"steps": [(d_today, "5875000", "قسط اول"),
|
|
(d_today + timedelta(days=30), "5875000", "قسط دوم"),
|
|
(d_today + timedelta(days=60), "5875000", "قسط سوم")]}],
|
|
decisions_map={
|
|
"گچ و خاک": {"status": "chosen", "project": "منشانه",
|
|
"termsheet": "سه قسط ماهانه", "price": "85000"},
|
|
"سیمان پرتلند": {"status": "chosen", "project": "دفتر",
|
|
"termsheet": "سه قسط ماهانه", "price": "185000"},
|
|
},
|
|
events=["offered", "accepted"],
|
|
legacy_project="منشانه",
|
|
)
|
|
ok3 = await insert_quote_block(
|
|
"q3", "نمایندگی فولاد مبارکه", ["بابک"], "arrived",
|
|
now - timedelta(days=15), d_today - timedelta(days=1),
|
|
now - timedelta(days=12), now - timedelta(days=3), True,
|
|
"[mock] میلگرد پروژه بابک (رسید کامل)",
|
|
items=[{"material": "میلگرد آجدار", "unit": "ton", "qty": "2", "price": "42000000"}],
|
|
termsheets=[{"label": "نقد امروز", "status": "accepted",
|
|
"steps": [(d_today, "84000000", "تسویه")]}],
|
|
decisions_map={"میلگرد آجدار": {"status": "chosen", "project": "بابک",
|
|
"termsheet": "نقد امروز", "price": "42000000"}},
|
|
events=["offered", "accepted", "arrived"],
|
|
receipts=[{"material": "میلگرد آجدار", "qty": "2", "project": "بابک",
|
|
"allocations": [("انبار مرکزی", "2")], "factor": 1000,
|
|
"canonical": "kg"}],
|
|
legacy_project="بابک",
|
|
)
|
|
_bump("quotes", sum(1 for x in (ok1, ok2, ok3) if x),
|
|
sum(1 for x in (ok1, ok2, ok3) if not x))
|
|
|
|
await s.commit()
|
|
|
|
print("\n=== seed summary (created / skipped) ===")
|
|
for kind, (c, sk) in STATS.items():
|
|
print(f" {kind:22} created={c:3} skipped={sk:3}")
|
|
print("done. Nothing was deleted or modified.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(seed())
|