lambda-erp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/bootstrap.py ADDED
@@ -0,0 +1,498 @@
1
+ """Idempotent first-run bootstrap for a public demo container.
2
+
3
+ Called from the FastAPI lifespan when LAMBDA_ERP_AUTO_DEMO=1. On first boot this
4
+ provisions a company, runs the 3-year historical simulator so the UI has real
5
+ transactional data to display, creates the public_manager demo account, and
6
+ pre-creates every concrete artifact the scripted chat replay narrates
7
+ (quotation, purchase order, custom analytics draft, a Redstone sales invoice)
8
+ so the links the demo shows resolve to real records.
9
+
10
+ Safe to run on every container start — each step checks existence before
11
+ doing work.
12
+
13
+ Simulation is pinned to 2023-04-20 → 2026-04-20 with seed=42, so the demo
14
+ world is identical across deploys and the chat script can reference it.
15
+ """
16
+
17
+ import json
18
+
19
+ from lambda_erp.database import get_db
20
+ from lambda_erp.utils import _dict
21
+
22
+
23
+ DEMO_COMPANY = "Lambda Demo Corp"
24
+ DEMO_SIM_START = "2023-04-20"
25
+ DEMO_SIM_END = "2026-04-20"
26
+ DEMO_SIM_SEED = 42
27
+ DEMO_CHAT_DATE = "2026-04-22" # "today" inside the demo narrative
28
+
29
+ # Settings keys used by load_demo_script() for placeholder substitution.
30
+ SETTING_DEMO_QUOTATION = "demo_chat_quotation"
31
+ SETTING_DEMO_PURCHASE_ORDER = "demo_chat_purchase_order"
32
+ SETTING_DEMO_COMPANY = "demo_chat_company"
33
+ # Top 3 customer ranking (from simulator data; deterministic under seed 42).
34
+ SETTING_DEMO_TOP1_ID = "demo_chat_top1_id"
35
+ SETTING_DEMO_TOP1_NAME = "demo_chat_top1_name"
36
+ SETTING_DEMO_TOP1_REVENUE = "demo_chat_top1_revenue"
37
+ SETTING_DEMO_TOP1_INVOICES = "demo_chat_top1_invoices"
38
+ SETTING_DEMO_TOP2_ID = "demo_chat_top2_id"
39
+ SETTING_DEMO_TOP2_NAME = "demo_chat_top2_name"
40
+ SETTING_DEMO_TOP2_REVENUE = "demo_chat_top2_revenue"
41
+ SETTING_DEMO_TOP3_ID = "demo_chat_top3_id"
42
+ SETTING_DEMO_TOP3_NAME = "demo_chat_top3_name"
43
+ SETTING_DEMO_TOP3_REVENUE = "demo_chat_top3_revenue"
44
+ # Last invoice for each of those top 3 (rendered as markdown line lists).
45
+ SETTING_DEMO_TOP1_LAST_INV = "demo_chat_top1_last_inv"
46
+ SETTING_DEMO_TOP1_LAST_INV_DATE = "demo_chat_top1_last_inv_date"
47
+ SETTING_DEMO_TOP1_LAST_INV_ITEMS = "demo_chat_top1_last_inv_items"
48
+ SETTING_DEMO_TOP2_LAST_INV = "demo_chat_top2_last_inv"
49
+ SETTING_DEMO_TOP2_LAST_INV_DATE = "demo_chat_top2_last_inv_date"
50
+ SETTING_DEMO_TOP2_LAST_INV_ITEMS = "demo_chat_top2_last_inv_items"
51
+ SETTING_DEMO_TOP3_LAST_INV = "demo_chat_top3_last_inv"
52
+ SETTING_DEMO_TOP3_LAST_INV_DATE = "demo_chat_top3_last_inv_date"
53
+ SETTING_DEMO_TOP3_LAST_INV_ITEMS = "demo_chat_top3_last_inv_items"
54
+ # Custom analytics report draft + follow-up Redstone sales invoice.
55
+ SETTING_DEMO_TOP7_REPORT_ID = "demo_chat_top7_report_id"
56
+ SETTING_DEMO_REDSTONE_SINV = "demo_chat_redstone_sinv"
57
+ SETTING_DEMO_REDSTONE_SINV_DATE = "demo_chat_redstone_sinv_date"
58
+ SETTING_DEMO_REDSTONE_DUE_DATE = "demo_chat_redstone_due_date"
59
+
60
+
61
+ def bootstrap_demo() -> None:
62
+ """Idempotent demo bootstrap. Logs each phase to stdout so a
63
+ `docker compose up` user sees steady progress during the ~3-minute
64
+ first-boot simulation run.
65
+
66
+ By default this seeds a company + 3 years of simulated history and then
67
+ hands off to the normal register-your-first-admin login flow. Set
68
+ LAMBDA_ERP_ENABLE_PUBLIC_DEMO=1 to additionally create the shared
69
+ public_manager account and the chat-replay artefacts — that's the mode
70
+ used for the hosted public demo at lambda.dev/erp."""
71
+ import os
72
+ import time
73
+
74
+ db = get_db()
75
+ t0 = time.monotonic()
76
+ public_demo = os.environ.get("LAMBDA_ERP_ENABLE_PUBLIC_DEMO") == "1"
77
+
78
+ # 1. Company (only if no company yet)
79
+ companies = db.get_all("Company", fields=["name"])
80
+ if not companies:
81
+ demo_currency = os.environ.get("LAMBDA_ERP_DEMO_CURRENCY", "USD")
82
+ print(f"[bootstrap] creating company: {DEMO_COMPANY} (base currency {demo_currency})", flush=True)
83
+ from api.routers.setup import create_company
84
+
85
+ create_company(
86
+ {"name": DEMO_COMPANY, "currency": demo_currency},
87
+ _user=None,
88
+ )
89
+ else:
90
+ print(f"[bootstrap] company already exists: {companies[0]['name']}", flush=True)
91
+
92
+ company = db.get_all("Company", fields=["name"])[0]["name"]
93
+
94
+ # 2. Historical simulation — skip if any quotations already exist, so
95
+ # re-boots don't regenerate and duplicate.
96
+ qtn_count = db.sql('SELECT COUNT(*) as cnt FROM "Quotation"')[0]["cnt"]
97
+ if qtn_count == 0:
98
+ print(
99
+ f"[bootstrap] running historical simulation "
100
+ f"({DEMO_SIM_START} -> {DEMO_SIM_END}, seed={DEMO_SIM_SEED})",
101
+ flush=True,
102
+ )
103
+ from lambda_erp.simulation import HistoricalSimulator
104
+
105
+ sim = HistoricalSimulator(
106
+ company=company,
107
+ start=DEMO_SIM_START,
108
+ end=DEMO_SIM_END,
109
+ seed=DEMO_SIM_SEED,
110
+ )
111
+ sim.run(simulate_activity=True)
112
+ else:
113
+ print(f"[bootstrap] simulation data present ({qtn_count} quotations), skipping", flush=True)
114
+
115
+ # 2b. A small EUR-denominated thread so the demo actually exercises the
116
+ # multi-currency engine (foreign invoice + realized FX + an open
117
+ # foreign bill). Additive and idempotent — it stays well below the
118
+ # top customers so the deterministic chat-replay snapshots are intact.
119
+ base_currency = db.get_value("Company", company, "default_currency") or "USD"
120
+ ensure_demo_foreign_currency_activity(db, company, base_currency)
121
+
122
+ # 3. Public-demo extras — opt-in. Without this, the container hands
123
+ # off to the normal login page where the first registered user
124
+ # becomes admin.
125
+ if public_demo:
126
+ print("[bootstrap] LAMBDA_ERP_ENABLE_PUBLIC_DEMO=1 — creating public_manager + chat-replay records", flush=True)
127
+ from api.auth import create_public_manager
128
+
129
+ create_public_manager(user=None)
130
+ ensure_demo_chat_records(company)
131
+ else:
132
+ print("[bootstrap] public demo disabled — first visitor registers as admin via the login page", flush=True)
133
+
134
+ elapsed = time.monotonic() - t0
135
+ # Recommend 127.0.0.1 — on WSL2 + Docker Desktop, browsers can stall
136
+ # for minutes on WebSocket upgrades to `localhost` even though HTTP
137
+ # works fine to the same address. 127.0.0.1 has never been observed
138
+ # to misbehave on any combination tested.
139
+ banner_url = "http://127.0.0.1:8000"
140
+ bar = "=" * 62
141
+ print(f"[bootstrap] complete in {elapsed:.1f}s", flush=True)
142
+ print(bar, flush=True)
143
+ print(f" Lambda ERP is READY — open {banner_url} in your browser", flush=True)
144
+ if public_demo:
145
+ print(f" Click 'Enter Live Demo' on the login page to start.", flush=True)
146
+ else:
147
+ print(f" First user to register on the login page becomes the admin.", flush=True)
148
+ print(f" (requests sent before this line were queued at the TCP layer)", flush=True)
149
+ print(bar, flush=True)
150
+
151
+
152
+ DEMO_FOREIGN_CURRENCY = "EUR"
153
+
154
+
155
+ def ensure_demo_foreign_currency_activity(db, company: str, base_currency: str) -> None:
156
+ """Seed a small EUR thread so the demo exercises the multi-currency paths:
157
+ a foreign-currency customer + sales invoice, a settlement at a different
158
+ rate (realized FX gain/loss), and an open foreign-currency bill.
159
+
160
+ Idempotent (keyed on the EUR customer), and additive — the amounts stay far
161
+ below the simulated top customers, so the deterministic chat-replay
162
+ snapshots don't move. Skipped when the base currency is already EUR.
163
+ """
164
+ foreign = DEMO_FOREIGN_CURRENCY
165
+ if base_currency == foreign:
166
+ print(f"[bootstrap] base currency is {foreign}; skipping foreign-currency demo thread", flush=True)
167
+ return
168
+
169
+ cust = "CUST-EUR-01"
170
+ if db.exists("Customer", cust):
171
+ return # already seeded on a previous boot
172
+
173
+ print(f"[bootstrap] seeding {foreign} multi-currency demo activity", flush=True)
174
+
175
+ # A EUR->base rate (carried forward) so any further EUR document or a
176
+ # period-end revaluation resolves a rate without manual entry.
177
+ rate_name = f"{foreign}-{base_currency}-2024-01-01"
178
+ if not db.exists("Currency Exchange", rate_name):
179
+ db.insert("Currency Exchange", _dict(
180
+ name=rate_name, date="2024-01-01",
181
+ from_currency=foreign, to_currency=base_currency, exchange_rate=1.10,
182
+ ))
183
+
184
+ db.insert("Customer", _dict(
185
+ name=cust, customer_name="Lumiere Audio SARL", customer_group="Commercial",
186
+ default_currency=foreign, email="compta@lumiere-audio.fr", country="FR",
187
+ ))
188
+ supp = "SUPP-EUR-01"
189
+ db.insert("Supplier", _dict(
190
+ name=supp, supplier_name="Metaux Lyon SAS", supplier_group="Raw Materials",
191
+ default_currency=foreign, email="ventes@metaux-lyon.fr", country="FR",
192
+ ))
193
+
194
+ from lambda_erp.accounting.sales_invoice import SalesInvoice
195
+ from lambda_erp.accounting.purchase_invoice import PurchaseInvoice
196
+ from lambda_erp.accounting.payment_entry import PaymentEntry
197
+
198
+ # EUR sale booked @1.10, then collected @1.05 -> realized FX loss in base.
199
+ inv = SalesInvoice(
200
+ customer=cust, company=company, posting_date="2026-03-15",
201
+ currency=foreign, conversion_rate=1.10,
202
+ items=[_dict(item_code="ITEM-001", qty=12, rate=125)],
203
+ )
204
+ inv.save()
205
+ inv.submit()
206
+ pe = PaymentEntry(
207
+ payment_type="Receive", party_type="Customer", party=cust, company=company,
208
+ posting_date="2026-04-05", paid_amount=inv.grand_total,
209
+ currency=foreign, conversion_rate=1.05,
210
+ references=[_dict(reference_doctype="Sales Invoice", reference_name=inv.name,
211
+ allocated_amount=inv.grand_total)],
212
+ )
213
+ pe.save()
214
+ pe.submit()
215
+
216
+ # An open EUR bill (foreign payable) — exercises foreign purchasing and
217
+ # leaves something for a period-end revaluation to restate.
218
+ bill = PurchaseInvoice(
219
+ supplier=supp, company=company, posting_date="2026-03-20",
220
+ currency=foreign, conversion_rate=1.10,
221
+ items=[_dict(item_code="SVC-001", qty=1, rate=800)],
222
+ )
223
+ bill.save()
224
+ bill.submit()
225
+
226
+
227
+ def ensure_demo_chat_records(company: str) -> None:
228
+ """Create every concrete artifact the scripted chat replay points at and
229
+ record their identifiers in Settings so load_demo_script can substitute
230
+ them into the template. Idempotent."""
231
+ db = get_db()
232
+
233
+ # --- quotation + purchase order (the opening demo references) ---------
234
+ existing_qtn = _get_setting(db, SETTING_DEMO_QUOTATION)
235
+ existing_po = _get_setting(db, SETTING_DEMO_PURCHASE_ORDER)
236
+ if not (
237
+ existing_qtn
238
+ and existing_po
239
+ and db.exists("Quotation", existing_qtn)
240
+ and db.exists("Purchase Order", existing_po)
241
+ ):
242
+ from lambda_erp.selling.quotation import Quotation
243
+ from lambda_erp.buying.purchase_order import PurchaseOrder
244
+
245
+ qtn = Quotation(
246
+ customer="CUST-001",
247
+ company=company,
248
+ transaction_date="2026-04-17",
249
+ items=[
250
+ _dict(item_code="ITEM-001", qty=10, rate=125),
251
+ _dict(item_code="SVC-001", qty=1, rate=150),
252
+ ],
253
+ )
254
+ qtn.save()
255
+ qtn.submit()
256
+
257
+ po = PurchaseOrder(
258
+ supplier="SUPP-005",
259
+ company=company,
260
+ transaction_date="2026-04-17",
261
+ items=[
262
+ _dict(item_code="ITEM-001", qty=20, rate=100),
263
+ ],
264
+ )
265
+ po.save()
266
+ po.submit()
267
+
268
+ _set_setting(db, SETTING_DEMO_QUOTATION, qtn.name)
269
+ _set_setting(db, SETTING_DEMO_PURCHASE_ORDER, po.name)
270
+ _set_setting(db, SETTING_DEMO_COMPANY, company)
271
+
272
+ # --- top customer ranking + last invoices ----------------------------
273
+ _ensure_top_customer_snapshots(db)
274
+
275
+ # --- custom analytics draft (Top 7 Customers by Revenue) -------------
276
+ _ensure_top7_report_draft(db)
277
+
278
+ # --- Redstone "8 more hours of project management" sales invoice -----
279
+ _ensure_redstone_project_management_sinv(db, company)
280
+
281
+
282
+ def _ensure_top_customer_snapshots(db) -> None:
283
+ """Compute the top-3 revenue customers from the simulator output and
284
+ snapshot the last-invoice line items for each. Written to Settings so
285
+ the scripted reply is accurate and doesn't depend on the LLM."""
286
+ if _get_setting(db, SETTING_DEMO_TOP1_ID):
287
+ return # already snapshotted
288
+
289
+ top_rows = db.sql(
290
+ """
291
+ SELECT customer,
292
+ customer_name,
293
+ SUM(net_total) AS revenue,
294
+ COUNT(*) AS invoice_count
295
+ FROM "Sales Invoice"
296
+ WHERE docstatus = 1 AND IFNULL(is_return, 0) = 0
297
+ GROUP BY customer, customer_name
298
+ ORDER BY revenue DESC
299
+ LIMIT 3
300
+ """
301
+ )
302
+ if len(top_rows) < 3:
303
+ return
304
+
305
+ slots = [
306
+ (SETTING_DEMO_TOP1_ID, SETTING_DEMO_TOP1_NAME, SETTING_DEMO_TOP1_REVENUE,
307
+ SETTING_DEMO_TOP1_INVOICES, SETTING_DEMO_TOP1_LAST_INV,
308
+ SETTING_DEMO_TOP1_LAST_INV_DATE, SETTING_DEMO_TOP1_LAST_INV_ITEMS),
309
+ (SETTING_DEMO_TOP2_ID, SETTING_DEMO_TOP2_NAME, SETTING_DEMO_TOP2_REVENUE,
310
+ None, SETTING_DEMO_TOP2_LAST_INV,
311
+ SETTING_DEMO_TOP2_LAST_INV_DATE, SETTING_DEMO_TOP2_LAST_INV_ITEMS),
312
+ (SETTING_DEMO_TOP3_ID, SETTING_DEMO_TOP3_NAME, SETTING_DEMO_TOP3_REVENUE,
313
+ None, SETTING_DEMO_TOP3_LAST_INV,
314
+ SETTING_DEMO_TOP3_LAST_INV_DATE, SETTING_DEMO_TOP3_LAST_INV_ITEMS),
315
+ ]
316
+ for row, slot in zip(top_rows, slots):
317
+ id_key, name_key, rev_key, inv_key, last_inv_key, last_inv_date_key, items_key = slot
318
+ _set_setting(db, id_key, row["customer"])
319
+ _set_setting(db, name_key, row["customer_name"] or row["customer"])
320
+ _set_setting(db, rev_key, _format_money(row["revenue"]))
321
+ if inv_key:
322
+ _set_setting(db, inv_key, str(int(row["invoice_count"])))
323
+
324
+ last_inv = db.sql(
325
+ """
326
+ SELECT name, posting_date
327
+ FROM "Sales Invoice"
328
+ WHERE docstatus = 1 AND IFNULL(is_return, 0) = 0 AND customer = ?
329
+ ORDER BY posting_date DESC, name DESC
330
+ LIMIT 1
331
+ """,
332
+ [row["customer"]],
333
+ )
334
+ if not last_inv:
335
+ continue
336
+ inv = last_inv[0]
337
+ _set_setting(db, last_inv_key, inv["name"])
338
+ _set_setting(db, last_inv_date_key, str(inv["posting_date"]))
339
+
340
+ item_rows = db.sql(
341
+ """
342
+ SELECT item_name, item_code, qty, uom
343
+ FROM "Sales Invoice Item"
344
+ WHERE parent = ?
345
+ ORDER BY idx
346
+ """,
347
+ [inv["name"]],
348
+ )
349
+ items_md = "\n".join(
350
+ f" - **{r['item_name'] or r['item_code']}** — {_format_qty(r['qty'])} {r['uom'] or ''}".rstrip()
351
+ for r in item_rows
352
+ )
353
+ _set_setting(db, items_key, items_md)
354
+
355
+
356
+ def _ensure_top7_report_draft(db) -> None:
357
+ """Create the 'Top 7 Customers by Revenue' analytics draft the chat
358
+ replay links to, so clicking the bar-chart link opens a real report."""
359
+ if _get_setting(db, SETTING_DEMO_TOP7_REPORT_ID):
360
+ return
361
+
362
+ from api.routers.analytics import create_report_draft_record
363
+
364
+ transform_js = (
365
+ "const grouped = helpers.group(sales, ['customer', 'customer_name'], {\n"
366
+ " revenue: ['sum', 'net_total'],\n"
367
+ "});\n"
368
+ "const sorted = helpers.sortBy(grouped, 'revenue', 'desc');\n"
369
+ "const top = helpers.topN(sorted, 7);\n"
370
+ "const rows = top.map(function(r) { return {\n"
371
+ " customer: r.customer,\n"
372
+ " customer_name: r.customer_name || r.customer,\n"
373
+ " revenue: r.revenue,\n"
374
+ "}; });\n"
375
+ "return {\n"
376
+ " title: 'Top 7 Customers by Revenue',\n"
377
+ " kpis: [\n"
378
+ " { label: 'Total Revenue (Top 7)', value: helpers.sum(rows, 'revenue'), format: 'currency' },\n"
379
+ " { label: 'Customers Shown', value: rows.length, format: 'number' },\n"
380
+ " ],\n"
381
+ " tables: [\n"
382
+ " {\n"
383
+ " title: 'Top 7 Customers by Revenue',\n"
384
+ " columns: [\n"
385
+ " { key: 'customer_name', label: 'Customer', type: 'string' },\n"
386
+ " { key: 'revenue', label: 'Revenue', type: 'currency' },\n"
387
+ " ],\n"
388
+ " rows: rows,\n"
389
+ " },\n"
390
+ " ],\n"
391
+ " charts: [\n"
392
+ " {\n"
393
+ " title: 'Top 7 Customers by Revenue',\n"
394
+ " type: 'bar',\n"
395
+ " x: 'customer_name',\n"
396
+ " y: 'revenue',\n"
397
+ " dataTable: 'Top 7 Customers by Revenue',\n"
398
+ " },\n"
399
+ " ],\n"
400
+ "};"
401
+ )
402
+
403
+ payload = {
404
+ "title": "Top 7 Customers by Revenue",
405
+ "description": "Top 7 customers ranked by total submitted sales invoice revenue (returns excluded).",
406
+ "data_requests": [
407
+ {
408
+ "name": "sales",
409
+ "dataset": "sales_invoices",
410
+ "fields": ["customer", "customer_name", "net_total", "is_return"],
411
+ "filters": {"is_return": 0},
412
+ }
413
+ ],
414
+ "transform_js": transform_js,
415
+ }
416
+ # Own the draft as the public_manager so the demo user can see it in
417
+ # the Custom Analytics sidebar and open it without hitting the 403
418
+ # "you do not have access" check. The user's `name` is generated at
419
+ # random by create_public_manager, so we look it up rather than
420
+ # hardcoding.
421
+ pm_user = _public_manager_user(db)
422
+ draft = create_report_draft_record(payload, user=pm_user)
423
+ _set_setting(db, SETTING_DEMO_TOP7_REPORT_ID, draft["id"])
424
+
425
+
426
+ def _ensure_redstone_project_management_sinv(db, company: str) -> None:
427
+ """Pre-create the 8-hour Project Management sales invoice for the #2
428
+ customer so the demo's "submit it" / "yup" step lands on a real,
429
+ already-submitted invoice.
430
+
431
+ We look up the customer dynamically (whoever is top-2) so the narrative
432
+ stays self-consistent even if simulator output shifts slightly.
433
+ """
434
+ existing_sinv = _get_setting(db, SETTING_DEMO_REDSTONE_SINV)
435
+ if existing_sinv and db.exists("Sales Invoice", existing_sinv):
436
+ return
437
+
438
+ top2_customer = _get_setting(db, SETTING_DEMO_TOP2_ID)
439
+ if not top2_customer:
440
+ return
441
+
442
+ from lambda_erp.accounting.sales_invoice import SalesInvoice
443
+ from datetime import date, timedelta
444
+
445
+ posting = date.fromisoformat(DEMO_CHAT_DATE)
446
+ due = posting + timedelta(days=30)
447
+
448
+ sinv = SalesInvoice(
449
+ customer=top2_customer,
450
+ company=company,
451
+ posting_date=DEMO_CHAT_DATE,
452
+ due_date=due.isoformat(),
453
+ items=[_dict(item_code="SVC-005", qty=8, rate=250)],
454
+ )
455
+ sinv.save()
456
+ sinv.submit()
457
+
458
+ _set_setting(db, SETTING_DEMO_REDSTONE_SINV, sinv.name)
459
+ _set_setting(db, SETTING_DEMO_REDSTONE_SINV_DATE, DEMO_CHAT_DATE)
460
+ _set_setting(db, SETTING_DEMO_REDSTONE_DUE_DATE, due.isoformat())
461
+
462
+
463
+ def _format_money(value) -> str:
464
+ try:
465
+ return f"{float(value):,.2f}"
466
+ except Exception:
467
+ return "0.00"
468
+
469
+
470
+ def _format_qty(value) -> str:
471
+ try:
472
+ f = float(value)
473
+ return str(int(f)) if f == int(f) else f"{f:g}"
474
+ except Exception:
475
+ return str(value)
476
+
477
+
478
+ def _public_manager_user(db) -> dict | None:
479
+ rows = db.sql(
480
+ 'SELECT name, role FROM "User" WHERE role = "public_manager" LIMIT 1'
481
+ )
482
+ if not rows:
483
+ return None
484
+ return {"name": rows[0]["name"], "role": rows[0]["role"]}
485
+
486
+
487
+ def _get_setting(db, key: str) -> str:
488
+ rows = db.sql('SELECT value FROM "Settings" WHERE key = ?', [key])
489
+ return rows[0]["value"] if rows else ""
490
+
491
+
492
+ def _set_setting(db, key: str, value: str) -> None:
493
+ existing = db.sql('SELECT key FROM "Settings" WHERE key = ?', [key])
494
+ if existing:
495
+ db.sql('UPDATE "Settings" SET value = ? WHERE key = ?', [str(value), key])
496
+ else:
497
+ db.sql('INSERT INTO "Settings" (key, value) VALUES (?, ?)', [key, str(value)])
498
+ db.conn.commit()