lambda-erp 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/PKG-INFO +17 -2
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/README.md +16 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/auth.py +59 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/bootstrap.py +16 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/main.py +12 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/documents.py +30 -2
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/services.py +94 -6
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/database.py +8 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/pyproject.toml +1 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/.gitignore +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/LICENSE +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/attachments.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/chat.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/deps.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/errors.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/oauth.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/pdf.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/providers.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/masters.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/routers/setup.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/templates/document.html +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/api/templates/proposal.html +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/frontend/LICENSE +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/frontend/README.md +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/model.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.0}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -112,6 +112,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
112
112
|
**Key design choices:**
|
|
113
113
|
|
|
114
114
|
- **Chat-first, not chat-bolted-on.** The chat isn't a copilot sidebar - it's the primary way to interact with the system. Every document type, every report, every master record is reachable from tool-use.
|
|
115
|
+
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
115
116
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
116
117
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
117
118
|
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
|
|
@@ -259,10 +260,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
259
260
|
OPENAI_API_KEY=sk-...
|
|
260
261
|
ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
|
|
261
262
|
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
263
|
+
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
264
|
+
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
265
|
+
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
262
266
|
```
|
|
263
267
|
|
|
264
268
|
Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
|
|
265
269
|
|
|
270
|
+
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
271
|
+
|
|
266
272
|
### Run the validation suite
|
|
267
273
|
|
|
268
274
|
```bash
|
|
@@ -353,12 +359,13 @@ for side-effects/integrations).
|
|
|
353
359
|
|
|
354
360
|
```python
|
|
355
361
|
# acme/plugin.py
|
|
356
|
-
from api.services import register_doctype, register_converter
|
|
362
|
+
from api.services import register_doctype, register_master, register_converter
|
|
357
363
|
from lambda_erp.hooks import register_hook
|
|
358
364
|
from .sales_invoice import AcmeSalesInvoice
|
|
359
365
|
|
|
360
366
|
def register():
|
|
361
367
|
register_doctype("Sales Invoice", AcmeSalesInvoice)
|
|
368
|
+
register_master("gadget", "Gadget", "gadget_name", name_prefix="GAD")
|
|
362
369
|
register_hook("Sales Invoice:after_submit", push_to_external_system)
|
|
363
370
|
# register_converter(source, target, fn) # only to replace conversion *logic*
|
|
364
371
|
```
|
|
@@ -367,6 +374,14 @@ Point the deployment at it with `LAMBDA_ERP_PLUGINS=acme` (comma-separated for
|
|
|
367
374
|
several). On startup the core imports each module and calls `register()`. Unset
|
|
368
375
|
= the core runs unchanged.
|
|
369
376
|
|
|
377
|
+
A `register_master` type (core 0.5.0+) is served at `/api/masters/{slug}` and
|
|
378
|
+
**discovered by the AI chat automatically**: `search_masters` (with fuzzy
|
|
379
|
+
fallback), `get_master_fields`, and `create/update/delete_master` accept it
|
|
380
|
+
from day one. The chat tool schemas and system prompt are built per request
|
|
381
|
+
from the live registries, and fields are introspected from the live table — no
|
|
382
|
+
per-type declarations. Details and caveats:
|
|
383
|
+
[`docs/core-extension-architecture.md`](docs/core-extension-architecture.md).
|
|
384
|
+
|
|
370
385
|
**Frontend overrides — the `@lambda-development/erp-core` library**
|
|
371
386
|
|
|
372
387
|
The frontend ships as a library. The customer app depends on it, registers its
|
|
@@ -85,6 +85,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
85
85
|
**Key design choices:**
|
|
86
86
|
|
|
87
87
|
- **Chat-first, not chat-bolted-on.** The chat isn't a copilot sidebar - it's the primary way to interact with the system. Every document type, every report, every master record is reachable from tool-use.
|
|
88
|
+
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
88
89
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
89
90
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
90
91
|
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
|
|
@@ -232,10 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
232
233
|
OPENAI_API_KEY=sk-...
|
|
233
234
|
ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
|
|
234
235
|
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
236
|
+
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
237
|
+
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
238
|
+
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
235
239
|
```
|
|
236
240
|
|
|
237
241
|
Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
|
|
238
242
|
|
|
243
|
+
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
244
|
+
|
|
239
245
|
### Run the validation suite
|
|
240
246
|
|
|
241
247
|
```bash
|
|
@@ -326,12 +332,13 @@ for side-effects/integrations).
|
|
|
326
332
|
|
|
327
333
|
```python
|
|
328
334
|
# acme/plugin.py
|
|
329
|
-
from api.services import register_doctype, register_converter
|
|
335
|
+
from api.services import register_doctype, register_master, register_converter
|
|
330
336
|
from lambda_erp.hooks import register_hook
|
|
331
337
|
from .sales_invoice import AcmeSalesInvoice
|
|
332
338
|
|
|
333
339
|
def register():
|
|
334
340
|
register_doctype("Sales Invoice", AcmeSalesInvoice)
|
|
341
|
+
register_master("gadget", "Gadget", "gadget_name", name_prefix="GAD")
|
|
335
342
|
register_hook("Sales Invoice:after_submit", push_to_external_system)
|
|
336
343
|
# register_converter(source, target, fn) # only to replace conversion *logic*
|
|
337
344
|
```
|
|
@@ -340,6 +347,14 @@ Point the deployment at it with `LAMBDA_ERP_PLUGINS=acme` (comma-separated for
|
|
|
340
347
|
several). On startup the core imports each module and calls `register()`. Unset
|
|
341
348
|
= the core runs unchanged.
|
|
342
349
|
|
|
350
|
+
A `register_master` type (core 0.5.0+) is served at `/api/masters/{slug}` and
|
|
351
|
+
**discovered by the AI chat automatically**: `search_masters` (with fuzzy
|
|
352
|
+
fallback), `get_master_fields`, and `create/update/delete_master` accept it
|
|
353
|
+
from day one. The chat tool schemas and system prompt are built per request
|
|
354
|
+
from the live registries, and fields are introspected from the live table — no
|
|
355
|
+
per-type declarations. Details and caveats:
|
|
356
|
+
[`docs/core-extension-architecture.md`](docs/core-extension-architecture.md).
|
|
357
|
+
|
|
343
358
|
**Frontend overrides — the `@lambda-development/erp-core` library**
|
|
344
359
|
|
|
345
360
|
The frontend ships as a library. The customer app depends on it, registers its
|
|
@@ -630,6 +630,65 @@ def _setting_enabled(db, key: str) -> bool:
|
|
|
630
630
|
return str(value) == "1"
|
|
631
631
|
|
|
632
632
|
|
|
633
|
+
def ensure_seed_admin() -> dict | None:
|
|
634
|
+
"""Idempotently provision an admin account from the environment.
|
|
635
|
+
|
|
636
|
+
When both LAMBDA_ERP_ADMIN_EMAIL and LAMBDA_ERP_ADMIN_PASSWORD are set,
|
|
637
|
+
guarantee an enabled admin with that email exists *before* the first
|
|
638
|
+
visitor can register. This closes the "first user to sign up becomes
|
|
639
|
+
admin" bootstrap window (see `register`) on any deployment whose
|
|
640
|
+
database is recreated on every rollout — e.g. the ephemeral-SQLite demo
|
|
641
|
+
container, where otherwise whichever stranger loads the login page first
|
|
642
|
+
after a redeploy would silently own the instance.
|
|
643
|
+
|
|
644
|
+
Create-if-missing: an existing account with that email is promoted to an
|
|
645
|
+
enabled admin if needed, but its password is left untouched so a redeploy
|
|
646
|
+
never clobbers a credential changed inside a live instance. The password
|
|
647
|
+
is read only from the environment and is never logged.
|
|
648
|
+
|
|
649
|
+
No-op (returns None) when either variable is unset — this is a general
|
|
650
|
+
ops seam, not demo-only.
|
|
651
|
+
"""
|
|
652
|
+
email = os.environ.get("LAMBDA_ERP_ADMIN_EMAIL")
|
|
653
|
+
password = os.environ.get("LAMBDA_ERP_ADMIN_PASSWORD")
|
|
654
|
+
if not email or not password:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
email = email.strip().lower()
|
|
658
|
+
db = get_db()
|
|
659
|
+
|
|
660
|
+
existing = db.sql('SELECT name, role, enabled FROM "User" WHERE email = ?', [email])
|
|
661
|
+
if existing:
|
|
662
|
+
row = existing[0]
|
|
663
|
+
updates = {}
|
|
664
|
+
if row["role"] != "admin":
|
|
665
|
+
updates["role"] = "admin"
|
|
666
|
+
if not row["enabled"]:
|
|
667
|
+
updates["enabled"] = 1
|
|
668
|
+
if updates:
|
|
669
|
+
updates["modified"] = now()
|
|
670
|
+
db.set_value("User", row["name"], updates)
|
|
671
|
+
print(f"[seed-admin] promoted existing user to enabled admin: {email}", flush=True)
|
|
672
|
+
else:
|
|
673
|
+
print(f"[seed-admin] admin already present: {email}", flush=True)
|
|
674
|
+
return {"name": row["name"], "status": "exists"}
|
|
675
|
+
|
|
676
|
+
user_name = f"USR-{uuid.uuid4().hex[:8]}"
|
|
677
|
+
full_name = os.environ.get("LAMBDA_ERP_ADMIN_NAME", "Administrator")
|
|
678
|
+
db.insert("User", {
|
|
679
|
+
"name": user_name,
|
|
680
|
+
"email": email,
|
|
681
|
+
"full_name": full_name,
|
|
682
|
+
"hashed_password": hash_password(password),
|
|
683
|
+
"role": "admin",
|
|
684
|
+
"enabled": 1,
|
|
685
|
+
"creation": now(),
|
|
686
|
+
"modified": now(),
|
|
687
|
+
})
|
|
688
|
+
print(f"[seed-admin] created admin account: {email}", flush=True)
|
|
689
|
+
return {"name": user_name, "status": "created"}
|
|
690
|
+
|
|
691
|
+
|
|
633
692
|
@router.get("/settings")
|
|
634
693
|
def get_settings(user: dict = Depends(get_current_user)):
|
|
635
694
|
db = get_db()
|
|
@@ -58,6 +58,16 @@ SETTING_DEMO_REDSTONE_SINV_DATE = "demo_chat_redstone_sinv_date"
|
|
|
58
58
|
SETTING_DEMO_REDSTONE_DUE_DATE = "demo_chat_redstone_due_date"
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _seed_setting_on(db, key: str) -> None:
|
|
62
|
+
"""Turn a boolean Settings flag on, but only if it has never been set —
|
|
63
|
+
so an admin who later toggles it off in a persistent instance isn't
|
|
64
|
+
overridden on the next boot. On the ephemeral demo DB the key is always
|
|
65
|
+
absent at boot, so this reliably enables it there."""
|
|
66
|
+
if not db.sql('SELECT 1 FROM "Settings" WHERE key = ?', [key]):
|
|
67
|
+
db.sql('INSERT INTO "Settings" (key, value) VALUES (?, ?)', [key, "1"])
|
|
68
|
+
db.conn.commit()
|
|
69
|
+
|
|
70
|
+
|
|
61
71
|
def bootstrap_demo() -> None:
|
|
62
72
|
"""Idempotent demo bootstrap. Logs each phase to stdout so a
|
|
63
73
|
`docker compose up` user sees steady progress during the ~3-minute
|
|
@@ -131,7 +141,12 @@ def bootstrap_demo() -> None:
|
|
|
131
141
|
create_public_manager(user=None)
|
|
132
142
|
ensure_demo_chat_records(company)
|
|
133
143
|
else:
|
|
134
|
-
|
|
144
|
+
# No public-manager replay: let visitors self-register as viewers so
|
|
145
|
+
# the demo is still hands-on. Admin is provisioned separately from the
|
|
146
|
+
# environment (see api.auth.ensure_seed_admin), so open signup here only
|
|
147
|
+
# ever mints viewers — nobody can grab admin off the login page.
|
|
148
|
+
_seed_setting_on(db, "allow_public_signup")
|
|
149
|
+
print("[bootstrap] public demo disabled — open viewer signup enabled; admin comes from LAMBDA_ERP_ADMIN_* env", flush=True)
|
|
135
150
|
|
|
136
151
|
elapsed = time.monotonic() - t0
|
|
137
152
|
# Recommend 127.0.0.1 — on WSL2 + Docker Desktop, browsers can stall
|
|
@@ -55,10 +55,22 @@ async def lifespan(app: FastAPI):
|
|
|
55
55
|
# Load customer extension plugins before anything creates documents.
|
|
56
56
|
load_plugins()
|
|
57
57
|
|
|
58
|
+
# Create plugin-registered tables and run their pending migrations. Must be
|
|
59
|
+
# after load_plugins() (registrations happen in register()) and before any
|
|
60
|
+
# document is created.
|
|
61
|
+
from api.services import apply_plugin_schema
|
|
62
|
+
apply_plugin_schema()
|
|
63
|
+
|
|
58
64
|
# Ensure the demo spend log table exists before any LLM call happens.
|
|
59
65
|
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
60
66
|
init_demo_spend_schema()
|
|
61
67
|
|
|
68
|
+
# Seed a deterministic admin from the environment (if configured) before
|
|
69
|
+
# anyone can register. On deployments whose DB is recreated each rollout
|
|
70
|
+
# this stops the first visitor after a redeploy from grabbing admin.
|
|
71
|
+
from api.auth import ensure_seed_admin
|
|
72
|
+
ensure_seed_admin()
|
|
73
|
+
|
|
62
74
|
# When packaged as a demo container, land visitors straight in demo mode.
|
|
63
75
|
if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
|
|
64
76
|
from api.bootstrap import bootstrap_demo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Generic CRUD routes for all document types."""
|
|
2
2
|
|
|
3
|
-
from fastapi import APIRouter, Depends, Query
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
4
4
|
from fastapi.responses import Response
|
|
5
5
|
from api.services import (
|
|
6
6
|
create_document,
|
|
@@ -12,6 +12,7 @@ from api.services import (
|
|
|
12
12
|
convert_document,
|
|
13
13
|
list_documents,
|
|
14
14
|
count_documents,
|
|
15
|
+
document_columns,
|
|
15
16
|
)
|
|
16
17
|
from api.pdf import generate_pdf
|
|
17
18
|
from api.auth import require_role
|
|
@@ -21,16 +22,26 @@ router = APIRouter(prefix="/documents", tags=["documents"])
|
|
|
21
22
|
_viewer = Depends(require_role("viewer"))
|
|
22
23
|
_manager = Depends(require_role("manager"))
|
|
23
24
|
|
|
25
|
+
# Query params the list endpoint interprets itself — everything else is treated
|
|
26
|
+
# as an ad-hoc column=value filter (validated against the doctype's columns).
|
|
27
|
+
_LIST_RESERVED = {
|
|
28
|
+
"status", "party", "from_date", "to_date", "docstatus",
|
|
29
|
+
"include_discarded", "limit", "offset", "order_by", "order",
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
|
|
25
33
|
@router.get("/{doctype_slug}")
|
|
26
34
|
def list_docs(
|
|
27
35
|
doctype_slug: str,
|
|
36
|
+
request: Request,
|
|
28
37
|
status: str | None = None,
|
|
29
38
|
party: str | None = None,
|
|
30
39
|
from_date: str | None = None,
|
|
31
40
|
to_date: str | None = None,
|
|
32
41
|
docstatus: int | None = None,
|
|
33
42
|
include_discarded: bool = False,
|
|
43
|
+
order_by: str | None = None,
|
|
44
|
+
order: str = "desc",
|
|
34
45
|
limit: int = Query(default=50, le=500),
|
|
35
46
|
offset: int = Query(default=0, ge=0),
|
|
36
47
|
_user: dict = _viewer,
|
|
@@ -46,8 +57,25 @@ def list_docs(
|
|
|
46
57
|
filters["from_date"] = from_date
|
|
47
58
|
if to_date:
|
|
48
59
|
filters["to_date"] = to_date
|
|
60
|
+
|
|
61
|
+
# Ad-hoc equality filters: any remaining query param that names a real column
|
|
62
|
+
# of this doctype (e.g. /documents/activity?lead_id=LEAD-3316). Validate
|
|
63
|
+
# against the live columns so an unknown field is a 400, never interpolated.
|
|
64
|
+
columns = document_columns(doctype_slug)
|
|
65
|
+
for key, value in request.query_params.items():
|
|
66
|
+
if key in _LIST_RESERVED:
|
|
67
|
+
continue
|
|
68
|
+
if key not in columns:
|
|
69
|
+
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
70
|
+
filters[key] = value
|
|
71
|
+
|
|
72
|
+
if order_by is not None and order_by not in columns:
|
|
73
|
+
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
|
|
74
|
+
if order.lower() not in ("asc", "desc"):
|
|
75
|
+
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
|
|
76
|
+
|
|
49
77
|
rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset,
|
|
50
|
-
include_discarded=include_discarded)
|
|
78
|
+
include_discarded=include_discarded, order_by=order_by, order=order)
|
|
51
79
|
total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
|
|
52
80
|
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
|
|
53
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Bridge between FastAPI request data and lambda_erp Document classes."""
|
|
2
2
|
|
|
3
|
-
from lambda_erp.utils import _dict
|
|
3
|
+
from lambda_erp.utils import _dict, now
|
|
4
4
|
from lambda_erp.database import get_db
|
|
5
5
|
|
|
6
6
|
from lambda_erp.selling.quotation import (
|
|
@@ -114,6 +114,82 @@ def register_doctype(doctype: str, cls, slug: str | None = None) -> None:
|
|
|
114
114
|
DOCTYPE_TO_SLUG[doctype] = slug
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
# --- Plugin schema seam ---
|
|
118
|
+
#
|
|
119
|
+
# Core tables are created by database.setup(); plugin tables have no home there
|
|
120
|
+
# (setup() runs before plugins load). These two registries let a plugin declare
|
|
121
|
+
# its own tables and one-time migrations from register(); the core applies them
|
|
122
|
+
# via apply_plugin_schema() in the app lifespan, right after load_plugins() and
|
|
123
|
+
# before any document is created. See docs/core-extension-architecture.md.
|
|
124
|
+
|
|
125
|
+
_PLUGIN_TABLES: list[str] = []
|
|
126
|
+
_PLUGIN_MIGRATIONS: list = [] # list[tuple[str, Callable[[DB], None]]]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register_table(ddl: str) -> None:
|
|
130
|
+
"""Register a CREATE TABLE (use IF NOT EXISTS) for a plugin table. The DDL is
|
|
131
|
+
the SQLite-flavoured subset the core's own DDL uses; apply_plugin_schema()
|
|
132
|
+
translates it per backend via db._ddl(). Idempotent — safe on every boot."""
|
|
133
|
+
_PLUGIN_TABLES.append(ddl)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def register_migration(migration_id: str, fn) -> None:
|
|
137
|
+
"""Register a one-shot migration run exactly once per database, recorded in
|
|
138
|
+
_PluginMigrations by `migration_id` (namespace it, e.g. "internal:0001_x").
|
|
139
|
+
`fn(db)` receives the DB; use db.ensure_column(...) for idempotent ALTERs
|
|
140
|
+
and plain db.sql(...) for backfills. Runs after registered tables exist, in
|
|
141
|
+
registration order. Must be idempotent and self-contained (single logical
|
|
142
|
+
unit — it commits on success)."""
|
|
143
|
+
_PLUGIN_MIGRATIONS.append((migration_id, fn))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def apply_plugin_schema() -> None:
|
|
147
|
+
"""Create registered plugin tables, then run pending plugin migrations once
|
|
148
|
+
each. Call from the app lifespan after load_plugins(). Table creation always
|
|
149
|
+
runs (IF NOT EXISTS); each migration runs only if its id isn't already in
|
|
150
|
+
_PluginMigrations. A failing migration is rolled back and left unrecorded so
|
|
151
|
+
it retries next boot; it does not abort startup (mirrors the core migrator)."""
|
|
152
|
+
db = get_db()
|
|
153
|
+
for ddl in _PLUGIN_TABLES:
|
|
154
|
+
db.conn.execute(db._ddl(ddl))
|
|
155
|
+
db.conn.commit()
|
|
156
|
+
|
|
157
|
+
if not _PLUGIN_MIGRATIONS:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
db.conn.execute(
|
|
161
|
+
'CREATE TABLE IF NOT EXISTS "_PluginMigrations" '
|
|
162
|
+
"(migration_id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
|
|
163
|
+
)
|
|
164
|
+
db.conn.commit()
|
|
165
|
+
applied = {r["migration_id"] for r in db.sql('SELECT migration_id FROM "_PluginMigrations"')}
|
|
166
|
+
|
|
167
|
+
for migration_id, fn in _PLUGIN_MIGRATIONS:
|
|
168
|
+
if migration_id in applied:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
fn(db)
|
|
172
|
+
db.sql(
|
|
173
|
+
'INSERT INTO "_PluginMigrations" (migration_id, applied_at) VALUES (?, ?)',
|
|
174
|
+
[migration_id, now()],
|
|
175
|
+
)
|
|
176
|
+
db.conn.commit()
|
|
177
|
+
print(f"[plugin-migration] applied {migration_id}", flush=True)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
db.conn.rollback()
|
|
180
|
+
print(f"[plugin-migration] FAILED {migration_id}: {e!r} — will retry next boot", flush=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def document_columns(doctype_slug: str) -> set:
|
|
184
|
+
"""The real column names of a registered doctype's table, or empty set if the
|
|
185
|
+
slug is unknown. Used to validate ad-hoc list filters against actual columns
|
|
186
|
+
(see the documents router) so an unknown field is a 400, never SQL."""
|
|
187
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
188
|
+
if not doctype:
|
|
189
|
+
return set()
|
|
190
|
+
return get_db()._get_table_columns(doctype)
|
|
191
|
+
|
|
192
|
+
|
|
117
193
|
def register_master(slug: str, table: str, name_field: str, *,
|
|
118
194
|
name_prefix: str | None = None,
|
|
119
195
|
identity_alias: str | None = None) -> None:
|
|
@@ -272,8 +348,18 @@ def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bo
|
|
|
272
348
|
db_filters["discarded"] = 0
|
|
273
349
|
|
|
274
350
|
|
|
351
|
+
def _order_clause(order_by: str = None, order: str = "desc") -> str:
|
|
352
|
+
"""ORDER BY clause for list queries. Defaults to newest-created first.
|
|
353
|
+
`order_by`, when given, must already be a validated column name (the
|
|
354
|
+
documents router checks it against document_columns) — it is quoted here."""
|
|
355
|
+
if not order_by:
|
|
356
|
+
return "creation DESC"
|
|
357
|
+
direction = "ASC" if str(order).lower() == "asc" else "DESC"
|
|
358
|
+
return f'"{order_by}" {direction}'
|
|
359
|
+
|
|
360
|
+
|
|
275
361
|
def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
|
|
276
|
-
include_discarded: bool = False) -> list:
|
|
362
|
+
include_discarded: bool = False, order_by: str = None, order: str = "desc") -> list:
|
|
277
363
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
278
364
|
if not doctype:
|
|
279
365
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
@@ -315,15 +401,17 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
|
|
|
315
401
|
db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset
|
|
316
402
|
)
|
|
317
403
|
|
|
404
|
+
order_clause = _order_clause(order_by, order)
|
|
405
|
+
|
|
318
406
|
# get_all doesn't support offset, so use raw SQL when needed
|
|
319
407
|
if offset:
|
|
320
|
-
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset)
|
|
408
|
+
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause)
|
|
321
409
|
|
|
322
410
|
rows = db.get_all(
|
|
323
411
|
doctype,
|
|
324
412
|
filters=db_filters if db_filters else None,
|
|
325
413
|
fields=["*"],
|
|
326
|
-
order_by=
|
|
414
|
+
order_by=order_clause,
|
|
327
415
|
limit=limit,
|
|
328
416
|
)
|
|
329
417
|
|
|
@@ -378,7 +466,7 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
|
|
|
378
466
|
return int(rows[0]["c"]) if rows else 0
|
|
379
467
|
|
|
380
468
|
|
|
381
|
-
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
469
|
+
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC"):
|
|
382
470
|
where_parts = []
|
|
383
471
|
params = []
|
|
384
472
|
for k, v in db_filters.items():
|
|
@@ -392,7 +480,7 @@ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
|
392
480
|
query = f'SELECT * FROM "{doctype}"'
|
|
393
481
|
if where_parts:
|
|
394
482
|
query += " WHERE " + " AND ".join(where_parts)
|
|
395
|
-
query += " ORDER BY
|
|
483
|
+
query += f" ORDER BY {order_clause}"
|
|
396
484
|
if limit:
|
|
397
485
|
query += f" LIMIT {int(limit)}"
|
|
398
486
|
if offset:
|
|
@@ -1391,6 +1391,14 @@ class Database:
|
|
|
1391
1391
|
self._col_cache.pop(table, None)
|
|
1392
1392
|
self._text_col_cache.pop(table, None)
|
|
1393
1393
|
|
|
1394
|
+
def ensure_column(self, table: str, column: str, definition: str) -> None:
|
|
1395
|
+
"""Idempotently add a column if it's missing. Public helper for plugin
|
|
1396
|
+
migrations registered via api.services.register_migration — the same
|
|
1397
|
+
backend-portable, PRAGMA/information_schema-guarded add the core's own
|
|
1398
|
+
migrations use. `definition` is the SQLite-flavoured column type (e.g.
|
|
1399
|
+
"TEXT", "INTEGER DEFAULT 0"); it is translated per dialect via _ddl."""
|
|
1400
|
+
self._add_column_if_missing(table, column, definition)
|
|
1401
|
+
|
|
1394
1402
|
def _migrate(self):
|
|
1395
1403
|
"""Run each pending migration in order, tracking applied versions."""
|
|
1396
1404
|
self.conn.execute(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|