lambda-erp 0.3.4__tar.gz → 0.5.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.3.4/frontend → lambda_erp-0.5.0}/LICENSE +1 -0
  2. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/PKG-INFO +2 -1
  3. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/README.md +1 -0
  4. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/auth.py +84 -23
  5. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/chat.py +58 -4
  6. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/main.py +3 -2
  7. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/masters.py +4 -17
  8. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/services.py +45 -0
  9. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/frontend/src/api/client.ts +1 -0
  10. lambda_erp-0.5.0/lambda_erp/__init__.py +33 -0
  11. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/pyproject.toml +1 -1
  12. lambda_erp-0.3.4/lambda_erp/__init__.py +0 -3
  13. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/.gitignore +0 -0
  14. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/__init__.py +0 -0
  15. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/attachments.py +0 -0
  16. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/bootstrap.py +0 -0
  17. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/demo_limits.py +0 -0
  18. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/deps.py +0 -0
  19. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/errors.py +0 -0
  20. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/oauth.py +0 -0
  21. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/pdf.py +0 -0
  22. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/providers.py +0 -0
  23. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/remarks_md.py +0 -0
  24. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/__init__.py +0 -0
  25. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/accounting.py +0 -0
  26. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/admin.py +0 -0
  27. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/analytics.py +0 -0
  28. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/bank_reconciliation.py +0 -0
  29. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/chat_api.py +0 -0
  30. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/documents.py +0 -0
  31. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/proposals.py +0 -0
  32. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/reports.py +0 -0
  33. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/setup.py +0 -0
  34. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/templates/document.html +0 -0
  35. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.3.4 → lambda_erp-0.5.0/frontend}/LICENSE +0 -0
  38. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/frontend/README.md +0 -0
  39. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/__init__.py +0 -0
  40. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  41. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/budget.py +0 -0
  42. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  43. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/general_ledger.py +0 -0
  44. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/journal_entry.py +0 -0
  45. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/payment_entry.py +0 -0
  46. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  47. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  48. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/revaluation.py +0 -0
  49. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  50. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  51. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/engine.py +0 -0
  52. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/pack.py +0 -0
  53. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  54. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  55. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  56. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  57. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  58. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  59. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  60. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/spine.py +0 -0
  61. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/subscription.py +0 -0
  62. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/buying/__init__.py +0 -0
  63. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/buying/purchase_order.py +0 -0
  64. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/__init__.py +0 -0
  65. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/currency.py +0 -0
  66. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/defaults.py +0 -0
  67. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  68. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  69. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/database.py +0 -0
  70. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/utils.py +0 -0
  84. {lambda_erp-0.3.4 → lambda_erp-0.5.0}/terraform/README.md +0 -0
@@ -1,3 +1,4 @@
1
+ Copyright 2026 TORUS INVESTMENTS AG
1
2
 
2
3
  Apache License
3
4
  Version 2.0, January 2004
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.3.4
3
+ Version: 0.5.0
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -159,6 +159,7 @@ The result is booked in one step — chart of accounts, sensible default account
159
159
  - Custom analytics drafts via chat (persisted, shareable, editable)
160
160
  - Server-side aggregation tool for in-chat factual answers across large datasets
161
161
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
162
+ - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
162
163
  - Auth with admin/manager/viewer roles plus a public demo mode
163
164
  - Full test suite that exercises every cycle against an in-memory SQLite
164
165
 
@@ -132,6 +132,7 @@ The result is booked in one step — chart of accounts, sensible default account
132
132
  - Custom analytics drafts via chat (persisted, shareable, editable)
133
133
  - Server-side aggregation tool for in-chat factual answers across large datasets
134
134
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
135
+ - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
135
136
  - Auth with admin/manager/viewer roles plus a public demo mode
136
137
  - Full test suite that exercises every cycle against an in-memory SQLite
137
138
 
@@ -198,20 +198,48 @@ ASSIGNABLE_ROLES = {"admin", "manager", "viewer"}
198
198
 
199
199
 
200
200
  def get_current_user(request: Request) -> dict:
201
- """FastAPI dependency: extract and validate JWT from cookie.
202
- Falls back to public_manager if one exists and no cookie is set."""
203
- token = request.cookies.get(COOKIE_NAME)
201
+ """FastAPI dependency: resolve the caller for every cookie-gated REST route.
202
+
203
+ Resolution order:
204
+ 1. Session cookie (the web app) — a valid JWT wins.
205
+ 2. `Authorization: Bearer <api-key>` — a programmatic caller, gated by the
206
+ `rest_api_enabled` Settings flag (off by default). The key acts AS its
207
+ owner with the owner's live role (capped at the key's role — see
208
+ `_lookup_api_key`), so a document it creates is attributed to the real
209
+ user and every `require_role` check behaves as if that user logged in.
210
+ This is the same key type the chat API uses; the two surfaces are
211
+ gated by independent flags.
212
+ 3. Public manager (demo mode) when no credential is presented.
213
+
214
+ A malformed or invalid Bearer credential is a *failed* auth attempt, not a
215
+ fall-through — it raises 401 rather than silently downgrading to demo
216
+ access. `_lookup_api_key` / `_bearer_token` / `_setting_enabled` are defined
217
+ lower in this module and resolved at call time (late binding)."""
218
+ db = get_db()
204
219
 
220
+ token = request.cookies.get(COOKIE_NAME)
205
221
  if token:
206
222
  user_name = decode_token(token)
207
223
  if user_name:
208
- db = get_db()
209
224
  user = db.get_value("User", user_name, ["name", "email", "full_name", "role", "enabled"])
210
225
  if user and user.get("enabled"):
211
226
  return dict(user)
212
227
 
228
+ bearer = _bearer_token(request)
229
+ if bearer is not None:
230
+ if not _setting_enabled(db, "rest_api_enabled"):
231
+ raise HTTPException(status_code=401, detail="REST API key access is disabled")
232
+ key, owner, effective_role = _lookup_api_key(db, bearer)
233
+ return {
234
+ "name": owner["name"],
235
+ "email": owner.get("email"),
236
+ "full_name": owner.get("full_name"),
237
+ "role": effective_role,
238
+ "enabled": owner.get("enabled"),
239
+ "api_key_id": key["id"],
240
+ }
241
+
213
242
  # Fall back to public manager (demo mode)
214
- db = get_db()
215
243
  pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
216
244
  if pub:
217
245
  return dict(pub[0])
@@ -586,6 +614,12 @@ DEFAULTS = {
586
614
  # active. Default off — an admin turns it on and issues keys. See
587
615
  # docs/chat-api-plan.md.
588
616
  "chat_api_enabled": "0",
617
+ # When "1", the same Bearer API keys authenticate the regular REST API
618
+ # (/api/documents, /api/masters, /api/reports, …) in addition to the session
619
+ # cookie. Default off — an admin turns it on to let connectors/scripts drive
620
+ # the ERP the way the web app does. Independent of chat_api_enabled. See
621
+ # docs/rest-api.md.
622
+ "rest_api_enabled": "0",
589
623
  }
590
624
 
591
625
 
@@ -655,27 +689,32 @@ def _role_rank(role: str) -> int:
655
689
  return ROLE_HIERARCHY.get(role, 0)
656
690
 
657
691
 
658
- def get_api_caller(request: Request) -> dict:
659
- """FastAPI dependency for the programmatic chat API.
660
-
661
- Gated by the `chat_api_enabled` Settings flag — when off the whole surface
662
- 404s (never advertised on instances that don't use it). Otherwise validates
663
- the `Authorization: Bearer <token>` header against a non-revoked Api Key.
692
+ def _bearer_token(request: Request) -> str | None:
693
+ """The token from an `Authorization: Bearer <token>` header.
664
694
 
665
- Keys are per-user credentials: the effective role is resolved LIVE as
666
- min(key's role cap, owner's current role), and a disabled or deleted owner
667
- kills every one of their keys immediately. `name` is the key's
668
- session_owner (``api:<username>``) all of one user's keys share a session
669
- space, separate from their interactive web chats.
695
+ Returns None when the header is absent or not a Bearer scheme (no auth
696
+ attempt the caller may still have a cookie). Returns "" for a bare
697
+ `Bearer ` with no token a malformed *attempt* that callers treat as a
698
+ 401, distinct from None.
670
699
  """
671
- db = get_db()
672
- if not _setting_enabled(db, "chat_api_enabled"):
673
- raise HTTPException(status_code=404, detail="Not Found")
674
-
675
700
  header = request.headers.get("authorization", "")
676
701
  if not header.lower().startswith("bearer "):
677
- raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
678
- token = header[7:].strip()
702
+ return None
703
+ return header[7:].strip()
704
+
705
+
706
+ def _lookup_api_key(db, token: str) -> tuple[dict, dict, str]:
707
+ """Validate a Bearer API token → (key, owner, effective_role).
708
+
709
+ Raises 401 on any failure (empty/unknown/revoked token, or a disabled/
710
+ deleted owner). Bumps `last_used_at`. Shared by the chat API and the REST
711
+ API so both resolve a key identically — they differ only in the Settings
712
+ flag that gates them and the caller identity they build from the result.
713
+
714
+ The effective role is resolved LIVE as min(key's role cap, owner's current
715
+ role): a key can never out-rank its owner, and lowering an owner's role (or
716
+ disabling them) instantly constrains every one of their keys.
717
+ """
679
718
  if not token:
680
719
  raise HTTPException(status_code=401, detail="Empty bearer token")
681
720
 
@@ -687,15 +726,37 @@ def get_api_caller(request: Request) -> dict:
687
726
  raise HTTPException(status_code=401, detail="Invalid API key")
688
727
  key = dict(rows[0])
689
728
 
690
- owner = db.get_value("User", key["owner"], ["name", "full_name", "role", "enabled"])
729
+ owner = db.get_value("User", key["owner"], ["name", "email", "full_name", "role", "enabled"])
691
730
  if not owner or not owner.get("enabled"):
692
731
  raise HTTPException(status_code=401, detail="Invalid API key")
732
+ owner = dict(owner)
693
733
  effective_role = (
694
734
  key["role"] if _role_rank(key["role"]) <= _role_rank(owner["role"]) else owner["role"]
695
735
  )
696
736
 
697
737
  db.sql('UPDATE "Api Key" SET last_used_at = ? WHERE id = ?', [now(), key["id"]])
698
738
  db.conn.commit()
739
+ return key, owner, effective_role
740
+
741
+
742
+ def get_api_caller(request: Request) -> dict:
743
+ """FastAPI dependency for the programmatic chat API (POST /api/v1/chat).
744
+
745
+ Gated by the `chat_api_enabled` Settings flag — when off the whole surface
746
+ 404s (never advertised on instances that don't use it). Otherwise validates
747
+ the Bearer key via `_lookup_api_key`. The caller's `name` is the key's
748
+ session_owner (``api:<username>``) — all of one user's keys share a chat
749
+ session space, separate from their interactive web chats. (The REST API, by
750
+ contrast, has the key act as the real user — see `get_current_user`.)
751
+ """
752
+ db = get_db()
753
+ if not _setting_enabled(db, "chat_api_enabled"):
754
+ raise HTTPException(status_code=404, detail="Not Found")
755
+
756
+ token = _bearer_token(request)
757
+ if token is None:
758
+ raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
759
+ key, owner, effective_role = _lookup_api_key(db, token)
699
760
 
700
761
  return {
701
762
  "name": key["session_owner"],
@@ -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
@@ -70,7 +71,7 @@ async def lifespan(app: FastAPI):
70
71
 
71
72
  app = FastAPI(
72
73
  title="Lambda ERP",
73
- version="0.1.0",
74
+ version=get_app_version(),
74
75
  lifespan=lifespan,
75
76
  )
76
77
 
@@ -105,7 +106,7 @@ app.include_router(chat_api.router, prefix="/api")
105
106
 
106
107
  @app.get("/api/health")
107
108
  def health():
108
- return {"status": "ok"}
109
+ return {"status": "ok", "version": get_app_version()}
109
110
 
110
111
 
111
112
  @app.websocket("/ws/chat")
@@ -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)."""
@@ -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,32 @@ def register_doctype(doctype: str, cls, slug: str | None = None) -> None:
95
114
  DOCTYPE_TO_SLUG[doctype] = slug
96
115
 
97
116
 
117
+ def register_master(slug: str, table: str, name_field: str, *,
118
+ name_prefix: str | None = None,
119
+ identity_alias: str | None = None) -> None:
120
+ """Register (or override) a master type — the master-side counterpart of
121
+ `register_doctype`.
122
+
123
+ A plugin calls this at startup and the master becomes a first-class type
124
+ everywhere `MASTER_TABLES` is consulted: the REST CRUD surface
125
+ (`/api/masters/{slug}`) and the chat tools (search_masters,
126
+ get_master_fields, create/update/delete_master — their schemas and the
127
+ system prompt are built from the live registry per request). Fields are
128
+ never declared: they're introspected from the table at call time, so every
129
+ text column of `table` is immediately searchable.
130
+
131
+ `name_field` is the human display column (e.g. "company_name").
132
+ `name_prefix` enables auto-generated ids (prefix "LEAD" -> LEAD-001) when a
133
+ record is created without an explicit `name`. `identity_alias` lets callers
134
+ address the `name` PK under a friendlier key, like item's `item_code`.
135
+ """
136
+ MASTER_TABLES[slug] = (table, name_field)
137
+ if name_prefix:
138
+ MASTER_NAME_PREFIXES[slug] = name_prefix
139
+ if identity_alias:
140
+ MASTER_IDENTITY_ALIAS[slug] = identity_alias
141
+
142
+
98
143
  def get_document_class(doctype_slug: str):
99
144
  """Get document class from URL slug."""
100
145
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
@@ -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()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.3.4"
3
+ version = "0.5.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