feat(bot): phase-6 calibration — mock seed, A/B harness, low-trust fallback
- 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
This commit is contained in:
+3
-1
@@ -147,7 +147,9 @@ class Settings(BaseSettings):
|
||||
JEV_ENABLED: bool = True
|
||||
JEV_TIMEOUT: float = 8.0
|
||||
JEV_CONF_EXACT: float = 0.85
|
||||
JEV_CONF_FLOOR: float = 0.60
|
||||
JEV_CONF_FLOOR: float = 0.65 # phase-6 calibration (2026-10-12): 0.60 let one
|
||||
# wrong candidate (جعبه→بسته @0.61) through; the
|
||||
# correct picks it drops are covered by difflib.
|
||||
JEV_MAX_QUESTIONS_PER_FIELD: int = 15
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
+20
-8
@@ -168,18 +168,30 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
|
||||
key=lambda x: -x["prob"],
|
||||
)[:6]
|
||||
|
||||
base = {"field": field, "variants": m["variants"], "confidence": conf,
|
||||
"choice": (cand.name if cand is not None else None),
|
||||
"candidates": ranked}
|
||||
|
||||
if cand is not None and conf >= settings.JEV_CONF_EXACT:
|
||||
res = {"field": field, "status": "exact", "variants": m["variants"],
|
||||
"id": cand.id, "name": cand.name, "extra": cand.extra or {},
|
||||
"confidence": conf}
|
||||
res = {**base, "status": "exact", "id": cand.id, "name": cand.name,
|
||||
"extra": cand.extra or {}}
|
||||
elif cand is not None and conf >= settings.JEV_CONF_FLOOR:
|
||||
res = {"field": field, "status": "ambiguity", "variants": m["variants"],
|
||||
"id": cand.id, "name": cand.name, "extra": cand.extra or {},
|
||||
"confidence": conf,
|
||||
res = {**base, "status": "ambiguity", "id": cand.id, "name": cand.name,
|
||||
"extra": cand.extra or {},
|
||||
"candidates": ranked or [{"id": cand.id, "name": cand.name, "prob": conf}]}
|
||||
elif cand is None:
|
||||
# Explicit «_none» (or an unusable choice): the model judged the whole
|
||||
# list → treat as new (the auto-create path handles it).
|
||||
res = {**base, "status": "new"}
|
||||
else:
|
||||
res = {"field": field, "status": "new", "variants": m["variants"],
|
||||
"confidence": conf, "candidates": ranked}
|
||||
# Picked something but below the floor = low trust: leave NO result so
|
||||
# the difflib fallback still gets its chance (suppressing it here would
|
||||
# let a correct-but-under-confident pick create duplicates instead).
|
||||
await obs.event(trace_id, "routing.decision", field=field, payload={
|
||||
"source": "jev-lowtrust", "variants": m["variants"],
|
||||
"choice": cand.name, "confidence": conf,
|
||||
})
|
||||
continue
|
||||
for v in m["variants"]:
|
||||
results[normalize_text(v)] = res
|
||||
decisions.append({"variants": m["variants"], "status": res["status"],
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Phase-6 calibration: A/B difflib vs Jev + confidence-threshold sweep.
|
||||
|
||||
docker compose exec bot python tools/calibrate_matching.py
|
||||
|
||||
- Runs a fixed scenario set (seeded catalog + realistic STT-ish variants and
|
||||
negatives) through BOTH matchers:
|
||||
* difflib baseline: resolve.safe_resolve (the pre-Jev path)
|
||||
* Jev: matching.resolve_mentions (one request per field — that is how the
|
||||
pipeline actually runs آن)
|
||||
- Every run is recorded as real observability data (traces with
|
||||
source="calibration") plus a markdown report under data/reports/.
|
||||
- Prints a summary with recommended JEV_CONF_EXACT / JEV_CONF_FLOOR values.
|
||||
|
||||
Nothing is sent to Bale and no drafts are created.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import matching, observability as obs
|
||||
from app.api.client import ErpClient
|
||||
from app.config import settings
|
||||
from app.resolve import Resolver, safe_resolve
|
||||
|
||||
# (field, variants, truth) — truth=None means "must NOT match anything".
|
||||
SCENARIOS = [
|
||||
("project", ["دفتر"], "دفتر"),
|
||||
("project", ["منشیانه"], "منشانه"),
|
||||
("project", ["منشانه"], "منشانه"),
|
||||
("project", ["باق اسپارت"], "باغ اسپارت"),
|
||||
("project", ["پردیس"], "پردیس کاشان"),
|
||||
("project", ["بابک"], "بابک"),
|
||||
("project", ["آسمون"], "آسمان"),
|
||||
("project", ["تست یک"], "تست 1"),
|
||||
("project", ["انبار قدیم"], None),
|
||||
|
||||
("supplier", ["مصطفوی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["مستفدی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["مصطفوی", "مستفدی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["فولاد مبارکه"], "نمایندگی فولاد مبارکه"),
|
||||
("supplier", ["پلیکا البرز"], "پلیکا فروش البرز"),
|
||||
("supplier", ["راجین"], "راجین"),
|
||||
("supplier", ["آهن فروشی آزاد"], None),
|
||||
|
||||
("material", ["گچ و خاک"], "گچ و خاک"),
|
||||
("material", ["سیمان"], "سیمان پرتلند"),
|
||||
("material", ["میلگرد"], "میلگرد آجدار"),
|
||||
("material", ["تیر آهن"], "تیرآهن"),
|
||||
("material", ["لوله پلیکا"], "لوله پلیکا"),
|
||||
("material", ["کاغذ A4"], None),
|
||||
|
||||
("unit", ["کیلو"], "کیلوگرم"),
|
||||
("unit", ["تن"], "تن"),
|
||||
("unit", ["کیسه"], "کیسه"),
|
||||
("unit", ["شاخه"], "شاخه"),
|
||||
("unit", ["جعبه"], None),
|
||||
|
||||
("brand", ["ذوب آهن"], "ذوب آهن اصفهان"),
|
||||
("brand", ["مبارکه"], "فولاد مبارکه"),
|
||||
("brand", ["سیمان تهران"], "سیمان تهران"),
|
||||
|
||||
("spec", ["سایز 14"], "سایز ۱۴"),
|
||||
("spec", ["50 کیلویی"], "۵۰ کیلویی"),
|
||||
("spec", ["6 اینچ"], "۶ اینچ"),
|
||||
("spec", ["40×40"], "۴۰×۴۰"),
|
||||
("spec", ["۲ میلی"], "۲ میل"),
|
||||
]
|
||||
|
||||
SWEEP = [round(0.55 + 0.05 * i, 2) for i in range(9)] # 0.55 … 0.95
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||
resolver = Resolver(api)
|
||||
ctx = SimpleNamespace(resolver=resolver)
|
||||
|
||||
fields: dict[str, list[dict]] = {}
|
||||
for field, variants, truth in SCENARIOS:
|
||||
fields.setdefault(field, []).append({"field": field, "variants": variants,
|
||||
"truth": truth, "difflib": None, "jev": None})
|
||||
|
||||
for field, scs in fields.items():
|
||||
trace = await obs.trace_start(0, None, "text", source="calibration")
|
||||
|
||||
# --- difflib baseline -------------------------------------------------
|
||||
for sc in scs:
|
||||
res = await safe_resolve(resolver, field, sc["variants"][0])
|
||||
cands = [{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]]
|
||||
sc["difflib"] = {"status": res.status, "top": cands[0]["name"] if cands else None,
|
||||
"cands": [c["name"] for c in cands]}
|
||||
await obs.log_resolution(trace, field, sc["variants"][0], res.status, cands)
|
||||
|
||||
# --- Jev (one batch for the whole field) ------------------------------
|
||||
# Collect RAW picks: temporarily widen the bands so every confidence is
|
||||
# returned; classification below uses the CONFIGURED thresholds.
|
||||
saved_exact, saved_floor = settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR
|
||||
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = 1.01, 0.0
|
||||
try:
|
||||
jev = await matching.resolve_mentions(
|
||||
ctx, {}, [{"field": s["field"], "variants": s["variants"]} for s in scs], trace)
|
||||
finally:
|
||||
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = saved_exact, saved_floor
|
||||
for sc in scs:
|
||||
hit = matching.lookup(jev, sc["variants"][0])
|
||||
raw_conf = None if hit is None else hit.get("confidence")
|
||||
raw_choice = None if hit is None else hit.get("choice")
|
||||
if raw_choice is None:
|
||||
sc["jev"] = {"status": "new", "choice": None, "conf": raw_conf}
|
||||
elif raw_conf >= saved_exact:
|
||||
sc["jev"] = {"status": "exact", "choice": raw_choice, "conf": raw_conf}
|
||||
elif raw_conf >= saved_floor:
|
||||
sc["jev"] = {"status": "ambiguity", "choice": raw_choice, "conf": raw_conf}
|
||||
else:
|
||||
sc["jev"] = {"status": "lowtrust", "choice": raw_choice, "conf": raw_conf}
|
||||
await obs.event(trace, "calibration.truth", payload={
|
||||
"field": field,
|
||||
"rows": [{"variants": s["variants"], "truth": s["truth"],
|
||||
"difflib": s["difflib"], "jev": s["jev"]} for s in scs],
|
||||
})
|
||||
|
||||
# ------------------------- metrics ---------------------------------------
|
||||
pos = [s for s in sum(fields.values(), []) if s["truth"]]
|
||||
neg = [s for s in sum(fields.values(), []) if not s["truth"]]
|
||||
|
||||
d_auto = d_amb = d_miss = 0
|
||||
for s in pos:
|
||||
d = s["difflib"]
|
||||
if d["top"] == s["truth"]:
|
||||
d_auto += 1
|
||||
elif s["truth"] in d["cands"]:
|
||||
d_amb += 1
|
||||
else:
|
||||
d_miss += 1
|
||||
d_neg_ok = sum(1 for s in neg if s["difflib"]["status"] == "none")
|
||||
|
||||
j_exact_ok = sum(1 for s in pos if s["jev"]["status"] == "exact"
|
||||
and s["jev"]["choice"] == s["truth"])
|
||||
j_exact_bad = sum(1 for s in pos if s["jev"]["status"] == "exact"
|
||||
and s["jev"]["choice"] != s["truth"])
|
||||
j_amb = sum(1 for s in pos if s["jev"]["status"] == "ambiguity")
|
||||
j_low = sum(1 for s in pos if s["jev"]["status"] == "lowtrust")
|
||||
j_new = sum(1 for s in pos if s["jev"]["status"] == "new")
|
||||
# negatives: rejected cleanly / asked the user (safe but noisy) / wrongly bound
|
||||
j_neg_ok = sum(1 for s in neg if s["jev"]["status"] in ("new", "lowtrust"))
|
||||
j_neg_ask = sum(1 for s in neg if s["jev"]["status"] == "ambiguity")
|
||||
j_neg_bad = sum(1 for s in neg if s["jev"]["status"] == "exact")
|
||||
|
||||
# --------------------- threshold sweep (EXACT) ---------------------------
|
||||
# Binds = positives above the threshold PLUS any pick on a negative scenario
|
||||
# (a wrong pick above EXACT would auto-bind — it must drag precision down).
|
||||
sweep_rows = []
|
||||
for th in SWEEP:
|
||||
pos_binds = [s for s in pos if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
|
||||
neg_binds = [s for s in neg if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
|
||||
ok = sum(1 for s in pos_binds if s["jev"]["choice"] == s["truth"])
|
||||
binds = len(pos_binds) + len(neg_binds)
|
||||
prec = (ok / binds) if binds else 0.0
|
||||
cov = len(pos_binds) / len(pos)
|
||||
sweep_rows.append({"th": th, "binds": binds, "correct": ok,
|
||||
"precision": round(prec, 3), "coverage": round(cov, 3)})
|
||||
|
||||
# Conservative recommendation (risk-asymmetric: a wrong auto-bind costs more
|
||||
# than an extra confirmation tap; the sample here is still small):
|
||||
# - EXACT: keep the configured value while its precision is >= 0.95; raise it
|
||||
# to the first threshold with precision >= 0.95 otherwise.
|
||||
# - FLOOR: raise just above the highest confidence of a WRONG pick (a wrong
|
||||
# pick below the floor never again becomes a candidate button).
|
||||
binds_now = [r for r in sweep_rows if r["th"] == settings.JEV_CONF_EXACT]
|
||||
prec_now = binds_now[0]["precision"] if binds_now else 0.0
|
||||
rec_exact = settings.JEV_CONF_EXACT
|
||||
if prec_now < 0.95:
|
||||
for row in sweep_rows:
|
||||
if row["precision"] >= 0.95 and row["coverage"] >= 0.30:
|
||||
rec_exact = row["th"]
|
||||
break
|
||||
|
||||
correct_confs = sorted(s["jev"]["conf"] for s in pos
|
||||
if s["jev"]["choice"] == s["truth"] and s["jev"]["conf"])
|
||||
# a wrong pick on a NEGATIVE scenario (truth=None) matters too: the floor
|
||||
# must sit above it so it never becomes a candidate button offer.
|
||||
wrong_confs = sorted(s["jev"]["conf"] for s in pos + neg
|
||||
if s["jev"]["choice"] and s["jev"]["choice"] != s["truth"] and s["jev"]["conf"])
|
||||
rec_floor = settings.JEV_CONF_FLOOR
|
||||
if wrong_confs:
|
||||
import math
|
||||
# The same scenario's confidence jitters ±0.05 across identical runs
|
||||
# (جعبه observed 0.61/0.60/0.58), so sit one step above the highest wrong
|
||||
# pick + margin — routing uses `conf >= FLOOR`, so equality leaks through.
|
||||
target = max(wrong_confs) + 0.05
|
||||
rec_floor = min(rec_exact, round(math.floor(target * 20 + 0.5) / 20.0, 2))
|
||||
if rec_floor <= max(wrong_confs):
|
||||
rec_floor = min(rec_exact,
|
||||
round((math.floor(max(wrong_confs) * 20) + 1) / 20.0, 2))
|
||||
|
||||
# how much coverage a more aggressive EXACT would buy (info only)
|
||||
lower_info = next((r for r in reversed(sweep_rows)
|
||||
if r["th"] < settings.JEV_CONF_EXACT and r["precision"] >= 0.98), None)
|
||||
|
||||
# ------------------------------ report -----------------------------------
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
|
||||
out_dir = Path(settings.OBS_VOICE_DIR).parent / "reports"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
report = out_dir / f"calibration_{ts}.md"
|
||||
|
||||
lines = [
|
||||
"# گزارش کالیبراسیون تطبیق موجودیتها (فاز ۶)",
|
||||
f"\nتاریخ: {ts} UTC · مدل Jev پینشده: `{settings.JEV_MODEL}` · "
|
||||
f"آستانههای فعلی: EXACT={settings.JEV_CONF_EXACT} · FLOOR={settings.JEV_CONF_FLOOR}",
|
||||
f"\nسناریوها: {len(pos)} مثبت + {len(neg)} منفی (بدون تطبیق) = {len(pos) + len(neg)}",
|
||||
"\n> روش: انتخابهای خام Jev با باند بازِ موقت جمعآوری میشوند و «وضعیت» هر "
|
||||
"سناریو با آستانههای پیکربندیشده بالا محاسبه میشود. status `lowtrust` یعنی "
|
||||
"انتخاب زیر FLOOR بود و در اجرای واقعی رد میشود → مسیر difflib ادامه پیدا میکند.",
|
||||
"\n## خلاصه",
|
||||
f"- **difflib (پایه):** اتصال درست {d_auto}/{len(pos)} · ابهام {d_amb} · از دست رفته {d_miss} "
|
||||
f"· رد درست منفیها {d_neg_ok}/{len(neg)}",
|
||||
f"- **Jev:** exact-درست {j_exact_ok} · exact-غلط {j_exact_bad} · ابهام(سؤال از کاربر) {j_amb} · "
|
||||
f"کماعتماد→difflib {j_low} · new {j_new}",
|
||||
f"- **منفیها:** ردِ تمیز {j_neg_ok}/{len(neg)} · سؤال از کاربر {j_neg_ask} · پذیرش غلط {j_neg_bad}",
|
||||
f"- **پیشنهاد آستانه:** `JEV_CONF_EXACT={rec_exact}` · `JEV_CONF_FLOOR={rec_floor}`"
|
||||
+ (f" _(داده فعلی از کاهش EXACT تا {lower_info['th']} با دقت {lower_info['precision']} "
|
||||
f"و پوشش {lower_info['coverage']} حمایت میکند — با حجم داده واقعی بازبینی شود)_"
|
||||
if lower_info else ""),
|
||||
"\n## جدول sweep آستانه EXACT",
|
||||
"| آستانه | اتصال خودکار (مثبت+منفی) | درست | دقت | پوشش مثبتها |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for r in sweep_rows:
|
||||
lines.append(f"| {r['th']} | {r['binds']} | {r['correct']} | {r['precision']} | {r['coverage']} |")
|
||||
lines += [
|
||||
"\n## توزیع confidence",
|
||||
f"- انتخابهای درست: {correct_confs or '—'}",
|
||||
f"- انتخابهای غلط: {wrong_confs or '—'}",
|
||||
]
|
||||
for field, scs in fields.items():
|
||||
lines += [f"\n## فیلد {field}", "| variants | حقیقت | difflib | Jev (choice/conf) |",
|
||||
"|---|---|---|---|"]
|
||||
for s in scs:
|
||||
j = s["jev"]
|
||||
jtxt = "—" if not j else f"{j['status']}: {j['choice'] or '_none'} @ {j['conf']}"
|
||||
if j and j["status"] == "lowtrust":
|
||||
jtxt += " (→ مسیر difflib)"
|
||||
lines.append(f"| {' / '.join(s['variants'])} | {s['truth'] or '—'} | "
|
||||
f"{s['difflib']['status']}: {s['difflib']['top'] or '—'} | {jtxt} |")
|
||||
report.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
print("\n".join(lines[:14]))
|
||||
print(f"\nگزارش کامل: {report}")
|
||||
print(f"\nپیشنهاد: JEV_CONF_EXACT={rec_exact} JEV_CONF_FLOOR={rec_floor}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,533 @@
|
||||
"""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())
|
||||
@@ -63,7 +63,7 @@ async def mock_tests() -> None:
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
orig_ask = matching.jev_ask
|
||||
|
||||
# 1) routing bands: exact / ambiguity / new + variant grouping
|
||||
# 1) routing bands: exact / ambiguity / low-trust / new + variant grouping
|
||||
matching.jev_ask = make_fake_ask({
|
||||
"mention_0": {"choice": "منشانه", "confidence": 0.9,
|
||||
"probabilities": {"منشانه": 0.95, "_none": 0.05}},
|
||||
@@ -71,11 +71,14 @@ async def mock_tests() -> None:
|
||||
"probabilities": {"دفتر": 0.73, "منشانه": 0.15, "_none": 0.12}},
|
||||
"mention_2": {"choice": "_none", "confidence": 0.9,
|
||||
"probabilities": {"_none": 0.94}},
|
||||
"mention_3": {"choice": "دفتر", "confidence": 0.5,
|
||||
"probabilities": {"دفتر": 0.52, "_none": 0.48}},
|
||||
})
|
||||
mentions = [
|
||||
{"field": "project", "variants": ["منشیانه"]},
|
||||
{"field": "project", "variants": ["دفتر", "دفر"]},
|
||||
{"field": "project", "variants": ["چیز عجیب"]},
|
||||
{"field": "project", "variants": ["دفر دو"]},
|
||||
]
|
||||
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
r0 = matching.lookup(res, "منشیانه")
|
||||
@@ -87,6 +90,9 @@ async def mock_tests() -> None:
|
||||
check("variant of same mention shares one result", r1 is r0 if False else
|
||||
(r1 and matching.lookup(res, "دفتر") is r1))
|
||||
check("_none / low conf -> new", r2 and r2["status"] == "new")
|
||||
check("below-floor pick -> NO result (difflib fallback keeps its chance)",
|
||||
matching.lookup(res, "دفر دو") is None)
|
||||
check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity")
|
||||
|
||||
# 2) Jev failure -> empty map (callers fall back to difflib)
|
||||
matching.jev_ask = make_fake_ask({})
|
||||
|
||||
@@ -322,6 +322,7 @@ services:
|
||||
|
||||
volumes:
|
||||
- ./bot/app:/srv/bot/app
|
||||
- ./bot/tools:/srv/bot/tools
|
||||
- ./bot/data:/srv/bot/data
|
||||
- ./bot/.env:/srv/bot/.env:ro
|
||||
|
||||
|
||||
Reference in New Issue
Block a user