lambda-erp 0.5.0__tar.gz → 0.6.1__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.
Files changed (83) hide show
  1. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/PKG-INFO +17 -2
  2. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/README.md +16 -1
  3. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/auth.py +59 -0
  4. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/bootstrap.py +16 -1
  5. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/chat.py +39 -2
  6. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/main.py +12 -0
  7. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/documents.py +30 -2
  8. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/services.py +111 -6
  9. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/database.py +8 -0
  10. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/pyproject.toml +1 -1
  11. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/.gitignore +0 -0
  12. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/LICENSE +0 -0
  13. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/__init__.py +0 -0
  14. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/attachments.py +0 -0
  15. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/deps.py +0 -0
  17. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/errors.py +0 -0
  18. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/oauth.py +0 -0
  19. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/pdf.py +0 -0
  20. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/providers.py +0 -0
  21. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/bank_reconciliation.py +0 -0
  27. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/chat_api.py +0 -0
  28. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/proposals.py +0 -0
  30. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/reports.py +0 -0
  31. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/setup.py +0 -0
  32. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/templates/document.html +0 -0
  33. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/LICENSE +0 -0
  36. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/README.md +0 -0
  37. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/src/api/client.ts +0 -0
  38. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/__init__.py +0 -0
  39. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/__init__.py +0 -0
  40. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/bank_transaction.py +0 -0
  41. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/budget.py +0 -0
  42. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  43. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/general_ledger.py +0 -0
  44. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/journal_entry.py +0 -0
  45. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/payment_entry.py +0 -0
  46. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/pos_invoice.py +0 -0
  47. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
  48. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/revaluation.py +0 -0
  49. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/sales_invoice.py +0 -0
  50. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/__init__.py +0 -0
  51. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/engine.py +0 -0
  52. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/pack.py +0 -0
  53. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  54. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  55. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  56. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  57. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  58. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  59. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/profiles.py +0 -0
  60. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/spine.py +0 -0
  61. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/subscription.py +0 -0
  62. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/buying/__init__.py +0 -0
  63. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/buying/purchase_order.py +0 -0
  64. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/__init__.py +0 -0
  65. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/currency.py +0 -0
  66. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/defaults.py +0 -0
  67. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/pricing_rule.py +0 -0
  68. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  69. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/exceptions.py +0 -0
  70. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/hooks.py +0 -0
  71. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/model.py +0 -0
  72. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/__init__.py +0 -0
  73. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/proposal.py +0 -0
  74. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/quotation.py +0 -0
  75. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/sales_order.py +0 -0
  76. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/simulation.py +0 -0
  77. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/__init__.py +0 -0
  78. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/delivery_note.py +0 -0
  79. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/purchase_receipt.py +0 -0
  80. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/stock_entry.py +0 -0
  81. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/stock_ledger.py +0 -0
  82. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/utils.py +0 -0
  83. {lambda_erp-0.5.0 → lambda_erp-0.6.1}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.5.0
3
+ Version: 0.6.1
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
- print("[bootstrap] public demo disabled first visitor registers as admin via the login page", flush=True)
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
@@ -463,7 +463,9 @@ TOOLS = [
463
463
  "type": "object",
464
464
  "properties": {
465
465
  "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
466
- "filters": {"type": "object", "description": "Optional filters like {\"status\": \"Draft\", \"customer\": \"CUST-001\"}", "default": {}},
466
+ "filters": {"type": "object", "description": "Optional equality filters on any column, e.g. {\"status\": \"Draft\", \"customer\": \"CUST-001\", \"lead_id\": \"LEAD-3316\"}", "default": {}},
467
+ "order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
468
+ "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
467
469
  "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
468
470
  },
469
471
  "required": ["doctype"],
@@ -1175,6 +1177,38 @@ def _extra_master_types():
1175
1177
  return sorted(m for m in services.MASTER_TABLES if m not in MASTER_TYPES)
1176
1178
 
1177
1179
 
1180
+ def _chat_doctype_section() -> str:
1181
+ """A '## Custom record types' block for doctypes registered via
1182
+ register_chat_doctype: each type's description, key fields, and its
1183
+ LINK_FIELDS relationships (read live from the Document class). Tells the
1184
+ model these are driven with the DOCUMENT tools and how they attach to a
1185
+ parent (e.g. a contact links to a lead via `lead_id`). Empty when none."""
1186
+ slugs = [s for s in services.CHAT_DOCTYPES if s in services.SLUG_TO_DOCTYPE]
1187
+ if not slugs:
1188
+ return ""
1189
+ lines = []
1190
+ for slug in slugs:
1191
+ meta = services.CHAT_DOCTYPES[slug]
1192
+ doctype = services.SLUG_TO_DOCTYPE[slug]
1193
+ cls = services.DOCUMENT_CLASSES.get(doctype)
1194
+ links = getattr(cls, "LINK_FIELDS", None) or {}
1195
+ line = f"- **{doctype}** (slug `{slug}`): {meta['description']}"
1196
+ if meta.get("fields"):
1197
+ line += f" Key fields: {', '.join('`' + f + '`' for f in meta['fields'])}."
1198
+ if links:
1199
+ link_str = ", ".join(f"`{f}` → the {t}'s name" for f, t in links.items())
1200
+ line += f" Links: {link_str}."
1201
+ lines.append(line)
1202
+ return (
1203
+ "\n\n## Custom record types (deployment-specific)\n"
1204
+ "Manage these with the DOCUMENT tools (`create_document`, `update_document`, "
1205
+ "`list_documents`, `get_document`) — NOT the master tools — so their `validate()` "
1206
+ "runs. To attach one to a parent, set its link field to the parent record's `name` "
1207
+ "(find the parent first, e.g. with `search_masters` or `list_documents`). Do not put "
1208
+ "child data on the parent's own fields.\n" + "\n".join(lines)
1209
+ )
1210
+
1211
+
1178
1212
  def build_tools():
1179
1213
  """TOOLS with the doctype/master enums widened from the live registries.
1180
1214
 
@@ -1217,6 +1251,8 @@ def _handle_list_documents(args):
1217
1251
  args["doctype"],
1218
1252
  filters=args.get("filters"),
1219
1253
  limit=args.get("limit", 20),
1254
+ order_by=args.get("order_by"),
1255
+ order=args.get("order", "desc"),
1220
1256
  )
1221
1257
  for row in rows:
1222
1258
  for key in child_keys:
@@ -1898,6 +1934,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
1898
1934
  f"display = `{services.MASTER_TABLES[m][1]}`"
1899
1935
  for m in extra_masters
1900
1936
  )
1937
+ chat_doctype_section = _chat_doctype_section()
1901
1938
 
1902
1939
  # Channel-aware link guidance. On the "web" channel the reader is a browser
1903
1940
  # inside the ERP, so web-relative links are clickable. On the "api" channel the
@@ -2006,7 +2043,7 @@ When a user asks you to do something they don't have permission for, explain wha
2006
2043
  - **Buying:** purchase-order, purchase-invoice
2007
2044
  - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
2008
2045
  - **Stock:** stock-entry, delivery-note, purchase-receipt
2009
- - **Settings:** pricing-rule{extension_doctypes_line}
2046
+ - **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
2010
2047
 
2011
2048
  ## Document Workflow & What Each Document Does
2012
2049
 
@@ -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:
@@ -140,6 +216,23 @@ def register_master(slug: str, table: str, name_field: str, *,
140
216
  MASTER_IDENTITY_ALIAS[slug] = identity_alias
141
217
 
142
218
 
219
+ # Chat guidance for registered doctypes: slug -> {"description", "fields"}. The
220
+ # document tools (create/update/list/get_document) already accept any registered
221
+ # doctype and run its validate(); this registry only teaches the AI chat WHAT a
222
+ # custom doctype is and HOW it links, so it uses the validated document path
223
+ # instead of, say, scribbling on a parent's fields. Relationships are read
224
+ # automatically from the Document class's LINK_FIELDS — not declared here.
225
+ CHAT_DOCTYPES: dict = {}
226
+
227
+
228
+ def register_chat_doctype(slug: str, *, description: str, fields: list | None = None) -> None:
229
+ """Give the AI chat a description (and optional key-field hints) for a doctype
230
+ already registered via `register_doctype`, so build_system_prompt can tell the
231
+ model what it is and how it links (LINK_FIELDS are surfaced automatically).
232
+ No new tools — the document tools already cover every registered doctype."""
233
+ CHAT_DOCTYPES[slug] = {"description": description, "fields": fields or []}
234
+
235
+
143
236
  def get_document_class(doctype_slug: str):
144
237
  """Get document class from URL slug."""
145
238
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
@@ -272,8 +365,18 @@ def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bo
272
365
  db_filters["discarded"] = 0
273
366
 
274
367
 
368
+ def _order_clause(order_by: str = None, order: str = "desc") -> str:
369
+ """ORDER BY clause for list queries. Defaults to newest-created first.
370
+ `order_by`, when given, must already be a validated column name (the
371
+ documents router checks it against document_columns) — it is quoted here."""
372
+ if not order_by:
373
+ return "creation DESC"
374
+ direction = "ASC" if str(order).lower() == "asc" else "DESC"
375
+ return f'"{order_by}" {direction}'
376
+
377
+
275
378
  def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
276
- include_discarded: bool = False) -> list:
379
+ include_discarded: bool = False, order_by: str = None, order: str = "desc") -> list:
277
380
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
278
381
  if not doctype:
279
382
  raise ValueError(f"Unknown document type: {doctype_slug}")
@@ -315,15 +418,17 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
315
418
  db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset
316
419
  )
317
420
 
421
+ order_clause = _order_clause(order_by, order)
422
+
318
423
  # get_all doesn't support offset, so use raw SQL when needed
319
424
  if offset:
320
- return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset)
425
+ return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause)
321
426
 
322
427
  rows = db.get_all(
323
428
  doctype,
324
429
  filters=db_filters if db_filters else None,
325
430
  fields=["*"],
326
- order_by="creation DESC",
431
+ order_by=order_clause,
327
432
  limit=limit,
328
433
  )
329
434
 
@@ -378,7 +483,7 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
378
483
  return int(rows[0]["c"]) if rows else 0
379
484
 
380
485
 
381
- def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
486
+ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC"):
382
487
  where_parts = []
383
488
  params = []
384
489
  for k, v in db_filters.items():
@@ -392,7 +497,7 @@ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
392
497
  query = f'SELECT * FROM "{doctype}"'
393
498
  if where_parts:
394
499
  query += " WHERE " + " AND ".join(where_parts)
395
- query += " ORDER BY creation DESC"
500
+ query += f" ORDER BY {order_clause}"
396
501
  if limit:
397
502
  query += f" LIMIT {int(limit)}"
398
503
  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(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
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