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.
Files changed (84) hide show
  1. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/PKG-INFO +17 -2
  2. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/README.md +16 -1
  3. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/auth.py +59 -0
  4. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/bootstrap.py +16 -1
  5. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/chat.py +58 -4
  6. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/main.py +15 -2
  7. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/documents.py +30 -2
  8. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/masters.py +4 -17
  9. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/services.py +139 -6
  10. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/src/api/client.ts +1 -0
  11. lambda_erp-0.6.0/lambda_erp/__init__.py +33 -0
  12. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/database.py +8 -0
  13. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/pyproject.toml +1 -1
  14. lambda_erp-0.4.0/lambda_erp/__init__.py +0 -3
  15. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/.gitignore +0 -0
  16. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/LICENSE +0 -0
  17. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/__init__.py +0 -0
  18. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/attachments.py +0 -0
  19. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/demo_limits.py +0 -0
  20. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/deps.py +0 -0
  21. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/errors.py +0 -0
  22. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/oauth.py +0 -0
  23. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/pdf.py +0 -0
  24. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/providers.py +0 -0
  25. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/remarks_md.py +0 -0
  26. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/__init__.py +0 -0
  27. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/accounting.py +0 -0
  28. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/admin.py +0 -0
  29. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/analytics.py +0 -0
  30. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/bank_reconciliation.py +0 -0
  31. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/chat_api.py +0 -0
  32. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/proposals.py +0 -0
  33. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/reports.py +0 -0
  34. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/routers/setup.py +0 -0
  35. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/templates/document.html +0 -0
  36. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/api/templates/proposal.html +0 -0
  37. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/docs/agents/README.md +0 -0
  38. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/LICENSE +0 -0
  39. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/frontend/README.md +0 -0
  40. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/__init__.py +0 -0
  41. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  42. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/budget.py +0 -0
  43. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  44. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/general_ledger.py +0 -0
  45. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/journal_entry.py +0 -0
  46. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/payment_entry.py +0 -0
  47. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  48. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  49. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/revaluation.py +0 -0
  50. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  51. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  52. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/engine.py +0 -0
  53. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/pack.py +0 -0
  54. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  55. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  56. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  57. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  58. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  59. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  60. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  61. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/setup/spine.py +0 -0
  62. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/accounting/subscription.py +0 -0
  63. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/buying/__init__.py +0 -0
  64. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/buying/purchase_order.py +0 -0
  65. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/__init__.py +0 -0
  66. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/currency.py +0 -0
  67. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/defaults.py +0 -0
  68. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  69. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  70. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.4.0 → lambda_erp-0.6.0}/lambda_erp/utils.py +0 -0
  84. {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.4.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
- 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
@@ -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
- customer, supplier, item, warehouse, account, company, cost-center
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=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="0.1.0",
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
- from api.services import MASTER_TABLES
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="creation DESC",
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 creation DESC"
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(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,3 +0,0 @@
1
- """Lambda ERP - Core ERP logic packaged as a standalone library."""
2
-
3
- __version__ = "0.1.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