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.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- 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()
|