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.
- {lambda_erp-0.3.4/frontend → lambda_erp-0.5.0}/LICENSE +1 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/PKG-INFO +2 -1
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/README.md +1 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/auth.py +84 -23
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/chat.py +58 -4
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/main.py +3 -2
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/masters.py +4 -17
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/services.py +45 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/frontend/src/api/client.ts +1 -0
- lambda_erp-0.5.0/lambda_erp/__init__.py +33 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/pyproject.toml +1 -1
- lambda_erp-0.3.4/lambda_erp/__init__.py +0 -3
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/.gitignore +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/attachments.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/bootstrap.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/deps.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/errors.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/oauth.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/pdf.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/providers.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/documents.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/routers/setup.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/templates/document.html +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/api/templates/proposal.html +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0/frontend}/LICENSE +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/frontend/README.md +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/database.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/model.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.3.4 → lambda_erp-0.5.0}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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:
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
659
|
-
"""
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
2133
|
+
{master_types_line}
|
|
2080
2134
|
|
|
2081
2135
|
## Master Data Deletion
|
|
2082
2136
|
Master records CAN be deleted — use the `delete_master` tool (ADMIN role only). It is reference-protected: an unreferenced record (e.g. a mistakenly created or duplicate one) is permanently deleted; a record referenced by any document, GL entry, or stock data is NOT deleted but automatically disabled/archived instead, and the result names the blocking reference — relay that reason to the user. When the user merely wants a record out of the way (not destroyed), prefer `update_master` with `{{"disabled": 1}}`. Non-admin users: explain that deletion needs an admin and offer to disable instead.
|
|
@@ -2153,7 +2207,7 @@ Every master record has a primary key (the `name` column) and a human-readable d
|
|
|
2153
2207
|
- **Item:** key = `item_code` (e.g. `SVC-005`), display = `item_name` (e.g. "Project Management")
|
|
2154
2208
|
- **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
|
|
2155
2209
|
- **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
|
|
2156
|
-
- **Warehouse / Company / Account / Cost Center:** key = `name`
|
|
2210
|
+
- **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
|
|
2157
2211
|
|
|
2158
2212
|
When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, etc. in a document or child-table row, you MUST use the **primary key**, never the display name. `"item_code": "Project Management"` is ALWAYS wrong — it's a name, not a code.
|
|
2159
2213
|
|
|
@@ -2646,7 +2700,7 @@ async def run_thinking_loop(
|
|
|
2646
2700
|
openai_client.chat.completions.create,
|
|
2647
2701
|
model=model_name,
|
|
2648
2702
|
messages=messages,
|
|
2649
|
-
tools=
|
|
2703
|
+
tools=build_tools(),
|
|
2650
2704
|
tool_choice="auto",
|
|
2651
2705
|
max_completion_tokens=max_completion,
|
|
2652
2706
|
# gpt-5.6-terra rejects function tools on /v1/chat/completions
|
|
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
11
11
|
from fastapi.responses import FileResponse
|
|
12
12
|
from fastapi.staticfiles import StaticFiles
|
|
13
13
|
|
|
14
|
+
from lambda_erp import get_app_version
|
|
14
15
|
from lambda_erp.database import setup
|
|
15
16
|
|
|
16
17
|
from api.errors import register_exception_handlers
|
|
@@ -70,7 +71,7 @@ async def lifespan(app: FastAPI):
|
|
|
70
71
|
|
|
71
72
|
app = FastAPI(
|
|
72
73
|
title="Lambda ERP",
|
|
73
|
-
version=
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|