Spaces:
Running
Running
| """Simulated retail ERP data warehouse (SQLite) — the knowledgebase the ERP DocIQ | |
| chatbot reasons over, and the source domain for the fine-tuning dataset. | |
| Deterministic: a fixed RNG seed makes the whole warehouse reproducible, so NLQ | |
| answers, analytics, evals and the fine-tune dataset are all stable across runs. | |
| Schema (a retail accounts-payable / procurement slice): | |
| vendors(vendor_id, name, region, category, payment_terms, on_time_rate, risk_tier) | |
| products(sku, name, category, unit_cost, unit_price) | |
| purchase_orders(po_id, vendor_id, order_date, status, region, amount) | |
| po_lines(po_id, sku, qty, unit_price, line_total) | |
| invoices(invoice_id, po_id, vendor_id, invoice_date, due_date, amount, tax, | |
| total, status, paid_date, days_to_pay) | |
| gl_entries(entry_id, invoice_id, account, cost_center, period, amount) | |
| inventory(sku, region, on_hand, reorder_point, monthly_demand) | |
| returns(return_id, sku, region, return_date, qty, reason, refund_amount) | |
| This is intentionally a *small* but internally-consistent dataset: invoices roll up | |
| from PO lines, GL entries roll up from invoices, returns reference real SKUs, so | |
| analytics ("why did spend spike in Q2", "top vendors by late payments") are answerable | |
| from the data rather than canned. | |
| """ | |
| from __future__ import annotations | |
| import random | |
| import sqlite3 | |
| import threading | |
| from datetime import date, timedelta | |
| from pathlib import Path | |
| SEED = 20260101 | |
| REGIONS = ["Northeast", "Midwest", "South", "West"] | |
| CATEGORIES = ["Fixtures", "Electronics", "Apparel", "Grocery", "Packaging", "Logistics"] | |
| RISK = ["low", "low", "low", "medium", "medium", "high"] | |
| VENDOR_NAMES = [ | |
| "Meridian Industrial", "Nordic Fixture Works", "BrightLite Electronics", "Halcyon Build", | |
| "Cascade Apparel Co", "Summit Packaging", "BlueRiver Logistics", "Orchard Grocery Supply", | |
| "PrimeEdge Components", "Vertex Retail Systems", "Granite State Goods", "Copperline Textiles", | |
| "Lakeside Distribution", "IronGate Hardware", "Pinnacle Foods", "Aurora Display Group", | |
| ] | |
| PRODUCTS = [ | |
| ("SKU-1001", "Heavy-gauge shelf unit", "Fixtures", 142.0, 189.0), | |
| ("SKU-1002", "LED retail strip 2m", "Electronics", 14.5, 22.4), | |
| ("SKU-1003", "Endcap display birch", "Fixtures", 232.0, 310.0), | |
| ("SKU-1004", "Thermal receipt rolls", "Packaging", 1.1, 2.4), | |
| ("SKU-1005", "Barcode scanner USB", "Electronics", 38.0, 59.0), | |
| ("SKU-1006", "Store associate polo", "Apparel", 9.2, 18.0), | |
| ("SKU-1007", "Pallet wrap roll", "Packaging", 18.0, 27.5), | |
| ("SKU-1008", "Organic coffee 1kg", "Grocery", 8.5, 14.0), | |
| ("SKU-1009", "Freight pallet move", "Logistics", 22.0, 35.0), | |
| ("SKU-1010", "Security tag pack", "Electronics", 4.0, 7.5), | |
| ("SKU-1011", "Checkout counter mat", "Fixtures", 26.0, 41.0), | |
| ("SKU-1012", "Reusable tote bag", "Apparel", 2.3, 5.0), | |
| ] | |
| ACCOUNTS = { | |
| "Fixtures": "5000-Store-Fit-Out", "Electronics": "5100-IT-Equipment", | |
| "Apparel": "5200-Uniforms", "Grocery": "5300-COGS-Grocery", | |
| "Packaging": "5400-Supplies", "Logistics": "5500-Freight", | |
| } | |
| RETURN_REASONS = ["damaged", "wrong item", "defective", "overstock", "late delivery"] | |
| class ErpWarehouse: | |
| """Read-mostly SQLite warehouse with a guarded NLQ query surface.""" | |
| def __init__(self, db_path: str | Path) -> None: | |
| self.db_path = Path(db_path) | |
| self.db_path.parent.mkdir(parents=True, exist_ok=True) | |
| self._lock = threading.Lock() | |
| self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) | |
| self._conn.row_factory = sqlite3.Row | |
| if not self._has_data(): | |
| self._build() | |
| def _has_data(self) -> bool: | |
| try: | |
| return self._conn.execute("SELECT 1 FROM invoices LIMIT 1").fetchone() is not None | |
| except sqlite3.OperationalError: | |
| return False | |
| # --- schema + seed -------------------------------------------------------- | |
| def _build(self) -> None: | |
| rng = random.Random(SEED) | |
| with self._lock: | |
| c = self._conn | |
| c.executescript( | |
| """ | |
| DROP TABLE IF EXISTS vendors; DROP TABLE IF EXISTS products; | |
| DROP TABLE IF EXISTS purchase_orders; DROP TABLE IF EXISTS po_lines; | |
| DROP TABLE IF EXISTS invoices; DROP TABLE IF EXISTS gl_entries; | |
| DROP TABLE IF EXISTS inventory; DROP TABLE IF EXISTS returns; | |
| CREATE TABLE vendors(vendor_id TEXT PRIMARY KEY, name TEXT, region TEXT, | |
| category TEXT, payment_terms TEXT, on_time_rate REAL, risk_tier TEXT); | |
| CREATE TABLE products(sku TEXT PRIMARY KEY, name TEXT, category TEXT, | |
| unit_cost REAL, unit_price REAL); | |
| CREATE TABLE purchase_orders(po_id TEXT PRIMARY KEY, vendor_id TEXT, | |
| order_date TEXT, status TEXT, region TEXT, amount REAL); | |
| CREATE TABLE po_lines(po_id TEXT, sku TEXT, qty INTEGER, unit_price REAL, | |
| line_total REAL); | |
| CREATE TABLE invoices(invoice_id TEXT PRIMARY KEY, po_id TEXT, vendor_id TEXT, | |
| invoice_date TEXT, due_date TEXT, amount REAL, tax REAL, total REAL, | |
| status TEXT, paid_date TEXT, days_to_pay INTEGER); | |
| CREATE TABLE gl_entries(entry_id TEXT PRIMARY KEY, invoice_id TEXT, account TEXT, | |
| cost_center TEXT, period TEXT, amount REAL); | |
| CREATE TABLE inventory(sku TEXT, region TEXT, on_hand INTEGER, | |
| reorder_point INTEGER, monthly_demand INTEGER); | |
| CREATE TABLE returns(return_id TEXT, sku TEXT, region TEXT, return_date TEXT, | |
| qty INTEGER, reason TEXT, refund_amount REAL); | |
| """ | |
| ) | |
| # vendors | |
| vendors = [] | |
| for i, nm in enumerate(VENDOR_NAMES): | |
| cat = CATEGORIES[i % len(CATEGORIES)] | |
| vid = f"V-{1000+i}" | |
| terms = rng.choice(["Net 30", "Net 30", "Net 45", "Net 60"]) | |
| on_time = round(rng.uniform(0.72, 0.99), 3) | |
| vendors.append((vid, nm, rng.choice(REGIONS), cat, terms, on_time, RISK[i % len(RISK)])) | |
| c.executemany("INSERT INTO vendors VALUES (?,?,?,?,?,?,?)", vendors) | |
| c.executemany("INSERT INTO products VALUES (?,?,?,?,?)", PRODUCTS) | |
| prod_by_cat: dict[str, list] = {} | |
| for p in PRODUCTS: | |
| prod_by_cat.setdefault(p[2], []).append(p) | |
| # 12 months of POs → invoices → GL. A deliberate Q2 spend spike on Fixtures | |
| # (store-remodel program) makes "why did spend rise" answerable from data. | |
| po_n = inv_n = gl_n = 0 | |
| start = date(2025, 7, 1) | |
| for month in range(12): | |
| m_date = (start + timedelta(days=30 * month)) | |
| period = m_date.strftime("%Y-%m") | |
| # base order volume, with a Fixtures surge in 2026 Q2 (months 9-11) | |
| n_orders = rng.randint(10, 16) | |
| surge = month in (9, 10, 11) | |
| for _ in range(n_orders): | |
| v = rng.choice(vendors) | |
| vid, vcat, vregion, terms, on_time = v[0], v[3], v[2], v[4], v[5] | |
| # bias product to vendor category; surge picks Fixtures | |
| cat = "Fixtures" if (surge and rng.random() < 0.45) else vcat | |
| pool = prod_by_cat.get(cat) or PRODUCTS | |
| po_n += 1 | |
| po_id = f"PO-{2000+po_n}" | |
| od = m_date + timedelta(days=rng.randint(0, 27)) | |
| n_lines = rng.randint(1, 4) | |
| amount = 0.0 | |
| lines = [] | |
| for _ in range(n_lines): | |
| p = rng.choice(pool) | |
| qty = rng.randint(2, 40) * (3 if (surge and cat == "Fixtures") else 1) | |
| unit = round(p[4] * rng.uniform(0.95, 1.05), 2) | |
| lt = round(qty * unit, 2) | |
| amount += lt | |
| lines.append((po_id, p[0], qty, unit, lt)) | |
| status = rng.choice(["received", "received", "received", "open", "cancelled"]) | |
| c.execute("INSERT INTO purchase_orders VALUES (?,?,?,?,?,?)", | |
| (po_id, vid, od.isoformat(), status, vregion, round(amount, 2))) | |
| c.executemany("INSERT INTO po_lines VALUES (?,?,?,?,?)", lines) | |
| if status == "cancelled": | |
| continue | |
| # invoice | |
| inv_n += 1 | |
| inv_id = f"INV-{5000+inv_n}" | |
| idate = od + timedelta(days=rng.randint(1, 10)) | |
| term_days = int(terms.split()[1]) | |
| due = idate + timedelta(days=term_days) | |
| tax = round(amount * 0.0825, 2) | |
| total = round(amount + tax, 2) | |
| paid = rng.random() < 0.82 | |
| if paid: | |
| # late if vendor has low on-time rate | |
| late = rng.random() > on_time | |
| dd = rng.randint(term_days + 3, term_days + 25) if late else rng.randint(8, term_days) | |
| paid_date = (idate + timedelta(days=dd)).isoformat() | |
| istatus = "paid" | |
| days_to_pay = dd | |
| else: | |
| paid_date, istatus, days_to_pay = None, "open", None | |
| c.execute("INSERT INTO invoices VALUES (?,?,?,?,?,?,?,?,?,?,?)", | |
| (inv_id, po_id, vid, idate.isoformat(), due.isoformat(), | |
| round(amount, 2), tax, total, istatus, paid_date, days_to_pay)) | |
| gl_n += 1 | |
| c.execute("INSERT INTO gl_entries VALUES (?,?,?,?,?,?)", | |
| (f"GL-{9000+gl_n}", inv_id, ACCOUNTS.get(cat, "5900-Other"), | |
| f"CC-{vregion[:3].upper()}", period, total)) | |
| # inventory + returns | |
| for p in PRODUCTS: | |
| for r in REGIONS: | |
| dem = rng.randint(20, 200) | |
| c.execute("INSERT INTO inventory VALUES (?,?,?,?,?)", | |
| (p[0], r, rng.randint(0, 400), int(dem * 0.5), dem)) | |
| ret_n = 0 | |
| for _ in range(60): | |
| p = rng.choice(PRODUCTS) | |
| ret_n += 1 | |
| rdate = (start + timedelta(days=rng.randint(0, 360))) | |
| qty = rng.randint(1, 12) | |
| c.execute("INSERT INTO returns VALUES (?,?,?,?,?,?,?)", | |
| (f"R-{7000+ret_n}", p[0], rng.choice(REGIONS), rdate.isoformat(), | |
| qty, rng.choice(RETURN_REASONS), round(qty * p[4], 2))) | |
| c.commit() | |
| # --- guarded query surface (for NLQ) -------------------------------------- | |
| def query(self, sql: str, limit: int = 200) -> tuple[list[str], list[list]]: | |
| """Execute a single read-only SELECT. Raises ValueError on anything unsafe.""" | |
| safe = sql.strip().rstrip(";").strip() | |
| low = safe.lower() | |
| if not low.startswith(("select", "with")): | |
| raise ValueError("only SELECT/WITH queries are allowed") | |
| forbidden = (" insert ", " update ", " delete ", " drop ", " alter ", " create ", | |
| " attach ", " pragma ", " replace ", "--", ";") | |
| padded = f" {low} " | |
| for f in forbidden: | |
| if f in padded: | |
| raise ValueError(f"forbidden token in query: {f.strip()!r}") | |
| if " limit " not in low: | |
| safe = f"{safe} LIMIT {limit}" | |
| with self._lock: | |
| cur = self._conn.execute(safe) | |
| rows = cur.fetchall() | |
| cols = [d[0] for d in cur.description] | |
| return cols, [list(r) for r in rows] | |
| def scalar(self, sql: str): | |
| cols, rows = self.query(sql, limit=1) | |
| return rows[0][0] if rows else None | |
| def table_counts(self) -> dict: | |
| out = {} | |
| for t in ("vendors", "products", "purchase_orders", "po_lines", "invoices", | |
| "gl_entries", "inventory", "returns"): | |
| out[t] = self.scalar(f"SELECT COUNT(*) FROM {t}") | |
| return out | |
| # Compact schema description handed to the NLQ model (kept byte-stable for caching). | |
| ERP_SCHEMA_DOC = """ERP warehouse schema (SQLite, retail procurement / AP): | |
| - vendors(vendor_id, name, region, category, payment_terms, on_time_rate, risk_tier) | |
| - products(sku, name, category, unit_cost, unit_price) | |
| - purchase_orders(po_id, vendor_id, order_date, status, region, amount) | |
| - po_lines(po_id, sku, qty, unit_price, line_total) | |
| - invoices(invoice_id, po_id, vendor_id, invoice_date, due_date, amount, tax, total, status, paid_date, days_to_pay) | |
| - gl_entries(entry_id, invoice_id, account, cost_center, period, amount) -- period is 'YYYY-MM' | |
| - inventory(sku, region, on_hand, reorder_point, monthly_demand) | |
| - returns(return_id, sku, region, return_date, qty, reason, refund_amount) | |
| Notes: invoices.status in ('paid','open'); a payment is LATE when days_to_pay > payment_terms days. | |
| Spend = invoices.total. Dates are ISO 'YYYY-MM-DD'. gl_entries.period groups spend by month.""" | |
| EXAMPLE_QUESTIONS = [ | |
| "What was total invoiced spend by month?", | |
| "Who are the top 5 vendors by spend?", | |
| "Which vendors paid late most often?", | |
| "Why did spend rise in Q2 2026?", | |
| "What is the late-payment rate overall?", | |
| "Show spend by category.", | |
| "Summarize accounts payable health.", | |
| "Which SKUs are below reorder point?", | |
| "What is the total value of open (unpaid) invoices?", | |
| "Top return reasons by refund amount?", | |
| ] | |
| _WAREHOUSE: ErpWarehouse | None = None | |
| def get_warehouse(settings) -> ErpWarehouse: | |
| """Process-wide singleton, seeded under the writable dir.""" | |
| global _WAREHOUSE | |
| if _WAREHOUSE is None: | |
| path = getattr(settings, "erp_db_path", None) or (settings.writable_dir / "erp.db") | |
| _WAREHOUSE = ErpWarehouse(path) | |
| return _WAREHOUSE | |