lambda-erp 0.4.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.4.0 → lambda_erp-0.6.0}/PKG-INFO +17 -2
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/README.md +16 -1
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/auth.py +59 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/bootstrap.py +16 -1
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/chat.py +58 -4
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/main.py +15 -2
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/documents.py +30 -2
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/masters.py +4 -17
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/services.py +139 -6
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/src/api/client.ts +1 -0
- lambda_erp-0.6.0/lambda_erp/__init__.py +33 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/database.py +8 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/pyproject.toml +1 -1
- lambda_erp-0.4.0/lambda_erp/__init__.py +0 -3
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/.gitignore +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/LICENSE +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/attachments.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/deps.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/errors.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/oauth.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/pdf.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/providers.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/setup.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/templates/document.html +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/templates/proposal.html +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/LICENSE +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/README.md +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/model.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.4.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
|
|
@@ -10,6 +10,7 @@ Supports multiple chat sessions, each with their own history and auto-generated
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import base64
|
|
13
|
+
import copy
|
|
13
14
|
import difflib
|
|
14
15
|
import io
|
|
15
16
|
import json
|
|
@@ -1163,6 +1164,44 @@ TOOLS = [
|
|
|
1163
1164
|
},
|
|
1164
1165
|
]
|
|
1165
1166
|
|
|
1167
|
+
|
|
1168
|
+
def _extra_document_slugs():
|
|
1169
|
+
"""Doctype slugs registered beyond the core list (via register_doctype)."""
|
|
1170
|
+
return sorted(s for s in services.SLUG_TO_DOCTYPE if s not in DOCUMENT_SLUGS)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _extra_master_types():
|
|
1174
|
+
"""Master types registered beyond the core list (via register_master)."""
|
|
1175
|
+
return sorted(m for m in services.MASTER_TABLES if m not in MASTER_TYPES)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def build_tools():
|
|
1179
|
+
"""TOOLS with the doctype/master enums widened from the live registries.
|
|
1180
|
+
|
|
1181
|
+
Plugins register doctypes and masters at startup — after this module is
|
|
1182
|
+
imported — so the static TOOLS constant can't know about them. The tool
|
|
1183
|
+
handlers already resolve through the live registries; the enums in the
|
|
1184
|
+
schemas were the only thing shutting registered types out of chat. Built
|
|
1185
|
+
per request (cheap next to the LLM call), which also makes plugin load
|
|
1186
|
+
order irrelevant. Only enums that exactly match the core lists are
|
|
1187
|
+
widened, so any differently-constrained doctype/master param (e.g. a
|
|
1188
|
+
curated subset) is left alone.
|
|
1189
|
+
"""
|
|
1190
|
+
extra_docs = _extra_document_slugs()
|
|
1191
|
+
extra_masters = _extra_master_types()
|
|
1192
|
+
if not extra_docs and not extra_masters:
|
|
1193
|
+
return TOOLS
|
|
1194
|
+
tools = copy.deepcopy(TOOLS)
|
|
1195
|
+
for tool in tools:
|
|
1196
|
+
props = tool["function"].get("parameters", {}).get("properties", {})
|
|
1197
|
+
for schema in props.values():
|
|
1198
|
+
if schema.get("enum") == DOCUMENT_SLUGS:
|
|
1199
|
+
schema["enum"] = DOCUMENT_SLUGS + extra_docs
|
|
1200
|
+
elif schema.get("enum") == MASTER_TYPES:
|
|
1201
|
+
schema["enum"] = MASTER_TYPES + extra_masters
|
|
1202
|
+
return tools
|
|
1203
|
+
|
|
1204
|
+
|
|
1166
1205
|
# ---------------------------------------------------------------------------
|
|
1167
1206
|
# Tool handlers — dispatch to existing service functions
|
|
1168
1207
|
# ---------------------------------------------------------------------------
|
|
@@ -1845,6 +1884,21 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
1845
1884
|
analytics_context = _prompt_analytics_context()
|
|
1846
1885
|
uom_context = _prompt_uom_context()
|
|
1847
1886
|
|
|
1887
|
+
# Deployment-registered types (register_doctype / register_master). Folded
|
|
1888
|
+
# into the type lists below so the assistant knows they exist and can act
|
|
1889
|
+
# on them from day one — the tool schemas widen the same way (build_tools).
|
|
1890
|
+
extra_docs = _extra_document_slugs()
|
|
1891
|
+
extra_masters = _extra_master_types()
|
|
1892
|
+
extension_doctypes_line = (
|
|
1893
|
+
f"\n- **Extensions (deployment-specific):** {', '.join(extra_docs)}" if extra_docs else ""
|
|
1894
|
+
)
|
|
1895
|
+
master_types_line = ", ".join(MASTER_TYPES + extra_masters)
|
|
1896
|
+
extension_master_keys = "".join(
|
|
1897
|
+
f"\n- **{services.MASTER_TABLES[m][0]}** (master_type `{m}`): key = `name`, "
|
|
1898
|
+
f"display = `{services.MASTER_TABLES[m][1]}`"
|
|
1899
|
+
for m in extra_masters
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1848
1902
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
1849
1903
|
# inside the ERP, so web-relative links are clickable. On the "api" channel the
|
|
1850
1904
|
# reply is relayed to an EXTERNAL app (lambda-web / the Lambda app) that is not
|
|
@@ -1952,7 +2006,7 @@ When a user asks you to do something they don't have permission for, explain wha
|
|
|
1952
2006
|
- **Buying:** purchase-order, purchase-invoice
|
|
1953
2007
|
- **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
|
|
1954
2008
|
- **Stock:** stock-entry, delivery-note, purchase-receipt
|
|
1955
|
-
- **Settings:** pricing-rule
|
|
2009
|
+
- **Settings:** pricing-rule{extension_doctypes_line}
|
|
1956
2010
|
|
|
1957
2011
|
## Document Workflow & What Each Document Does
|
|
1958
2012
|
|
|
@@ -2076,7 +2130,7 @@ For purchased goods, prefer Purchase Receipt when receiving separately from bill
|
|
|
2076
2130
|
- Cancelled documents cannot be re-submitted or modified.
|
|
2077
2131
|
|
|
2078
2132
|
## Master Data Types
|
|
2079
|
-
|
|
2133
|
+
{master_types_line}
|
|
2080
2134
|
|
|
2081
2135
|
## Master Data Deletion
|
|
2082
2136
|
Master records CAN be deleted — use the `delete_master` tool (ADMIN role only). It is reference-protected: an unreferenced record (e.g. a mistakenly created or duplicate one) is permanently deleted; a record referenced by any document, GL entry, or stock data is NOT deleted but automatically disabled/archived instead, and the result names the blocking reference — relay that reason to the user. When the user merely wants a record out of the way (not destroyed), prefer `update_master` with `{{"disabled": 1}}`. Non-admin users: explain that deletion needs an admin and offer to disable instead.
|
|
@@ -2153,7 +2207,7 @@ Every master record has a primary key (the `name` column) and a human-readable d
|
|
|
2153
2207
|
- **Item:** key = `item_code` (e.g. `SVC-005`), display = `item_name` (e.g. "Project Management")
|
|
2154
2208
|
- **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
|
|
2155
2209
|
- **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
|
|
2156
|
-
- **Warehouse / Company / Account / Cost Center:** key = `name`
|
|
2210
|
+
- **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
|
|
2157
2211
|
|
|
2158
2212
|
When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, etc. in a document or child-table row, you MUST use the **primary key**, never the display name. `"item_code": "Project Management"` is ALWAYS wrong — it's a name, not a code.
|
|
2159
2213
|
|
|
@@ -2646,7 +2700,7 @@ async def run_thinking_loop(
|
|
|
2646
2700
|
openai_client.chat.completions.create,
|
|
2647
2701
|
model=model_name,
|
|
2648
2702
|
messages=messages,
|
|
2649
|
-
tools=
|
|
2703
|
+
tools=build_tools(),
|
|
2650
2704
|
tool_choice="auto",
|
|
2651
2705
|
max_completion_tokens=max_completion,
|
|
2652
2706
|
# gpt-5.6-terra rejects function tools on /v1/chat/completions
|
|
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
11
11
|
from fastapi.responses import FileResponse
|
|
12
12
|
from fastapi.staticfiles import StaticFiles
|
|
13
13
|
|
|
14
|
+
from lambda_erp import get_app_version
|
|
14
15
|
from lambda_erp.database import setup
|
|
15
16
|
|
|
16
17
|
from api.errors import register_exception_handlers
|
|
@@ -54,10 +55,22 @@ async def lifespan(app: FastAPI):
|
|
|
54
55
|
# Load customer extension plugins before anything creates documents.
|
|
55
56
|
load_plugins()
|
|
56
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
|
+
|
|
57
64
|
# Ensure the demo spend log table exists before any LLM call happens.
|
|
58
65
|
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
59
66
|
init_demo_spend_schema()
|
|
60
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
|
+
|
|
61
74
|
# When packaged as a demo container, land visitors straight in demo mode.
|
|
62
75
|
if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
|
|
63
76
|
from api.bootstrap import bootstrap_demo
|
|
@@ -70,7 +83,7 @@ async def lifespan(app: FastAPI):
|
|
|
70
83
|
|
|
71
84
|
app = FastAPI(
|
|
72
85
|
title="Lambda ERP",
|
|
73
|
-
version=
|
|
86
|
+
version=get_app_version(),
|
|
74
87
|
lifespan=lifespan,
|
|
75
88
|
)
|
|
76
89
|
|
|
@@ -105,7 +118,7 @@ app.include_router(chat_api.router, prefix="/api")
|
|
|
105
118
|
|
|
106
119
|
@app.get("/api/health")
|
|
107
120
|
def health():
|
|
108
|
-
return {"status": "ok"}
|
|
121
|
+
return {"status": "ok", "version": get_app_version()}
|
|
109
122
|
|
|
110
123
|
|
|
111
124
|
@app.websocket("/ws/chat")
|
|
@@ -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
|
|
|
@@ -5,7 +5,10 @@ import re
|
|
|
5
5
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
6
6
|
from lambda_erp.database import get_db
|
|
7
7
|
from lambda_erp.utils import _dict
|
|
8
|
-
|
|
8
|
+
# The per-type registries live in api.services so `register_master` can extend
|
|
9
|
+
# them; imported here (not moved) so existing `from api.routers.masters import
|
|
10
|
+
# MASTER_IDENTITY_ALIAS`-style imports keep working.
|
|
11
|
+
from api.services import MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS
|
|
9
12
|
from api.auth import require_role, require_non_public_manager
|
|
10
13
|
|
|
11
14
|
router = APIRouter(prefix="/masters", tags=["masters"])
|
|
@@ -14,22 +17,6 @@ _viewer = Depends(require_role("viewer"))
|
|
|
14
17
|
_manager = Depends(require_non_public_manager)
|
|
15
18
|
_admin = Depends(require_role("admin"))
|
|
16
19
|
|
|
17
|
-
MASTER_NAME_PREFIXES = {
|
|
18
|
-
"customer": "CUST",
|
|
19
|
-
"supplier": "SUPP",
|
|
20
|
-
"item": "ITEM",
|
|
21
|
-
"warehouse": "WH",
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
# An Item's code is stored in the primary-key column `name`, but every
|
|
25
|
-
# transaction line and report references it as
|
|
26
|
-
# `item_code`. Accept that intuitive alias on the master API so callers
|
|
27
|
-
# (LLM tools, REST clients) can set and read the code under the name they
|
|
28
|
-
# already use everywhere else, instead of silently falling back to ITEM-NNN.
|
|
29
|
-
MASTER_IDENTITY_ALIAS = {
|
|
30
|
-
"item": "item_code",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
20
|
|
|
34
21
|
def _echo_identity_alias(master_type: str, row):
|
|
35
22
|
"""Mirror the master's `name` back under its intuitive alias (item_code)."""
|
|
@@ -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 (
|
|
@@ -70,6 +70,25 @@ MASTER_TABLES = {
|
|
|
70
70
|
"cost-center": ("Cost Center", "cost_center_name"),
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
# Auto-naming prefixes: masters created without an explicit `name` get a
|
|
74
|
+
# generated PREFIX-NNN id (see masters.create_master_record). Types without an
|
|
75
|
+
# entry require an explicit name (or derive it, like company).
|
|
76
|
+
MASTER_NAME_PREFIXES = {
|
|
77
|
+
"customer": "CUST",
|
|
78
|
+
"supplier": "SUPP",
|
|
79
|
+
"item": "ITEM",
|
|
80
|
+
"warehouse": "WH",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# An Item's code is stored in the primary-key column `name`, but every
|
|
84
|
+
# transaction line and report references it as `item_code`. Accept that
|
|
85
|
+
# intuitive alias on the master API so callers (LLM tools, REST clients) can
|
|
86
|
+
# set and read the code under the name they already use everywhere else,
|
|
87
|
+
# instead of silently falling back to ITEM-NNN.
|
|
88
|
+
MASTER_IDENTITY_ALIAS = {
|
|
89
|
+
"item": "item_code",
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
# Slug <-> doctype name mapping
|
|
74
93
|
SLUG_TO_DOCTYPE = {}
|
|
75
94
|
DOCTYPE_TO_SLUG = {}
|
|
@@ -95,6 +114,108 @@ def register_doctype(doctype: str, cls, slug: str | None = None) -> None:
|
|
|
95
114
|
DOCTYPE_TO_SLUG[doctype] = slug
|
|
96
115
|
|
|
97
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
|
+
|
|
193
|
+
def register_master(slug: str, table: str, name_field: str, *,
|
|
194
|
+
name_prefix: str | None = None,
|
|
195
|
+
identity_alias: str | None = None) -> None:
|
|
196
|
+
"""Register (or override) a master type — the master-side counterpart of
|
|
197
|
+
`register_doctype`.
|
|
198
|
+
|
|
199
|
+
A plugin calls this at startup and the master becomes a first-class type
|
|
200
|
+
everywhere `MASTER_TABLES` is consulted: the REST CRUD surface
|
|
201
|
+
(`/api/masters/{slug}`) and the chat tools (search_masters,
|
|
202
|
+
get_master_fields, create/update/delete_master — their schemas and the
|
|
203
|
+
system prompt are built from the live registry per request). Fields are
|
|
204
|
+
never declared: they're introspected from the table at call time, so every
|
|
205
|
+
text column of `table` is immediately searchable.
|
|
206
|
+
|
|
207
|
+
`name_field` is the human display column (e.g. "company_name").
|
|
208
|
+
`name_prefix` enables auto-generated ids (prefix "LEAD" -> LEAD-001) when a
|
|
209
|
+
record is created without an explicit `name`. `identity_alias` lets callers
|
|
210
|
+
address the `name` PK under a friendlier key, like item's `item_code`.
|
|
211
|
+
"""
|
|
212
|
+
MASTER_TABLES[slug] = (table, name_field)
|
|
213
|
+
if name_prefix:
|
|
214
|
+
MASTER_NAME_PREFIXES[slug] = name_prefix
|
|
215
|
+
if identity_alias:
|
|
216
|
+
MASTER_IDENTITY_ALIAS[slug] = identity_alias
|
|
217
|
+
|
|
218
|
+
|
|
98
219
|
def get_document_class(doctype_slug: str):
|
|
99
220
|
"""Get document class from URL slug."""
|
|
100
221
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
@@ -227,8 +348,18 @@ def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bo
|
|
|
227
348
|
db_filters["discarded"] = 0
|
|
228
349
|
|
|
229
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
|
+
|
|
230
361
|
def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
|
|
231
|
-
include_discarded: bool = False) -> list:
|
|
362
|
+
include_discarded: bool = False, order_by: str = None, order: str = "desc") -> list:
|
|
232
363
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
233
364
|
if not doctype:
|
|
234
365
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
@@ -270,15 +401,17 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
|
|
|
270
401
|
db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset
|
|
271
402
|
)
|
|
272
403
|
|
|
404
|
+
order_clause = _order_clause(order_by, order)
|
|
405
|
+
|
|
273
406
|
# get_all doesn't support offset, so use raw SQL when needed
|
|
274
407
|
if offset:
|
|
275
|
-
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)
|
|
276
409
|
|
|
277
410
|
rows = db.get_all(
|
|
278
411
|
doctype,
|
|
279
412
|
filters=db_filters if db_filters else None,
|
|
280
413
|
fields=["*"],
|
|
281
|
-
order_by=
|
|
414
|
+
order_by=order_clause,
|
|
282
415
|
limit=limit,
|
|
283
416
|
)
|
|
284
417
|
|
|
@@ -333,7 +466,7 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
|
|
|
333
466
|
return int(rows[0]["c"]) if rows else 0
|
|
334
467
|
|
|
335
468
|
|
|
336
|
-
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"):
|
|
337
470
|
where_parts = []
|
|
338
471
|
params = []
|
|
339
472
|
for k, v in db_filters.items():
|
|
@@ -347,7 +480,7 @@ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
|
347
480
|
query = f'SELECT * FROM "{doctype}"'
|
|
348
481
|
if where_parts:
|
|
349
482
|
query += " WHERE " + " AND ".join(where_parts)
|
|
350
|
-
query += " ORDER BY
|
|
483
|
+
query += f" ORDER BY {order_clause}"
|
|
351
484
|
if limit:
|
|
352
485
|
query += f" LIMIT {int(limit)}"
|
|
353
486
|
if offset:
|
|
@@ -447,6 +447,7 @@ export const api = {
|
|
|
447
447
|
getPublicManagerStatus: () => request<{ active: boolean; user?: any }>("/auth/public-manager"),
|
|
448
448
|
createPublicManager: () => request<any>("/auth/public-manager", { method: "POST" }),
|
|
449
449
|
removePublicManager: () => request<any>("/auth/public-manager", { method: "DELETE" }),
|
|
450
|
+
getHealth: () => request<{ status: string; version: string }>("/health"),
|
|
450
451
|
getSettings: () => request<Record<string, string>>("/auth/settings"),
|
|
451
452
|
updateSettings: (data: Record<string, string>) =>
|
|
452
453
|
request<Record<string, string>>("/auth/settings", { method: "PUT", body: JSON.stringify(data) }),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Lambda ERP - Core ERP logic packaged as a standalone library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_app_version() -> str:
|
|
5
|
+
"""Runtime version of the ERP core.
|
|
6
|
+
|
|
7
|
+
The single human-edited source is the packaging version in `pyproject.toml`,
|
|
8
|
+
which the release process bumps. A wheel/pip install doesn't ship
|
|
9
|
+
`pyproject.toml`, so there we read the installed package metadata (populated
|
|
10
|
+
from that same version at build time). A source checkout (incl. editable
|
|
11
|
+
dev) reads `pyproject.toml` directly, so it's correct without a reinstall.
|
|
12
|
+
Falls back to "dev" when neither is available.
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
18
|
+
if pyproject.is_file():
|
|
19
|
+
match = re.search(
|
|
20
|
+
r'^version\s*=\s*"([^"]+)"', pyproject.read_text(encoding="utf-8"), re.MULTILINE
|
|
21
|
+
)
|
|
22
|
+
if match:
|
|
23
|
+
return match.group(1)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import version
|
|
27
|
+
|
|
28
|
+
return version("lambda-erp")
|
|
29
|
+
except Exception:
|
|
30
|
+
return "dev"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__version__ = get_app_version()
|
|
@@ -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
|