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/demo_limits.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Token-spend rate limiting for the public demo.
|
|
2
|
+
|
|
3
|
+
Two sliding 1-hour windows guard the demo budget:
|
|
4
|
+
* global — caps total USD spend across all visitors per hour
|
|
5
|
+
* per-IP — caps a single client IP per hour
|
|
6
|
+
|
|
7
|
+
Default thresholds: $10/hr global ($240/day). The per-IP cap defaults
|
|
8
|
+
to ~$0.52/hr (the previous absolute cap, i.e. 25% of the old $50/day
|
|
9
|
+
budget) and is also clamped to at most 25% of the global cap so one
|
|
10
|
+
actor cannot monopolize it.
|
|
11
|
+
|
|
12
|
+
Only `public_manager` traffic counts against the global cap (that's
|
|
13
|
+
demo traffic); admin/manager sessions are still logged but exempt.
|
|
14
|
+
|
|
15
|
+
Every LLM call is persisted to the `Demo Spend Log` table so admins can
|
|
16
|
+
see per-window breakdowns (1h/2h/4h/12h/24h/7d) via
|
|
17
|
+
/api/admin/demo-spend. Single-replica deploy is the hard prerequisite
|
|
18
|
+
for this design — the in-process SQLite file is the single source of
|
|
19
|
+
truth. If the container ever scales horizontally, swap the store.
|
|
20
|
+
|
|
21
|
+
Pricing lives in `api.providers`; this module only owns persistence and
|
|
22
|
+
limit checks.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from lambda_erp.database import get_db
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_WINDOW_SECONDS = 3600 # 1 hour
|
|
37
|
+
|
|
38
|
+
# Upper bound on how long a reservation may hold budget before it's
|
|
39
|
+
# considered stale and ignored. Must exceed the longest provider call
|
|
40
|
+
# timeout (OpenAI/Anthropic clients use 120s) plus a fudge for the
|
|
41
|
+
# event-loop latency between the SDK call returning and settle() firing.
|
|
42
|
+
# Defence-in-depth against cancellation leaks that slip past try/finally.
|
|
43
|
+
_RESERVATION_TTL_SECONDS = 180
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_SCHEMA_SQL = """
|
|
47
|
+
CREATE TABLE IF NOT EXISTS "Demo Spend Log" (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
ts REAL NOT NULL,
|
|
50
|
+
ip TEXT,
|
|
51
|
+
role TEXT,
|
|
52
|
+
provider TEXT,
|
|
53
|
+
model TEXT,
|
|
54
|
+
prompt_tokens INTEGER DEFAULT 0,
|
|
55
|
+
completion_tokens INTEGER DEFAULT 0,
|
|
56
|
+
cost_usd REAL NOT NULL,
|
|
57
|
+
session_id TEXT
|
|
58
|
+
);
|
|
59
|
+
"""
|
|
60
|
+
_INDEX_TS_SQL = 'CREATE INDEX IF NOT EXISTS "idx_demo_spend_ts" ON "Demo Spend Log" (ts)'
|
|
61
|
+
_INDEX_IP_TS_SQL = 'CREATE INDEX IF NOT EXISTS "idx_demo_spend_ip_ts" ON "Demo Spend Log" (ip, ts)'
|
|
62
|
+
|
|
63
|
+
_schema_lock = threading.Lock()
|
|
64
|
+
_schema_ready = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _Reservation:
|
|
69
|
+
ip: str
|
|
70
|
+
role: str
|
|
71
|
+
estimated_usd: float
|
|
72
|
+
created_at: float
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def init_schema() -> None:
|
|
76
|
+
"""Create the spend-log table. Safe to call repeatedly; guarded by a
|
|
77
|
+
module-level flag so we don't re-issue DDL on every request."""
|
|
78
|
+
global _schema_ready
|
|
79
|
+
with _schema_lock:
|
|
80
|
+
if _schema_ready:
|
|
81
|
+
return
|
|
82
|
+
db = get_db()
|
|
83
|
+
db.conn.execute(_SCHEMA_SQL)
|
|
84
|
+
db.conn.execute(_INDEX_TS_SQL)
|
|
85
|
+
db.conn.execute(_INDEX_IP_TS_SQL)
|
|
86
|
+
db.conn.commit()
|
|
87
|
+
_schema_ready = True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DemoSpendLimiter:
|
|
91
|
+
"""SQLite-backed spend tracker with sliding-window caps.
|
|
92
|
+
|
|
93
|
+
`check()` reads SUM(cost_usd) over the last hour for both the global
|
|
94
|
+
demo bucket (role=public_manager) and the caller's IP; `record()`
|
|
95
|
+
appends one row per LLM call for both limiting and analytics.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, global_hourly_usd: float, per_ip_hourly_usd: float):
|
|
99
|
+
self.global_hourly_usd = global_hourly_usd
|
|
100
|
+
self.per_ip_hourly_usd = per_ip_hourly_usd
|
|
101
|
+
self._reservation_lock = threading.Lock()
|
|
102
|
+
self._next_reservation_id = 1
|
|
103
|
+
self._reservations: dict[int, _Reservation] = {}
|
|
104
|
+
|
|
105
|
+
def _reserved_totals_unlocked(self, ip: str) -> tuple[float, float]:
|
|
106
|
+
# Opportunistically evict stale reservations. This is the only
|
|
107
|
+
# path that totals reservations, so piggy-backing the sweep keeps
|
|
108
|
+
# the lock count down without needing a separate background task.
|
|
109
|
+
now = time.time()
|
|
110
|
+
cutoff = now - _RESERVATION_TTL_SECONDS
|
|
111
|
+
stale = [rid for rid, row in self._reservations.items() if row.created_at < cutoff]
|
|
112
|
+
for rid in stale:
|
|
113
|
+
self._reservations.pop(rid, None)
|
|
114
|
+
|
|
115
|
+
global_reserved = 0.0
|
|
116
|
+
ip_reserved = 0.0
|
|
117
|
+
for row in self._reservations.values():
|
|
118
|
+
if row.role != "public_manager":
|
|
119
|
+
continue
|
|
120
|
+
global_reserved += row.estimated_usd
|
|
121
|
+
if row.ip == ip:
|
|
122
|
+
ip_reserved += row.estimated_usd
|
|
123
|
+
return global_reserved, ip_reserved
|
|
124
|
+
|
|
125
|
+
# ---- checks --------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def check(self, ip: str) -> Optional[str]:
|
|
128
|
+
"""Return a human-readable reason if `ip` (or the global demo
|
|
129
|
+
bucket) has crossed its hourly cap, else None. Admin/manager
|
|
130
|
+
traffic is never blocked here — that decision is at the call
|
|
131
|
+
site via `is_demo_role(role)`."""
|
|
132
|
+
init_schema()
|
|
133
|
+
db = get_db()
|
|
134
|
+
cutoff = time.time() - _WINDOW_SECONDS
|
|
135
|
+
|
|
136
|
+
global_spend = db.conn.execute(
|
|
137
|
+
'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
|
|
138
|
+
'WHERE ts > ? AND role = ?',
|
|
139
|
+
(cutoff, "public_manager"),
|
|
140
|
+
).fetchone()["s"]
|
|
141
|
+
if global_spend >= self.global_hourly_usd:
|
|
142
|
+
return (
|
|
143
|
+
f"Demo budget exhausted for this hour "
|
|
144
|
+
f"(~${global_spend:.2f} / ${self.global_hourly_usd:.2f}). "
|
|
145
|
+
"Please try again later."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
ip_spend = db.conn.execute(
|
|
149
|
+
'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
|
|
150
|
+
'WHERE ts > ? AND ip = ? AND role = ?',
|
|
151
|
+
(cutoff, ip, "public_manager"),
|
|
152
|
+
).fetchone()["s"]
|
|
153
|
+
if ip_spend >= self.per_ip_hourly_usd:
|
|
154
|
+
return (
|
|
155
|
+
f"You've reached the hourly demo limit for your IP "
|
|
156
|
+
f"(~${ip_spend:.2f} / ${self.per_ip_hourly_usd:.2f}). "
|
|
157
|
+
"Please try again later."
|
|
158
|
+
)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def reserve(
|
|
162
|
+
self,
|
|
163
|
+
ip: str,
|
|
164
|
+
*,
|
|
165
|
+
estimated_usd: float,
|
|
166
|
+
role: str | None = None,
|
|
167
|
+
) -> tuple[Optional[str], Optional[int]]:
|
|
168
|
+
"""Atomically reserve budget before an outbound LLM call."""
|
|
169
|
+
amount = max(float(estimated_usd or 0.0), 0.0)
|
|
170
|
+
if amount <= 0:
|
|
171
|
+
return None, None
|
|
172
|
+
if role != "public_manager":
|
|
173
|
+
return None, None
|
|
174
|
+
|
|
175
|
+
init_schema()
|
|
176
|
+
db = get_db()
|
|
177
|
+
cutoff = time.time() - _WINDOW_SECONDS
|
|
178
|
+
with self._reservation_lock:
|
|
179
|
+
global_spend = db.conn.execute(
|
|
180
|
+
'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
|
|
181
|
+
'WHERE ts > ? AND role = ?',
|
|
182
|
+
(cutoff, "public_manager"),
|
|
183
|
+
).fetchone()["s"]
|
|
184
|
+
ip_spend = db.conn.execute(
|
|
185
|
+
'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
|
|
186
|
+
'WHERE ts > ? AND ip = ? AND role = ?',
|
|
187
|
+
(cutoff, ip, "public_manager"),
|
|
188
|
+
).fetchone()["s"]
|
|
189
|
+
reserved_global, reserved_ip = self._reserved_totals_unlocked(ip)
|
|
190
|
+
|
|
191
|
+
projected_global = float(global_spend) + reserved_global + amount
|
|
192
|
+
if projected_global > self.global_hourly_usd:
|
|
193
|
+
return (
|
|
194
|
+
f"Demo budget exhausted for this hour "
|
|
195
|
+
f"(~${global_spend + reserved_global:.2f} / ${self.global_hourly_usd:.2f}). "
|
|
196
|
+
"Please try again later.",
|
|
197
|
+
None,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
projected_ip = float(ip_spend) + reserved_ip + amount
|
|
201
|
+
if projected_ip > self.per_ip_hourly_usd:
|
|
202
|
+
return (
|
|
203
|
+
f"You've reached the hourly demo limit for your IP "
|
|
204
|
+
f"(~${ip_spend + reserved_ip:.2f} / ${self.per_ip_hourly_usd:.2f}). "
|
|
205
|
+
"Please try again later.",
|
|
206
|
+
None,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
reservation_id = self._next_reservation_id
|
|
210
|
+
self._next_reservation_id += 1
|
|
211
|
+
self._reservations[reservation_id] = _Reservation(
|
|
212
|
+
ip=ip,
|
|
213
|
+
role=role or "",
|
|
214
|
+
estimated_usd=amount,
|
|
215
|
+
created_at=time.time(),
|
|
216
|
+
)
|
|
217
|
+
return None, reservation_id
|
|
218
|
+
|
|
219
|
+
def release(self, reservation_id: int | None) -> None:
|
|
220
|
+
"""Drop a reservation without recording spend (failed call path)."""
|
|
221
|
+
if reservation_id is None:
|
|
222
|
+
return
|
|
223
|
+
with self._reservation_lock:
|
|
224
|
+
self._reservations.pop(reservation_id, None)
|
|
225
|
+
|
|
226
|
+
def settle(
|
|
227
|
+
self,
|
|
228
|
+
reservation_id: int | None,
|
|
229
|
+
*,
|
|
230
|
+
actual_cost_usd: float,
|
|
231
|
+
ip: str,
|
|
232
|
+
role: str | None = None,
|
|
233
|
+
provider: str | None = None,
|
|
234
|
+
model: str | None = None,
|
|
235
|
+
prompt_tokens: int = 0,
|
|
236
|
+
completion_tokens: int = 0,
|
|
237
|
+
session_id: str | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Finalize a reservation and persist the actual spend row."""
|
|
240
|
+
if reservation_id is not None:
|
|
241
|
+
with self._reservation_lock:
|
|
242
|
+
self._reservations.pop(reservation_id, None)
|
|
243
|
+
self.record(
|
|
244
|
+
ip,
|
|
245
|
+
actual_cost_usd,
|
|
246
|
+
role=role,
|
|
247
|
+
provider=provider,
|
|
248
|
+
model=model,
|
|
249
|
+
prompt_tokens=prompt_tokens,
|
|
250
|
+
completion_tokens=completion_tokens,
|
|
251
|
+
session_id=session_id,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# ---- recording -----------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def record(
|
|
257
|
+
self,
|
|
258
|
+
ip: str,
|
|
259
|
+
cost_usd: float,
|
|
260
|
+
*,
|
|
261
|
+
role: str | None = None,
|
|
262
|
+
provider: str | None = None,
|
|
263
|
+
model: str | None = None,
|
|
264
|
+
prompt_tokens: int = 0,
|
|
265
|
+
completion_tokens: int = 0,
|
|
266
|
+
session_id: str | None = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
if cost_usd <= 0:
|
|
269
|
+
return
|
|
270
|
+
init_schema()
|
|
271
|
+
db = get_db()
|
|
272
|
+
db.conn.execute(
|
|
273
|
+
'INSERT INTO "Demo Spend Log" '
|
|
274
|
+
'(ts, ip, role, provider, model, prompt_tokens, completion_tokens, cost_usd, session_id) '
|
|
275
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
276
|
+
(
|
|
277
|
+
time.time(),
|
|
278
|
+
ip,
|
|
279
|
+
role,
|
|
280
|
+
provider,
|
|
281
|
+
model,
|
|
282
|
+
int(prompt_tokens or 0),
|
|
283
|
+
int(completion_tokens or 0),
|
|
284
|
+
float(cost_usd),
|
|
285
|
+
session_id,
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
db.conn.commit()
|
|
289
|
+
|
|
290
|
+
# ---- reporting -----------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def snapshot(self) -> dict:
|
|
293
|
+
"""Lightweight health snapshot — for logs / live dashboards."""
|
|
294
|
+
init_schema()
|
|
295
|
+
db = get_db()
|
|
296
|
+
cutoff = time.time() - _WINDOW_SECONDS
|
|
297
|
+
row = db.conn.execute(
|
|
298
|
+
'SELECT COALESCE(SUM(cost_usd), 0) AS s, '
|
|
299
|
+
'COUNT(DISTINCT ip) AS ips '
|
|
300
|
+
'FROM "Demo Spend Log" WHERE ts > ? AND role = ?',
|
|
301
|
+
(cutoff, "public_manager"),
|
|
302
|
+
).fetchone()
|
|
303
|
+
return {
|
|
304
|
+
"global_hourly_usd": round(row["s"], 4),
|
|
305
|
+
"global_hourly_cap": self.global_hourly_usd,
|
|
306
|
+
"per_ip_hourly_cap": self.per_ip_hourly_usd,
|
|
307
|
+
"active_ips": int(row["ips"] or 0),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Process-wide singleton. Thresholds are read once at import time from env.
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _env_float(name: str, default: float) -> float:
|
|
316
|
+
raw = os.environ.get(name)
|
|
317
|
+
if raw is None or raw == "":
|
|
318
|
+
return default
|
|
319
|
+
try:
|
|
320
|
+
return float(raw)
|
|
321
|
+
except ValueError:
|
|
322
|
+
return default
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# $10/hr → $240/day cap.
|
|
326
|
+
_GLOBAL_HOURLY_USD = _env_float("LAMBDA_ERP_DEMO_GLOBAL_HOURLY_USD", 10.0)
|
|
327
|
+
# Pinned to the previous absolute default (25% of the old $50/day
|
|
328
|
+
# global ≈ $0.521/hr) so raising the global cap does not also raise
|
|
329
|
+
# what a single IP can spend.
|
|
330
|
+
_PER_IP_HOURLY_USD = _env_float(
|
|
331
|
+
"LAMBDA_ERP_DEMO_PER_IP_HOURLY_USD",
|
|
332
|
+
(50.0 / 24.0) * 0.25,
|
|
333
|
+
)
|
|
334
|
+
_PER_IP_HOURLY_USD = min(_PER_IP_HOURLY_USD, _GLOBAL_HOURLY_USD * 0.25)
|
|
335
|
+
|
|
336
|
+
limiter = DemoSpendLimiter(
|
|
337
|
+
global_hourly_usd=_GLOBAL_HOURLY_USD,
|
|
338
|
+
per_ip_hourly_usd=_PER_IP_HOURLY_USD,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def is_demo_role(role: Optional[str]) -> bool:
|
|
343
|
+
"""Limits only apply to the shared demo account, not admins/managers."""
|
|
344
|
+
return role == "public_manager"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def demo_max_completion_tokens(default: int = 4096) -> int:
|
|
348
|
+
"""Env-overridable cap for `max_completion_tokens` on demo calls.
|
|
349
|
+
|
|
350
|
+
Defaults to 1024 — tight enough to bound worst-case turn cost but
|
|
351
|
+
generous enough for typical replies. Callers apply this only for
|
|
352
|
+
`public_manager`; logged-in managers/admins keep the original cap."""
|
|
353
|
+
raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_COMPLETION_TOKENS")
|
|
354
|
+
if raw is None or raw == "":
|
|
355
|
+
return 1024
|
|
356
|
+
try:
|
|
357
|
+
return max(256, int(raw))
|
|
358
|
+
except ValueError:
|
|
359
|
+
return 1024
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def demo_call_reserve_usd() -> float:
|
|
363
|
+
"""Conservative pre-call reservation for demo LLM requests."""
|
|
364
|
+
raw = os.environ.get("LAMBDA_ERP_DEMO_CALL_RESERVE_USD")
|
|
365
|
+
if raw is None or raw == "":
|
|
366
|
+
return 0.35
|
|
367
|
+
try:
|
|
368
|
+
return max(0.05, float(raw))
|
|
369
|
+
except ValueError:
|
|
370
|
+
return 0.35
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def demo_max_message_chars() -> int:
|
|
374
|
+
"""Env-overridable cap on the length of a single chat message from a
|
|
375
|
+
public_manager session. Without this, a visitor could paste ~100k
|
|
376
|
+
characters and burn the global hourly budget in a single call even
|
|
377
|
+
though the per-call max_completion_tokens is tight — input tokens
|
|
378
|
+
aren't capped server-side otherwise."""
|
|
379
|
+
raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_MESSAGE_CHARS")
|
|
380
|
+
if raw is None or raw == "":
|
|
381
|
+
return 300
|
|
382
|
+
try:
|
|
383
|
+
return max(50, int(raw))
|
|
384
|
+
except ValueError:
|
|
385
|
+
return 300
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def demo_max_attachment_bytes() -> int:
|
|
389
|
+
"""Env-overridable cap on the size of a single chat attachment uploaded
|
|
390
|
+
from a public_manager session. Default 100 KiB. Images/PDFs are sent
|
|
391
|
+
to the LLM as base64 multimodal parts, so each uploaded byte becomes
|
|
392
|
+
~1.33 bytes of prompt — a 10 MB file would otherwise blow the hourly
|
|
393
|
+
budget in one call."""
|
|
394
|
+
raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_ATTACHMENT_BYTES")
|
|
395
|
+
if raw is None or raw == "":
|
|
396
|
+
return 100 * 1024
|
|
397
|
+
try:
|
|
398
|
+
return max(1024, int(raw))
|
|
399
|
+
except ValueError:
|
|
400
|
+
return 100 * 1024
|
api/deps.py
ADDED
api/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Map lambda_erp exceptions to HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from lambda_erp.exceptions import (
|
|
6
|
+
ValidationError,
|
|
7
|
+
MandatoryError,
|
|
8
|
+
DocumentStatusError,
|
|
9
|
+
DebitCreditNotEqual,
|
|
10
|
+
NegativeStockError,
|
|
11
|
+
InvalidAccountError,
|
|
12
|
+
InvalidCurrency,
|
|
13
|
+
InsufficientFunds,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_exception_handlers(app: FastAPI):
|
|
18
|
+
|
|
19
|
+
@app.exception_handler(ValueError)
|
|
20
|
+
async def value_error(request: Request, exc: ValueError):
|
|
21
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
22
|
+
|
|
23
|
+
@app.exception_handler(DocumentStatusError)
|
|
24
|
+
async def document_status_error(request: Request, exc: DocumentStatusError):
|
|
25
|
+
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
|
26
|
+
|
|
27
|
+
@app.exception_handler(ValidationError)
|
|
28
|
+
async def validation_error(request: Request, exc: ValidationError):
|
|
29
|
+
msg = str(exc)
|
|
30
|
+
if msg.endswith("not found"):
|
|
31
|
+
return JSONResponse(status_code=404, content={"detail": msg})
|
|
32
|
+
return JSONResponse(status_code=422, content={"detail": msg})
|
|
33
|
+
|
|
34
|
+
@app.exception_handler(MandatoryError)
|
|
35
|
+
async def mandatory_error(request: Request, exc: MandatoryError):
|
|
36
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
37
|
+
|
|
38
|
+
@app.exception_handler(DebitCreditNotEqual)
|
|
39
|
+
async def debit_credit_error(request: Request, exc: DebitCreditNotEqual):
|
|
40
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
41
|
+
|
|
42
|
+
@app.exception_handler(NegativeStockError)
|
|
43
|
+
async def negative_stock_error(request: Request, exc: NegativeStockError):
|
|
44
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
45
|
+
|
|
46
|
+
@app.exception_handler(InvalidAccountError)
|
|
47
|
+
async def invalid_account_error(request: Request, exc: InvalidAccountError):
|
|
48
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
49
|
+
|
|
50
|
+
@app.exception_handler(InvalidCurrency)
|
|
51
|
+
async def invalid_currency_error(request: Request, exc: InvalidCurrency):
|
|
52
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
53
|
+
|
|
54
|
+
@app.exception_handler(InsufficientFunds)
|
|
55
|
+
async def insufficient_funds_error(request: Request, exc: InsufficientFunds):
|
|
56
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
api/main.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application entry point.
|
|
3
|
+
|
|
4
|
+
Run with: uvicorn api.main:app --reload --port 8000
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from fastapi import FastAPI, HTTPException, WebSocket
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from fastapi.responses import FileResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
|
|
14
|
+
from lambda_erp.database import setup
|
|
15
|
+
|
|
16
|
+
from api.errors import register_exception_handlers
|
|
17
|
+
from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
18
|
+
from api.attachments import router as attachments_router
|
|
19
|
+
from api.chat import chat_websocket, router as chat_router
|
|
20
|
+
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_plugins() -> None:
|
|
24
|
+
"""Import customer extension modules named in LAMBDA_ERP_PLUGINS and call
|
|
25
|
+
each module's register().
|
|
26
|
+
|
|
27
|
+
A customer deployment is a separate repo that depends on this core and
|
|
28
|
+
registers doctype overrides / lifecycle hooks (see
|
|
29
|
+
docs/core-extension-architecture.md). Set e.g. LAMBDA_ERP_PLUGINS=acme
|
|
30
|
+
(comma-separated for several). Runs after setup() and BEFORE any documents
|
|
31
|
+
are created, so overrides/hooks apply to seeded data too. Unset = no-op, so
|
|
32
|
+
the core runs unchanged. Fails fast on a broken plugin rather than booting
|
|
33
|
+
silently without the customer's customizations.
|
|
34
|
+
"""
|
|
35
|
+
import importlib
|
|
36
|
+
|
|
37
|
+
names = [m.strip() for m in os.environ.get("LAMBDA_ERP_PLUGINS", "").split(",") if m.strip()]
|
|
38
|
+
for name in names:
|
|
39
|
+
print(f"[plugins] loading {name}", flush=True)
|
|
40
|
+
module = importlib.import_module(name)
|
|
41
|
+
register = getattr(module, "register", None)
|
|
42
|
+
if not callable(register):
|
|
43
|
+
raise RuntimeError(f"Plugin '{name}' has no callable register()")
|
|
44
|
+
register()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@asynccontextmanager
|
|
48
|
+
async def lifespan(app: FastAPI):
|
|
49
|
+
# Startup: initialize database
|
|
50
|
+
db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
|
|
51
|
+
setup(db_path)
|
|
52
|
+
|
|
53
|
+
# Load customer extension plugins before anything creates documents.
|
|
54
|
+
load_plugins()
|
|
55
|
+
|
|
56
|
+
# Ensure the demo spend log table exists before any LLM call happens.
|
|
57
|
+
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
58
|
+
init_demo_spend_schema()
|
|
59
|
+
|
|
60
|
+
# When packaged as a demo container, land visitors straight in demo mode.
|
|
61
|
+
if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
|
|
62
|
+
from api.bootstrap import bootstrap_demo
|
|
63
|
+
|
|
64
|
+
bootstrap_demo()
|
|
65
|
+
|
|
66
|
+
yield
|
|
67
|
+
# Shutdown: nothing to clean up (SQLite handles it)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
app = FastAPI(
|
|
71
|
+
title="Lambda ERP",
|
|
72
|
+
version="0.1.0",
|
|
73
|
+
lifespan=lifespan,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# CORS for Vite dev server
|
|
77
|
+
app.add_middleware(
|
|
78
|
+
CORSMiddleware,
|
|
79
|
+
allow_origins=["http://localhost:5173"],
|
|
80
|
+
allow_credentials=True,
|
|
81
|
+
allow_methods=["*"],
|
|
82
|
+
allow_headers=["*"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Exception handlers
|
|
86
|
+
register_exception_handlers(app)
|
|
87
|
+
|
|
88
|
+
# Routers
|
|
89
|
+
app.include_router(auth_router, prefix="/api")
|
|
90
|
+
app.include_router(attachments_router, prefix="/api")
|
|
91
|
+
app.include_router(documents.router, prefix="/api")
|
|
92
|
+
app.include_router(masters.router, prefix="/api")
|
|
93
|
+
app.include_router(reports.router, prefix="/api")
|
|
94
|
+
app.include_router(analytics.router, prefix="/api")
|
|
95
|
+
app.include_router(setup_router.router, prefix="/api")
|
|
96
|
+
app.include_router(bank_reconciliation.router, prefix="/api")
|
|
97
|
+
app.include_router(accounting.router, prefix="/api")
|
|
98
|
+
app.include_router(admin.router, prefix="/api")
|
|
99
|
+
app.include_router(chat_router, prefix="/api")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.get("/api/health")
|
|
103
|
+
def health():
|
|
104
|
+
return {"status": "ok"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.websocket("/ws/chat")
|
|
108
|
+
async def ws_chat(websocket: WebSocket):
|
|
109
|
+
# Authenticate via cookie, fall back to public_manager for demo mode
|
|
110
|
+
from lambda_erp.database import get_db
|
|
111
|
+
|
|
112
|
+
token = websocket.cookies.get(COOKIE_NAME)
|
|
113
|
+
user_name = decode_token(token) if token else None
|
|
114
|
+
user = None
|
|
115
|
+
|
|
116
|
+
db = get_db()
|
|
117
|
+
if user_name:
|
|
118
|
+
user = db.get_value("User", user_name, ["name", "full_name", "role", "enabled"])
|
|
119
|
+
if user and not user.get("enabled"):
|
|
120
|
+
user = None
|
|
121
|
+
|
|
122
|
+
# Fall back to public manager
|
|
123
|
+
if not user:
|
|
124
|
+
pub = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role = "public_manager" AND enabled = 1')
|
|
125
|
+
user = pub[0] if pub else None
|
|
126
|
+
|
|
127
|
+
if not user:
|
|
128
|
+
await websocket.accept()
|
|
129
|
+
await websocket.close(code=4001, reason="Not authenticated")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Client IP for demo-spend rate limiting. Azure Container Apps ingress
|
|
133
|
+
# (and any sane reverse proxy) sets X-Forwarded-For with the original
|
|
134
|
+
# client as the leftmost entry; fall back to the socket peer for local
|
|
135
|
+
# dev where no proxy is in front.
|
|
136
|
+
xff = websocket.headers.get("x-forwarded-for", "")
|
|
137
|
+
if xff:
|
|
138
|
+
client_ip = xff.split(",")[0].strip()
|
|
139
|
+
else:
|
|
140
|
+
client_ip = websocket.client.host if websocket.client else "unknown"
|
|
141
|
+
|
|
142
|
+
await chat_websocket(websocket, user_info=dict(user), client_ip=client_ip)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Serve frontend build in production (if dist/ exists).
|
|
146
|
+
#
|
|
147
|
+
# In local dev the frontend runs on Vite's dev server (port 5173), which
|
|
148
|
+
# has SPA fallback built in — any unknown route serves index.html so
|
|
149
|
+
# React Router can take over. In the container, FastAPI serves the built
|
|
150
|
+
# frontend itself, and StaticFiles(html=True) only serves index.html for
|
|
151
|
+
# directory requests, not arbitrary paths. That made direct-URL visits
|
|
152
|
+
# and reloads on routes like /chat/:id return `{"detail": "Not Found"}`
|
|
153
|
+
# instead of the React app. The route below replicates Vite's fallback
|
|
154
|
+
# semantics: real files (assets, favicon, logos) resolve as-is; anything
|
|
155
|
+
# else falls through to index.html and React Router handles it
|
|
156
|
+
# client-side. API and WebSocket paths keep returning their normal 404s.
|
|
157
|
+
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
|
158
|
+
if os.path.isdir(frontend_dist):
|
|
159
|
+
assets_dir = os.path.join(frontend_dist, "assets")
|
|
160
|
+
if os.path.isdir(assets_dir):
|
|
161
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
|
162
|
+
|
|
163
|
+
frontend_root = os.path.realpath(frontend_dist)
|
|
164
|
+
index_html = os.path.join(frontend_root, "index.html")
|
|
165
|
+
|
|
166
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
167
|
+
async def spa_fallback(full_path: str):
|
|
168
|
+
# Let API/WS paths that didn't match a real route 404 as JSON,
|
|
169
|
+
# rather than accidentally returning the SPA shell HTML.
|
|
170
|
+
if full_path.startswith(("api/", "ws/")):
|
|
171
|
+
raise HTTPException(status_code=404, detail="Not Found")
|
|
172
|
+
|
|
173
|
+
# Real public files (favicon.ico, favicon.png, logo_*.png, etc.)
|
|
174
|
+
# should resolve to themselves. Guard against path traversal by
|
|
175
|
+
# re-anchoring to the resolved frontend_dist root.
|
|
176
|
+
if full_path:
|
|
177
|
+
candidate = os.path.realpath(os.path.join(frontend_root, full_path))
|
|
178
|
+
if candidate.startswith(frontend_root + os.sep) and os.path.isfile(candidate):
|
|
179
|
+
return FileResponse(candidate)
|
|
180
|
+
|
|
181
|
+
# SPA fallback — React Router takes over from here.
|
|
182
|
+
return FileResponse(index_html)
|