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
@@ -0,0 +1,1009 @@
1
+ """Analytics and client-runtime reporting endpoints.
2
+
3
+ This module now exposes two layers:
4
+
5
+ 1. The original preset analytics API used by the current /reports/analytics page.
6
+ 2. A semantic dataset registry + bounded data-fetch endpoint for client-side
7
+ programmable reports. The browser can request approved datasets, then run an
8
+ arbitrary JS transform locally in a worker without direct database access.
9
+ """
10
+
11
+ import json
12
+ import uuid
13
+ from typing import Any
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Query
16
+ from pydantic import BaseModel, Field
17
+
18
+ from api.auth import require_role
19
+ from lambda_erp.database import get_db
20
+ from lambda_erp.utils import flt
21
+
22
+ router = APIRouter(prefix="/reports", tags=["analytics"])
23
+
24
+ _viewer = Depends(require_role("viewer"))
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Legacy preset analytics
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _TIME_BUCKETS = {
32
+ "month": "strftime('%Y-%m', {date_col})",
33
+ "quarter": "strftime('%Y-Q', {date_col}) || ((CAST(strftime('%m', {date_col}) AS INTEGER) + 2) / 3)",
34
+ "year": "strftime('%Y', {date_col})",
35
+ }
36
+
37
+
38
+ def _bucket_expr(group_by: str, date_col: str) -> str:
39
+ tmpl = _TIME_BUCKETS.get(group_by)
40
+ if tmpl:
41
+ return tmpl.format(date_col=date_col)
42
+ return group_by
43
+
44
+
45
+ def _date_filter(filters, date_col, params):
46
+ clauses = []
47
+ if filters.get("from_date"):
48
+ clauses.append(f"{date_col} >= ?")
49
+ params.append(filters["from_date"])
50
+ if filters.get("to_date"):
51
+ clauses.append(f"{date_col} <= ?")
52
+ params.append(filters["to_date"])
53
+ return clauses
54
+
55
+
56
+ def _company_filter(filters, table_alias, params):
57
+ if filters.get("company"):
58
+ params.append(filters["company"])
59
+ return [f"{table_alias}.company = ?"]
60
+ return []
61
+
62
+
63
+ def _sales_metric(db, group_by, filters, *, is_return):
64
+ where = ["si.docstatus = 1", f"COALESCE(si.is_return, 0) = {1 if is_return else 0}"]
65
+ params = []
66
+ where += _date_filter(filters, "si.posting_date", params)
67
+ where += _company_filter(filters, "si", params)
68
+
69
+ if group_by == "item":
70
+ sql = f"""
71
+ SELECT sii.item_code AS bucket, SUM(sii.net_amount) AS value
72
+ FROM "Sales Invoice Item" sii
73
+ JOIN "Sales Invoice" si ON si.name = sii.parent
74
+ WHERE {' AND '.join(where)}
75
+ GROUP BY bucket ORDER BY value DESC LIMIT 30
76
+ """
77
+ else:
78
+ bucket = _bucket_expr(group_by, "si.posting_date") if group_by in _TIME_BUCKETS else "si.customer"
79
+ order = "bucket" if group_by in _TIME_BUCKETS else "value DESC"
80
+ sql = f"""
81
+ SELECT {bucket} AS bucket, SUM(si.grand_total) AS value
82
+ FROM "Sales Invoice" si
83
+ WHERE {' AND '.join(where)}
84
+ GROUP BY bucket ORDER BY {order}
85
+ {'LIMIT 30' if group_by == 'customer' else ''}
86
+ """
87
+ rows = db.sql(sql, params)
88
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
89
+
90
+
91
+ def _purchases(db, group_by, filters):
92
+ where = ["pi.docstatus = 1", "COALESCE(pi.is_return, 0) = 0"]
93
+ params = []
94
+ where += _date_filter(filters, "pi.posting_date", params)
95
+ where += _company_filter(filters, "pi", params)
96
+
97
+ if group_by == "item":
98
+ sql = f"""
99
+ SELECT pii.item_code AS bucket, SUM(pii.net_amount) AS value
100
+ FROM "Purchase Invoice Item" pii
101
+ JOIN "Purchase Invoice" pi ON pi.name = pii.parent
102
+ WHERE {' AND '.join(where)}
103
+ GROUP BY bucket ORDER BY value DESC LIMIT 30
104
+ """
105
+ else:
106
+ bucket = _bucket_expr(group_by, "pi.posting_date") if group_by in _TIME_BUCKETS else "pi.supplier"
107
+ order = "bucket" if group_by in _TIME_BUCKETS else "value DESC"
108
+ sql = f"""
109
+ SELECT {bucket} AS bucket, SUM(pi.grand_total) AS value
110
+ FROM "Purchase Invoice" pi
111
+ WHERE {' AND '.join(where)}
112
+ GROUP BY bucket ORDER BY {order}
113
+ {'LIMIT 30' if group_by == 'supplier' else ''}
114
+ """
115
+ rows = db.sql(sql, params)
116
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
117
+
118
+
119
+ def _payments(db, group_by, filters, *, payment_type, party_type):
120
+ where = ["pe.docstatus = 1", "pe.payment_type = ?", "pe.party_type = ?"]
121
+ params = [payment_type, party_type]
122
+ where += _date_filter(filters, "pe.posting_date", params)
123
+ where += _company_filter(filters, "pe", params)
124
+
125
+ bucket = _bucket_expr(group_by, "pe.posting_date") if group_by in _TIME_BUCKETS else "pe.party"
126
+ order = "bucket" if group_by in _TIME_BUCKETS else "value DESC"
127
+ sql = f"""
128
+ SELECT {bucket} AS bucket, SUM(pe.paid_amount) AS value
129
+ FROM "Payment Entry" pe
130
+ WHERE {' AND '.join(where)}
131
+ GROUP BY bucket ORDER BY {order}
132
+ {'LIMIT 30' if group_by in ('customer', 'supplier') else ''}
133
+ """
134
+ rows = db.sql(sql, params)
135
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
136
+
137
+
138
+ def _outstanding_ar(db, _group_by, filters):
139
+ where = ["docstatus = 1", "COALESCE(is_return, 0) = 0", "outstanding_amount > 0"]
140
+ params = []
141
+ if filters.get("company"):
142
+ where.append("company = ?")
143
+ params.append(filters["company"])
144
+ sql = f"""
145
+ SELECT customer AS bucket, SUM(outstanding_amount) AS value
146
+ FROM "Sales Invoice"
147
+ WHERE {' AND '.join(where)}
148
+ GROUP BY customer ORDER BY value DESC LIMIT 30
149
+ """
150
+ rows = db.sql(sql, params)
151
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
152
+
153
+
154
+ def _outstanding_ap(db, _group_by, filters):
155
+ where = ["docstatus = 1", "COALESCE(is_return, 0) = 0", "outstanding_amount > 0"]
156
+ params = []
157
+ if filters.get("company"):
158
+ where.append("company = ?")
159
+ params.append(filters["company"])
160
+ sql = f"""
161
+ SELECT supplier AS bucket, SUM(outstanding_amount) AS value
162
+ FROM "Purchase Invoice"
163
+ WHERE {' AND '.join(where)}
164
+ GROUP BY supplier ORDER BY value DESC LIMIT 30
165
+ """
166
+ rows = db.sql(sql, params)
167
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
168
+
169
+
170
+ def _stock_value(db, group_by, filters):
171
+ where = []
172
+ params: list[Any] = []
173
+ if filters.get("company"):
174
+ where.append("w.company = ?")
175
+ params.append(filters["company"])
176
+ if group_by == "warehouse":
177
+ sql = f"""
178
+ SELECT b.warehouse AS bucket, SUM(b.stock_value) AS value
179
+ FROM "Bin" b
180
+ LEFT JOIN "Warehouse" w ON w.name = b.warehouse
181
+ {'WHERE ' + ' AND '.join(where) if where else ''}
182
+ GROUP BY b.warehouse ORDER BY value DESC
183
+ """
184
+ else:
185
+ sql = f"""
186
+ SELECT b.item_code AS bucket, SUM(b.stock_value) AS value
187
+ FROM "Bin" b
188
+ LEFT JOIN "Warehouse" w ON w.name = b.warehouse
189
+ {'WHERE ' + ' AND '.join(where) if where else ''}
190
+ GROUP BY b.item_code ORDER BY value DESC LIMIT 30
191
+ """
192
+ rows = db.sql(sql, params)
193
+ return [{"label": r["bucket"] or "—", "value": flt(r["value"], 2)} for r in rows]
194
+
195
+
196
+ METRICS: dict[str, dict[str, Any]] = {
197
+ "sales_revenue": {
198
+ "label": "Sales Revenue",
199
+ "group_by": ["month", "quarter", "year", "customer", "item"],
200
+ "time_based": True,
201
+ "handler": lambda db, g, f: _sales_metric(db, g, f, is_return=False),
202
+ },
203
+ "sales_returns": {
204
+ "label": "Sales Returns",
205
+ "group_by": ["month", "quarter", "year", "customer"],
206
+ "time_based": True,
207
+ "handler": lambda db, g, f: _sales_metric(db, g, f, is_return=True),
208
+ },
209
+ "purchases": {
210
+ "label": "Purchases",
211
+ "group_by": ["month", "quarter", "year", "supplier", "item"],
212
+ "time_based": True,
213
+ "handler": _purchases,
214
+ },
215
+ "payments_received": {
216
+ "label": "Payments Received",
217
+ "group_by": ["month", "quarter", "year", "customer"],
218
+ "time_based": True,
219
+ "handler": lambda db, g, f: _payments(db, g, f, payment_type="Receive", party_type="Customer"),
220
+ },
221
+ "payments_made": {
222
+ "label": "Payments Made",
223
+ "group_by": ["month", "quarter", "year", "supplier"],
224
+ "time_based": True,
225
+ "handler": lambda db, g, f: _payments(db, g, f, payment_type="Pay", party_type="Supplier"),
226
+ },
227
+ "outstanding_ar": {
228
+ "label": "Outstanding AR",
229
+ "group_by": ["customer"],
230
+ "time_based": False,
231
+ "handler": _outstanding_ar,
232
+ },
233
+ "outstanding_ap": {
234
+ "label": "Outstanding AP",
235
+ "group_by": ["supplier"],
236
+ "time_based": False,
237
+ "handler": _outstanding_ap,
238
+ },
239
+ "stock_value": {
240
+ "label": "Stock Value",
241
+ "group_by": ["warehouse", "item"],
242
+ "time_based": False,
243
+ "handler": _stock_value,
244
+ },
245
+ }
246
+
247
+
248
+ def _chart_type(group_by: str) -> str:
249
+ return "line" if group_by in _TIME_BUCKETS else "bar"
250
+
251
+
252
+ @router.get("/analytics/metrics")
253
+ def list_metrics(_user: dict = _viewer):
254
+ return {
255
+ "metrics": [
256
+ {
257
+ "metric": key,
258
+ "label": m["label"],
259
+ "group_by": m["group_by"],
260
+ "time_based": m["time_based"],
261
+ }
262
+ for key, m in METRICS.items()
263
+ ],
264
+ }
265
+
266
+
267
+ @router.get("/analytics")
268
+ def analytics(
269
+ metric: str = Query(..., description="Metric key (see /analytics/metrics)"),
270
+ group_by: str = Query(..., description="Dimension to group by"),
271
+ from_date: str | None = None,
272
+ to_date: str | None = None,
273
+ company: str | None = None,
274
+ _user: dict = _viewer,
275
+ ):
276
+ meta = METRICS.get(metric)
277
+ if not meta:
278
+ raise HTTPException(400, f"Unknown metric '{metric}'. Try /analytics/metrics.")
279
+ if group_by not in meta["group_by"]:
280
+ raise HTTPException(
281
+ 400,
282
+ f"Metric '{metric}' doesn't support group_by='{group_by}'. "
283
+ f"Allowed: {', '.join(meta['group_by'])}",
284
+ )
285
+
286
+ filters = {"from_date": from_date, "to_date": to_date, "company": company}
287
+ rows = meta["handler"](get_db(), group_by, filters)
288
+ return {
289
+ "metric": metric,
290
+ "metric_label": meta["label"],
291
+ "group_by": group_by,
292
+ "chart_type": _chart_type(group_by),
293
+ "time_based": meta["time_based"],
294
+ "from_date": from_date,
295
+ "to_date": to_date,
296
+ "company": company,
297
+ "rows": rows,
298
+ "total": flt(sum(flt(r["value"]) for r in rows), 2),
299
+ }
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Client-side report runtime
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ class RuntimeDataRequest(BaseModel):
308
+ name: str | None = None
309
+ dataset: str
310
+ fields: list[str] | None = None
311
+ filters: dict[str, Any] = Field(default_factory=dict)
312
+ limit: int | None = None
313
+
314
+
315
+ class RuntimeFetchPayload(BaseModel):
316
+ requests: list[RuntimeDataRequest]
317
+
318
+
319
+ class ReportDraftPayload(BaseModel):
320
+ title: str
321
+ description: str | None = None
322
+ data_requests: list[RuntimeDataRequest]
323
+ transform_js: str
324
+
325
+
326
+ class ReportDraftUpdatePayload(BaseModel):
327
+ title: str | None = None
328
+ description: str | None = None
329
+ data_requests: list[RuntimeDataRequest] | None = None
330
+ transform_js: str | None = None
331
+
332
+
333
+ SEMANTIC_DATASETS: dict[str, dict[str, Any]] = {
334
+ "sales_invoices": {
335
+ "label": "Sales Invoices",
336
+ "description": "Submitted sales invoices, including returns and outstanding amounts.",
337
+ "sql_from": 'FROM "Sales Invoice" si',
338
+ "fields": {
339
+ "name": "string",
340
+ "docstatus": "number",
341
+ "posting_date": "date",
342
+ "company": "string",
343
+ "customer": "string",
344
+ "customer_name": "string",
345
+ "net_total": "number",
346
+ "grand_total": "number",
347
+ "outstanding_amount": "number",
348
+ "is_return": "boolean",
349
+ },
350
+ "field_sql": {
351
+ "name": "si.name",
352
+ "docstatus": "si.docstatus",
353
+ "posting_date": "si.posting_date",
354
+ "company": "si.company",
355
+ "customer": "si.customer",
356
+ "customer_name": "si.customer_name",
357
+ "net_total": "si.net_total",
358
+ "grand_total": "si.grand_total",
359
+ "outstanding_amount": "si.outstanding_amount",
360
+ "is_return": "COALESCE(si.is_return, 0)",
361
+ },
362
+ "default_where": ["si.docstatus = 1"],
363
+ "filter_fields": {"company", "customer", "posting_date", "is_return", "name"},
364
+ "default_limit": 500,
365
+ "max_limit": 5000,
366
+ "default_order_by": "si.posting_date DESC, si.name DESC",
367
+ },
368
+ "sales_invoice_lines": {
369
+ "label": "Sales Invoice Lines",
370
+ "description": "Submitted sales invoice lines joined to their invoice header.",
371
+ "sql_from": 'FROM "Sales Invoice Item" sii JOIN "Sales Invoice" si ON si.name = sii.parent',
372
+ "fields": {
373
+ "invoice": "string",
374
+ "docstatus": "number",
375
+ "posting_date": "date",
376
+ "company": "string",
377
+ "customer": "string",
378
+ "item_code": "string",
379
+ "warehouse": "string",
380
+ "qty": "number",
381
+ "net_amount": "number",
382
+ "income_account": "string",
383
+ "cost_center": "string",
384
+ "is_return": "boolean",
385
+ },
386
+ "field_sql": {
387
+ "invoice": "si.name",
388
+ "docstatus": "si.docstatus",
389
+ "posting_date": "si.posting_date",
390
+ "company": "si.company",
391
+ "customer": "si.customer",
392
+ "item_code": "sii.item_code",
393
+ "warehouse": "sii.warehouse",
394
+ "qty": "sii.qty",
395
+ "net_amount": "sii.net_amount",
396
+ "income_account": "sii.income_account",
397
+ "cost_center": "sii.cost_center",
398
+ "is_return": "COALESCE(si.is_return, 0)",
399
+ },
400
+ "default_where": ["si.docstatus = 1"],
401
+ "filter_fields": {"company", "customer", "posting_date", "item_code", "warehouse", "is_return"},
402
+ "default_limit": 1000,
403
+ "max_limit": 10000,
404
+ "default_order_by": "si.posting_date DESC, si.name DESC, sii.idx ASC",
405
+ },
406
+ "purchase_invoices": {
407
+ "label": "Purchase Invoices",
408
+ "description": "Submitted purchase invoices, including returns and outstanding amounts.",
409
+ "sql_from": 'FROM "Purchase Invoice" pi',
410
+ "fields": {
411
+ "name": "string",
412
+ "docstatus": "number",
413
+ "posting_date": "date",
414
+ "company": "string",
415
+ "supplier": "string",
416
+ "supplier_name": "string",
417
+ "net_total": "number",
418
+ "grand_total": "number",
419
+ "outstanding_amount": "number",
420
+ "is_return": "boolean",
421
+ },
422
+ "field_sql": {
423
+ "name": "pi.name",
424
+ "docstatus": "pi.docstatus",
425
+ "posting_date": "pi.posting_date",
426
+ "company": "pi.company",
427
+ "supplier": "pi.supplier",
428
+ "supplier_name": "pi.supplier_name",
429
+ "net_total": "pi.net_total",
430
+ "grand_total": "pi.grand_total",
431
+ "outstanding_amount": "pi.outstanding_amount",
432
+ "is_return": "COALESCE(pi.is_return, 0)",
433
+ },
434
+ "default_where": ["pi.docstatus = 1"],
435
+ "filter_fields": {"company", "supplier", "posting_date", "is_return", "name"},
436
+ "default_limit": 500,
437
+ "max_limit": 5000,
438
+ "default_order_by": "pi.posting_date DESC, pi.name DESC",
439
+ },
440
+ "purchase_invoice_lines": {
441
+ "label": "Purchase Invoice Lines",
442
+ "description": "Submitted purchase invoice lines joined to their invoice header.",
443
+ "sql_from": 'FROM "Purchase Invoice Item" pii JOIN "Purchase Invoice" pi ON pi.name = pii.parent',
444
+ "fields": {
445
+ "invoice": "string",
446
+ "docstatus": "number",
447
+ "posting_date": "date",
448
+ "company": "string",
449
+ "supplier": "string",
450
+ "item_code": "string",
451
+ "warehouse": "string",
452
+ "qty": "number",
453
+ "net_amount": "number",
454
+ "expense_account": "string",
455
+ "cost_center": "string",
456
+ "is_return": "boolean",
457
+ },
458
+ "field_sql": {
459
+ "invoice": "pi.name",
460
+ "docstatus": "pi.docstatus",
461
+ "posting_date": "pi.posting_date",
462
+ "company": "pi.company",
463
+ "supplier": "pi.supplier",
464
+ "item_code": "pii.item_code",
465
+ "warehouse": "pii.warehouse",
466
+ "qty": "pii.qty",
467
+ "net_amount": "pii.net_amount",
468
+ "expense_account": "pii.expense_account",
469
+ "cost_center": "pii.cost_center",
470
+ "is_return": "COALESCE(pi.is_return, 0)",
471
+ },
472
+ "default_where": ["pi.docstatus = 1"],
473
+ "filter_fields": {"company", "supplier", "posting_date", "item_code", "warehouse", "is_return"},
474
+ "default_limit": 1000,
475
+ "max_limit": 10000,
476
+ "default_order_by": "pi.posting_date DESC, pi.name DESC, pii.idx ASC",
477
+ },
478
+ "payments": {
479
+ "label": "Payments",
480
+ "description": "Submitted payment entries with party and bank/control account context.",
481
+ "sql_from": 'FROM "Payment Entry" pe',
482
+ "fields": {
483
+ "name": "string",
484
+ "docstatus": "number",
485
+ "posting_date": "date",
486
+ "company": "string",
487
+ "payment_type": "string",
488
+ "party_type": "string",
489
+ "party": "string",
490
+ "paid_amount": "number",
491
+ "received_amount": "number",
492
+ "paid_from": "string",
493
+ "paid_to": "string",
494
+ },
495
+ "field_sql": {
496
+ "name": "pe.name",
497
+ "docstatus": "pe.docstatus",
498
+ "posting_date": "pe.posting_date",
499
+ "company": "pe.company",
500
+ "payment_type": "pe.payment_type",
501
+ "party_type": "pe.party_type",
502
+ "party": "pe.party",
503
+ "paid_amount": "pe.paid_amount",
504
+ "received_amount": "pe.received_amount",
505
+ "paid_from": "pe.paid_from",
506
+ "paid_to": "pe.paid_to",
507
+ },
508
+ "default_where": ["pe.docstatus = 1"],
509
+ "filter_fields": {"company", "posting_date", "payment_type", "party_type", "party"},
510
+ "default_limit": 1000,
511
+ "max_limit": 10000,
512
+ "default_order_by": "pe.posting_date DESC, pe.name DESC",
513
+ },
514
+ "ar_open_items": {
515
+ "label": "AR Open Items",
516
+ "description": "Open submitted non-return sales invoices with positive outstanding amounts.",
517
+ "sql_from": 'FROM "Sales Invoice" si',
518
+ "fields": {
519
+ "invoice": "string",
520
+ "posting_date": "date",
521
+ "company": "string",
522
+ "customer": "string",
523
+ "customer_name": "string",
524
+ "grand_total": "number",
525
+ "outstanding_amount": "number",
526
+ },
527
+ "field_sql": {
528
+ "invoice": "si.name",
529
+ "posting_date": "si.posting_date",
530
+ "company": "si.company",
531
+ "customer": "si.customer",
532
+ "customer_name": "si.customer_name",
533
+ "grand_total": "si.grand_total",
534
+ "outstanding_amount": "si.outstanding_amount",
535
+ },
536
+ "default_where": [
537
+ "si.docstatus = 1",
538
+ "COALESCE(si.is_return, 0) = 0",
539
+ "si.outstanding_amount > 0",
540
+ ],
541
+ "filter_fields": {"company", "customer", "posting_date"},
542
+ "default_limit": 1000,
543
+ "max_limit": 10000,
544
+ "default_order_by": "si.outstanding_amount DESC, si.posting_date DESC",
545
+ },
546
+ "ap_open_items": {
547
+ "label": "AP Open Items",
548
+ "description": "Open submitted non-return purchase invoices with positive outstanding amounts.",
549
+ "sql_from": 'FROM "Purchase Invoice" pi',
550
+ "fields": {
551
+ "invoice": "string",
552
+ "posting_date": "date",
553
+ "company": "string",
554
+ "supplier": "string",
555
+ "supplier_name": "string",
556
+ "grand_total": "number",
557
+ "outstanding_amount": "number",
558
+ },
559
+ "field_sql": {
560
+ "invoice": "pi.name",
561
+ "posting_date": "pi.posting_date",
562
+ "company": "pi.company",
563
+ "supplier": "pi.supplier",
564
+ "supplier_name": "pi.supplier_name",
565
+ "grand_total": "pi.grand_total",
566
+ "outstanding_amount": "pi.outstanding_amount",
567
+ },
568
+ "default_where": [
569
+ "pi.docstatus = 1",
570
+ "COALESCE(pi.is_return, 0) = 0",
571
+ "pi.outstanding_amount > 0",
572
+ ],
573
+ "filter_fields": {"company", "supplier", "posting_date"},
574
+ "default_limit": 1000,
575
+ "max_limit": 10000,
576
+ "default_order_by": "pi.outstanding_amount DESC, pi.posting_date DESC",
577
+ },
578
+ "stock_balances": {
579
+ "label": "Stock Balances",
580
+ "description": "Current stock positions from Bin joined to Warehouse for company-aware filtering.",
581
+ "sql_from": 'FROM "Bin" b LEFT JOIN "Warehouse" w ON w.name = b.warehouse',
582
+ "fields": {
583
+ "item_code": "string",
584
+ "warehouse": "string",
585
+ "company": "string",
586
+ "actual_qty": "number",
587
+ "valuation_rate": "number",
588
+ "stock_value": "number",
589
+ },
590
+ "field_sql": {
591
+ "item_code": "b.item_code",
592
+ "warehouse": "b.warehouse",
593
+ "company": "w.company",
594
+ "actual_qty": "b.actual_qty",
595
+ "valuation_rate": "b.valuation_rate",
596
+ "stock_value": "b.stock_value",
597
+ },
598
+ "default_where": [],
599
+ "filter_fields": {"company", "warehouse", "item_code"},
600
+ "default_limit": 1000,
601
+ "max_limit": 10000,
602
+ "default_order_by": "b.stock_value DESC, b.item_code ASC",
603
+ },
604
+ "stock_movements": {
605
+ "label": "Stock Movements",
606
+ "description": (
607
+ "Stock ledger entries — one row per inflow/outflow/transfer leg per "
608
+ "item × warehouse. Use `abs_qty` for volume moved (units regardless "
609
+ "of direction) and `actual_qty` for net flow (+ in, − out). A "
610
+ "warehouse-to-warehouse transfer records two legs (out + in)."
611
+ ),
612
+ "sql_from": 'FROM "Stock Ledger Entry" sle',
613
+ "fields": {
614
+ "item_code": "string",
615
+ "warehouse": "string",
616
+ "company": "string",
617
+ "posting_date": "date",
618
+ "voucher_type": "string",
619
+ "voucher_no": "string",
620
+ "actual_qty": "number",
621
+ "abs_qty": "number",
622
+ "stock_value_difference": "number",
623
+ },
624
+ "field_sql": {
625
+ "item_code": "sle.item_code",
626
+ "warehouse": "sle.warehouse",
627
+ "company": "sle.company",
628
+ "posting_date": "sle.posting_date",
629
+ "voucher_type": "sle.voucher_type",
630
+ "voucher_no": "sle.voucher_no",
631
+ "actual_qty": "sle.actual_qty",
632
+ # Volume moved: units regardless of direction. Derived so a measure
633
+ # can SUM it (measures only wrap a field, they can't apply ABS()).
634
+ "abs_qty": "ABS(sle.actual_qty)",
635
+ "stock_value_difference": "sle.stock_value_difference",
636
+ },
637
+ "default_where": ["sle.is_cancelled = 0"],
638
+ "filter_fields": {"company", "warehouse", "item_code", "voucher_type", "posting_date"},
639
+ "default_limit": 1000,
640
+ "max_limit": 10000,
641
+ "default_order_by": "sle.posting_date DESC, sle.name DESC",
642
+ },
643
+ }
644
+
645
+
646
+ def _build_dataset_filter_clauses(spec: dict[str, Any], filters: dict[str, Any], params: list[Any]) -> list[str]:
647
+ clauses = list(spec.get("default_where", []))
648
+ for field, value in (filters or {}).items():
649
+ if field not in spec["filter_fields"]:
650
+ raise HTTPException(400, f"Dataset '{spec['label']}' does not allow filtering on '{field}'")
651
+ column = spec["field_sql"][field]
652
+ if value is None or value == "":
653
+ continue
654
+ if isinstance(value, dict):
655
+ if "from" in value and value["from"] not in (None, ""):
656
+ clauses.append(f"{column} >= ?")
657
+ params.append(value["from"])
658
+ if "to" in value and value["to"] not in (None, ""):
659
+ clauses.append(f"{column} <= ?")
660
+ params.append(value["to"])
661
+ continue
662
+ if isinstance(value, list):
663
+ if not value:
664
+ continue
665
+ placeholders = ", ".join("?" for _ in value)
666
+ clauses.append(f"{column} IN ({placeholders})")
667
+ params.extend(value)
668
+ continue
669
+ clauses.append(f"{column} = ?")
670
+ params.append(value)
671
+ return clauses
672
+
673
+
674
+ def _fetch_semantic_dataset(request: RuntimeDataRequest) -> dict[str, Any]:
675
+ db = get_db()
676
+ spec = SEMANTIC_DATASETS.get(request.dataset)
677
+ if not spec:
678
+ raise HTTPException(400, f"Unknown semantic dataset '{request.dataset}'")
679
+
680
+ requested_fields = request.fields or list(spec["fields"].keys())
681
+ invalid = [field for field in requested_fields if field not in spec["fields"]]
682
+ if invalid:
683
+ raise HTTPException(
684
+ 400,
685
+ f"Dataset '{request.dataset}' does not expose fields: {', '.join(invalid)}",
686
+ )
687
+
688
+ limit = request.limit or spec["default_limit"]
689
+ limit = max(1, min(limit, spec["max_limit"]))
690
+
691
+ params: list[Any] = []
692
+ where = _build_dataset_filter_clauses(spec, request.filters, params)
693
+ select = ", ".join(f"{spec['field_sql'][field]} AS \"{field}\"" for field in requested_fields)
694
+ sql = f"""
695
+ SELECT {select}
696
+ {spec['sql_from']}
697
+ {'WHERE ' + ' AND '.join(where) if where else ''}
698
+ ORDER BY {spec['default_order_by']}
699
+ LIMIT {limit}
700
+ """
701
+ rows = [dict(row) for row in db.sql(sql, params)]
702
+ return {
703
+ "name": request.name or request.dataset,
704
+ "dataset": request.dataset,
705
+ "rows": rows,
706
+ "fields": {field: spec["fields"][field] for field in requested_fields},
707
+ "row_count": len(rows),
708
+ "truncated": len(rows) >= limit,
709
+ "limit": limit,
710
+ }
711
+
712
+
713
+ _AGG_OPS = {"sum", "count", "avg", "min", "max"}
714
+
715
+
716
+ def aggregate_semantic_dataset(
717
+ dataset: str,
718
+ group_by: list[str] | None,
719
+ measures: dict[str, list[str]],
720
+ filters: dict[str, Any] | None = None,
721
+ order_by: list[dict[str, str]] | None = None,
722
+ limit: int | None = None,
723
+ ) -> dict[str, Any]:
724
+ """Run a whitelisted GROUP BY aggregation over a semantic dataset.
725
+
726
+ `measures` is shaped like `{alias: [op, field]}` where `op` is one of
727
+ sum / count / avg / min / max. For `count`, field can be omitted or
728
+ set to "*".
729
+ """
730
+ spec = SEMANTIC_DATASETS.get(dataset)
731
+ if not spec:
732
+ raise HTTPException(400, f"Unknown semantic dataset '{dataset}'")
733
+
734
+ group_by = group_by or []
735
+ invalid_groups = [f for f in group_by if f not in spec["fields"]]
736
+ if invalid_groups:
737
+ raise HTTPException(
738
+ 400,
739
+ f"Dataset '{dataset}' does not expose fields: {', '.join(invalid_groups)}",
740
+ )
741
+
742
+ if not measures:
743
+ raise HTTPException(400, "At least one measure is required.")
744
+
745
+ select_parts: list[str] = []
746
+ for field in group_by:
747
+ select_parts.append(f"{spec['field_sql'][field]} AS \"{field}\"")
748
+
749
+ for alias, definition in measures.items():
750
+ if not isinstance(definition, (list, tuple)) or len(definition) < 1:
751
+ raise HTTPException(400, f"Measure '{alias}' must be [op] or [op, field].")
752
+ op = str(definition[0]).lower()
753
+ if op not in _AGG_OPS:
754
+ raise HTTPException(
755
+ 400,
756
+ f"Measure '{alias}' uses unsupported op '{op}'. Allowed: {sorted(_AGG_OPS)}",
757
+ )
758
+ field = definition[1] if len(definition) > 1 else "*"
759
+ if op == "count" and field in (None, "*"):
760
+ select_parts.append(f"COUNT(*) AS \"{alias}\"")
761
+ continue
762
+ if field not in spec["fields"]:
763
+ raise HTTPException(
764
+ 400,
765
+ f"Measure '{alias}' references unknown field '{field}' for dataset '{dataset}'.",
766
+ )
767
+ select_parts.append(f"{op.upper()}({spec['field_sql'][field]}) AS \"{alias}\"")
768
+
769
+ params: list[Any] = []
770
+ where = _build_dataset_filter_clauses(spec, filters or {}, params)
771
+
772
+ order_clauses: list[str] = []
773
+ for entry in order_by or []:
774
+ field = entry.get("field")
775
+ direction = (entry.get("direction") or "asc").lower()
776
+ if direction not in ("asc", "desc"):
777
+ raise HTTPException(400, f"Invalid order direction '{direction}'.")
778
+ if field in measures:
779
+ order_clauses.append(f'"{field}" {direction}')
780
+ elif field in group_by:
781
+ order_clauses.append(f'"{field}" {direction}')
782
+ else:
783
+ raise HTTPException(
784
+ 400,
785
+ f"order_by field '{field}' must be a group_by field or a measure alias.",
786
+ )
787
+
788
+ resolved_limit = limit if limit is not None else spec["default_limit"]
789
+ resolved_limit = max(1, min(resolved_limit, spec["max_limit"]))
790
+
791
+ sql = f"""
792
+ SELECT {', '.join(select_parts)}
793
+ {spec['sql_from']}
794
+ {'WHERE ' + ' AND '.join(where) if where else ''}
795
+ {'GROUP BY ' + ', '.join(spec['field_sql'][f] for f in group_by) if group_by else ''}
796
+ {'ORDER BY ' + ', '.join(order_clauses) if order_clauses else ''}
797
+ LIMIT {resolved_limit}
798
+ """
799
+ rows = [dict(row) for row in get_db().sql(sql, params)]
800
+ return {
801
+ "dataset": dataset,
802
+ "group_by": group_by,
803
+ "measures": list(measures.keys()),
804
+ "row_count": len(rows),
805
+ "rows": rows,
806
+ "truncated": len(rows) >= resolved_limit,
807
+ }
808
+
809
+
810
+ @router.get("/runtime/datasets")
811
+ def list_semantic_datasets(_user: dict = _viewer):
812
+ return {
813
+ "datasets": [
814
+ {
815
+ "dataset": dataset,
816
+ "label": spec["label"],
817
+ "description": spec["description"],
818
+ "fields": spec["fields"],
819
+ "filter_fields": sorted(spec["filter_fields"]),
820
+ "default_limit": spec["default_limit"],
821
+ "max_limit": spec["max_limit"],
822
+ }
823
+ for dataset, spec in SEMANTIC_DATASETS.items()
824
+ ]
825
+ }
826
+
827
+
828
+ @router.post("/runtime/data")
829
+ def fetch_runtime_data(payload: RuntimeFetchPayload, _user: dict = _viewer):
830
+ return {
831
+ "datasets": [_fetch_semantic_dataset(request) for request in payload.requests],
832
+ }
833
+
834
+
835
+ def create_report_draft_record(
836
+ payload: dict[str, Any],
837
+ user: dict | None = None,
838
+ source_chat_session_id: str | None = None,
839
+ ) -> dict[str, Any]:
840
+ db = get_db()
841
+ draft_id = f"RPT-{str(uuid.uuid4())[:8].upper()}"
842
+ definition = {
843
+ "title": payload["title"],
844
+ "description": payload.get("description"),
845
+ "data_requests": payload.get("data_requests", []),
846
+ "transform_js": payload.get("transform_js", ""),
847
+ }
848
+ db.sql(
849
+ 'INSERT INTO "Report Draft" (id, title, description, definition_json, created_by, source_chat_session_id) VALUES (?, ?, ?, ?, ?, ?)',
850
+ [
851
+ draft_id,
852
+ payload["title"],
853
+ payload.get("description"),
854
+ json.dumps(definition),
855
+ (user or {}).get("name"),
856
+ source_chat_session_id,
857
+ ],
858
+ )
859
+ db.conn.commit()
860
+ return {
861
+ "id": draft_id,
862
+ "title": payload["title"],
863
+ "description": payload.get("description"),
864
+ "definition": definition,
865
+ "url": f"/reports/analytics?report_id={draft_id}",
866
+ "created_by": (user or {}).get("name"),
867
+ "source_chat_session_id": source_chat_session_id,
868
+ }
869
+
870
+
871
+ def get_report_draft_record(report_id: str, user: dict | None = None) -> dict[str, Any] | None:
872
+ db = get_db()
873
+ rows = db.sql(
874
+ '''
875
+ SELECT id, title, description, definition_json, created_by, source_chat_session_id, created_at, updated_at
876
+ FROM "Report Draft"
877
+ WHERE id = ?
878
+ LIMIT 1
879
+ ''',
880
+ [report_id],
881
+ )
882
+ if not rows:
883
+ return None
884
+ row = dict(rows[0])
885
+ if user and row.get("created_by") and row.get("created_by") != user.get("name") and user.get("role") != "admin":
886
+ raise HTTPException(403, "You do not have access to this report draft.")
887
+ definition = json.loads(row["definition_json"])
888
+ return {
889
+ "id": row["id"],
890
+ "title": row["title"],
891
+ "description": row.get("description"),
892
+ "definition": definition,
893
+ "created_by": row.get("created_by"),
894
+ "source_chat_session_id": row.get("source_chat_session_id"),
895
+ "created_at": row.get("created_at"),
896
+ "updated_at": row.get("updated_at"),
897
+ "url": f"/reports/analytics?report_id={row['id']}",
898
+ }
899
+
900
+
901
+ def update_report_draft_record(report_id: str, payload: dict[str, Any], user: dict | None = None) -> dict[str, Any] | None:
902
+ existing = get_report_draft_record(report_id, user)
903
+ if not existing:
904
+ return None
905
+
906
+ definition = dict(existing["definition"])
907
+ title = payload.get("title", existing["title"])
908
+ description = payload.get("description", existing.get("description"))
909
+ if payload.get("title") is not None:
910
+ definition["title"] = payload["title"]
911
+ if payload.get("description") is not None:
912
+ definition["description"] = payload["description"]
913
+ if payload.get("data_requests") is not None:
914
+ definition["data_requests"] = payload["data_requests"]
915
+ if payload.get("transform_js") is not None:
916
+ definition["transform_js"] = payload["transform_js"]
917
+
918
+ db = get_db()
919
+ db.sql(
920
+ '''
921
+ UPDATE "Report Draft"
922
+ SET title = ?, description = ?, definition_json = ?, updated_at = CURRENT_TIMESTAMP
923
+ WHERE id = ?
924
+ ''',
925
+ [title, description, json.dumps(definition), report_id],
926
+ )
927
+ db.conn.commit()
928
+ return get_report_draft_record(report_id, user)
929
+
930
+
931
+ @router.post("/runtime/drafts")
932
+ def create_report_draft(payload: ReportDraftPayload, user: dict = _viewer):
933
+ return create_report_draft_record(payload.model_dump(), user)
934
+
935
+
936
+ @router.get("/runtime/drafts/{report_id}")
937
+ def get_report_draft(report_id: str, user: dict = _viewer):
938
+ row = get_report_draft_record(report_id, user)
939
+ if not row:
940
+ raise HTTPException(404, f"Report draft '{report_id}' not found")
941
+ return row
942
+
943
+
944
+ @router.put("/runtime/drafts/{report_id}")
945
+ def update_report_draft(report_id: str, payload: ReportDraftUpdatePayload, user: dict = _viewer):
946
+ row = update_report_draft_record(report_id, payload.model_dump(exclude_none=True), user)
947
+ if not row:
948
+ raise HTTPException(404, f"Report draft '{report_id}' not found")
949
+ return row
950
+
951
+
952
+ @router.get("/runtime/drafts")
953
+ def list_report_drafts(user: dict = _viewer):
954
+ db = get_db()
955
+ if user.get("role") == "admin":
956
+ rows = db.sql(
957
+ '''
958
+ SELECT id, title, description, created_by, source_chat_session_id, created_at, updated_at
959
+ FROM "Report Draft"
960
+ ORDER BY updated_at DESC
961
+ '''
962
+ )
963
+ else:
964
+ # Non-admins see their own drafts, plus ownerless drafts — those
965
+ # are system/demo assets (e.g. the seeded "Top 7 Customers by
966
+ # Revenue" draft bootstrap creates for the demo). The access check
967
+ # in get_report_draft_record already treats null-owned drafts as
968
+ # open, so showing them in the list is consistent.
969
+ rows = db.sql(
970
+ '''
971
+ SELECT id, title, description, created_by, source_chat_session_id, created_at, updated_at
972
+ FROM "Report Draft"
973
+ WHERE created_by = ? OR created_by IS NULL
974
+ ORDER BY updated_at DESC
975
+ ''',
976
+ [user.get("name")],
977
+ )
978
+ return {
979
+ "drafts": [
980
+ {
981
+ "id": row["id"],
982
+ "title": row["title"],
983
+ "description": row.get("description"),
984
+ "created_by": row.get("created_by"),
985
+ "source_chat_session_id": row.get("source_chat_session_id"),
986
+ "created_at": row.get("created_at"),
987
+ "updated_at": row.get("updated_at"),
988
+ "url": f"/reports/analytics?report_id={row['id']}",
989
+ }
990
+ for row in (dict(r) for r in rows)
991
+ ]
992
+ }
993
+
994
+
995
+ @router.delete("/runtime/drafts/{report_id}")
996
+ def delete_report_draft(report_id: str, user: dict = _viewer):
997
+ db = get_db()
998
+ rows = db.sql(
999
+ 'SELECT created_by FROM "Report Draft" WHERE id = ? LIMIT 1',
1000
+ [report_id],
1001
+ )
1002
+ if not rows:
1003
+ raise HTTPException(404, f"Report draft '{report_id}' not found")
1004
+ owner = dict(rows[0]).get("created_by")
1005
+ if owner and owner != user.get("name") and user.get("role") != "admin":
1006
+ raise HTTPException(403, "You do not have access to this report draft.")
1007
+ db.sql('DELETE FROM "Report Draft" WHERE id = ?', [report_id])
1008
+ db.conn.commit()
1009
+ return {"deleted": report_id}