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/routers/analytics.py
ADDED
|
@@ -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}
|