lambda-erp 0.8.12__tar.gz → 0.8.14__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.8.12 → lambda_erp-0.8.14}/PKG-INFO +1 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/chat.py +28 -9
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/main.py +2 -1
- lambda_erp-0.8.14/api/routers/actions.py +23 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/masters.py +43 -6
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/mcp.py +4 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/services.py +115 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/frontend/src/api/client.ts +6 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/pyproject.toml +1 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/.gitignore +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/LICENSE +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/attachments.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/auth.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/deps.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/errors.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/oauth.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/pdf.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/providers.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/templates/document.html +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/frontend/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.14}/terraform/README.md +0 -0
|
@@ -1260,8 +1260,8 @@ def _chat_doctype_section() -> str:
|
|
|
1260
1260
|
)
|
|
1261
1261
|
|
|
1262
1262
|
|
|
1263
|
-
def build_tools():
|
|
1264
|
-
"""TOOLS with
|
|
1263
|
+
def build_tools(user_info: dict | None = None):
|
|
1264
|
+
"""TOOLS widened with live plugin types and registered business actions.
|
|
1265
1265
|
|
|
1266
1266
|
Plugins register doctypes and masters at startup — after this module is
|
|
1267
1267
|
imported — so the static TOOLS constant can't know about them. The tool
|
|
@@ -1274,7 +1274,14 @@ def build_tools():
|
|
|
1274
1274
|
"""
|
|
1275
1275
|
extra_docs = _extra_document_slugs()
|
|
1276
1276
|
extra_masters = _extra_master_types()
|
|
1277
|
-
|
|
1277
|
+
action_tools = services.registered_action_tools()
|
|
1278
|
+
if user_info is not None:
|
|
1279
|
+
role = user_info.get("role")
|
|
1280
|
+
action_tools = [
|
|
1281
|
+
tool for tool in action_tools
|
|
1282
|
+
if services.registered_action_allowed(tool["function"]["name"], role)
|
|
1283
|
+
]
|
|
1284
|
+
if not extra_docs and not extra_masters and not action_tools:
|
|
1278
1285
|
return TOOLS
|
|
1279
1286
|
tools = copy.deepcopy(TOOLS)
|
|
1280
1287
|
for tool in tools:
|
|
@@ -1284,6 +1291,8 @@ def build_tools():
|
|
|
1284
1291
|
schema["enum"] = DOCUMENT_SLUGS + extra_docs
|
|
1285
1292
|
elif schema.get("enum") == MASTER_TYPES:
|
|
1286
1293
|
schema["enum"] = MASTER_TYPES + extra_masters
|
|
1294
|
+
existing_names = {tool["function"]["name"] for tool in tools}
|
|
1295
|
+
tools.extend(tool for tool in action_tools if tool["function"]["name"] not in existing_names)
|
|
1287
1296
|
return tools
|
|
1288
1297
|
|
|
1289
1298
|
|
|
@@ -2053,11 +2062,20 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
2053
2062
|
f"\n- **Extensions (deployment-specific):** {', '.join(extra_docs)}" if extra_docs else ""
|
|
2054
2063
|
)
|
|
2055
2064
|
master_types_line = ", ".join(MASTER_TYPES + extra_masters)
|
|
2056
|
-
extension_master_keys = ""
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2065
|
+
extension_master_keys = ""
|
|
2066
|
+
for master in extra_masters:
|
|
2067
|
+
meta = services.MASTER_METADATA.get(master, {})
|
|
2068
|
+
extension_master_keys += (
|
|
2069
|
+
f"\n- **{services.MASTER_TABLES[master][0]}** (master_type `{master}`): "
|
|
2070
|
+
f"key = `name`, display = `{services.MASTER_TABLES[master][1]}`"
|
|
2071
|
+
)
|
|
2072
|
+
if meta.get("description"):
|
|
2073
|
+
extension_master_keys += f". {meta['description']}"
|
|
2074
|
+
if meta.get("fields"):
|
|
2075
|
+
extension_master_keys += (
|
|
2076
|
+
". Key fields: "
|
|
2077
|
+
+ ", ".join(f"`{field}`" for field in meta["fields"])
|
|
2078
|
+
)
|
|
2061
2079
|
chat_doctype_section = _chat_doctype_section()
|
|
2062
2080
|
|
|
2063
2081
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
@@ -2974,6 +2992,7 @@ async def run_thinking_loop(
|
|
|
2974
2992
|
# delete_master needs the caller's role (admin-only) — scoped here rather
|
|
2975
2993
|
# than in TOOL_HANDLERS, whose handlers are called with (args) only.
|
|
2976
2994
|
tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, user_info)
|
|
2995
|
+
tool_handlers.update(services.registered_action_handlers(user_info))
|
|
2977
2996
|
|
|
2978
2997
|
user_role = user_info.get("role") if user_info else None
|
|
2979
2998
|
demo_mode = is_demo_role(user_role)
|
|
@@ -3075,7 +3094,7 @@ async def run_thinking_loop(
|
|
|
3075
3094
|
# Responses API (ERP_CHAT_API=responses). Returns Chat-shaped
|
|
3076
3095
|
# (message, usage) either way, so everything below is unchanged.
|
|
3077
3096
|
message, usage = await asyncio.to_thread(
|
|
3078
|
-
_orchestrator_turn, openai_client, messages, build_tools(), max_completion,
|
|
3097
|
+
_orchestrator_turn, openai_client, messages, build_tools(user_info), max_completion,
|
|
3079
3098
|
)
|
|
3080
3099
|
except Exception as e:
|
|
3081
3100
|
await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
|
|
@@ -19,7 +19,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
|
19
19
|
from api.oauth import router as oauth_router
|
|
20
20
|
from api.attachments import router as attachments_router
|
|
21
21
|
from api.chat import chat_websocket, router as chat_router
|
|
22
|
-
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability
|
|
22
|
+
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability, actions
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def load_plugins() -> None:
|
|
@@ -106,6 +106,7 @@ app.include_router(attachments_router, prefix="/api")
|
|
|
106
106
|
app.include_router(documents.router, prefix="/api")
|
|
107
107
|
app.include_router(proposals.router, prefix="/api")
|
|
108
108
|
app.include_router(masters.router, prefix="/api")
|
|
109
|
+
app.include_router(actions.router, prefix="/api")
|
|
109
110
|
app.include_router(availability.router, prefix="/api")
|
|
110
111
|
app.include_router(reports.router, prefix="/api")
|
|
111
112
|
app.include_router(analytics.router, prefix="/api")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""REST surface for deployment-registered business actions."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
|
|
5
|
+
from api import services
|
|
6
|
+
from api.auth import require_role
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/actions", tags=["actions"])
|
|
10
|
+
_viewer = Depends(require_role("viewer"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/{action_name}")
|
|
14
|
+
def run_action(action_name: str, data: dict, user: dict = _viewer):
|
|
15
|
+
"""Execute the same registered action exposed to chat and MCP."""
|
|
16
|
+
try:
|
|
17
|
+
return services.run_registered_action(action_name, data, user)
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise HTTPException(status_code=404, detail=f"Unknown action: {action_name}")
|
|
20
|
+
except PermissionError as exc:
|
|
21
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"""Master data CRUD: Customer, Supplier, Item, Warehouse, Account, Company."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
import secrets
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
6
7
|
from lambda_erp.database import get_db
|
|
7
|
-
from lambda_erp.utils import _dict
|
|
8
|
+
from lambda_erp.utils import _dict, now
|
|
8
9
|
# The per-type registries live in api.services so `register_master` can extend
|
|
9
10
|
# them; imported here (not moved) so existing `from api.routers.masters import
|
|
10
11
|
# MASTER_IDENTITY_ALIAS`-style imports keep working.
|
|
11
12
|
from api.services import (
|
|
12
|
-
MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS,
|
|
13
|
+
MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_RANDOM_NAME_TYPES, MASTER_IDENTITY_ALIAS,
|
|
14
|
+
MASTER_REFERENCE_CHECKS,
|
|
13
15
|
_search_clause, _where_from_filters, master_search_columns, count_query_cached,
|
|
14
16
|
)
|
|
15
17
|
from api.auth import require_role, require_non_public_manager
|
|
@@ -145,7 +147,11 @@ def _with_active_filter(db, doctype: str, filters: dict | None = None) -> dict |
|
|
|
145
147
|
|
|
146
148
|
def _find_reference(master_type: str, name: str) -> str | None:
|
|
147
149
|
db = get_db()
|
|
148
|
-
|
|
150
|
+
checks = [
|
|
151
|
+
*DELETE_REFERENCE_CHECKS.get(master_type, []),
|
|
152
|
+
*MASTER_REFERENCE_CHECKS.get(master_type, []),
|
|
153
|
+
]
|
|
154
|
+
for query, label in checks:
|
|
149
155
|
params = [name] if query.count("?") == 1 else [name] * query.count("?")
|
|
150
156
|
try:
|
|
151
157
|
if db.sql(query, params):
|
|
@@ -178,6 +184,14 @@ def _generate_master_name(db, doctype: str, prefix: str) -> str:
|
|
|
178
184
|
return f"{prefix}-{number:03d}"
|
|
179
185
|
|
|
180
186
|
|
|
187
|
+
def _generate_random_master_name(db, doctype: str, prefix: str) -> str:
|
|
188
|
+
"""Opaque, index-scale id for high-volume registered masters."""
|
|
189
|
+
while True:
|
|
190
|
+
name = f"{prefix}-{secrets.token_hex(8).upper()}"
|
|
191
|
+
if not db.exists(doctype, name):
|
|
192
|
+
return name
|
|
193
|
+
|
|
194
|
+
|
|
181
195
|
def _normalize_master_data(data: dict) -> dict:
|
|
182
196
|
normalized = _dict(data)
|
|
183
197
|
for key, value in list(normalized.items()):
|
|
@@ -213,7 +227,10 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
213
227
|
if not doc.get("name"):
|
|
214
228
|
prefix = MASTER_NAME_PREFIXES.get(master_type)
|
|
215
229
|
if prefix:
|
|
216
|
-
|
|
230
|
+
if master_type in MASTER_RANDOM_NAME_TYPES:
|
|
231
|
+
doc["name"] = _generate_random_master_name(db, doctype, prefix)
|
|
232
|
+
else:
|
|
233
|
+
doc["name"] = _generate_master_name(db, doctype, prefix)
|
|
217
234
|
elif master_type == "company" and doc.get("company_name"):
|
|
218
235
|
# A company's id is conventionally its name (mirrors /setup/company).
|
|
219
236
|
doc["name"] = doc["company_name"]
|
|
@@ -223,6 +240,13 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
223
240
|
if db.exists(doctype, doc["name"]):
|
|
224
241
|
raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
|
|
225
242
|
|
|
243
|
+
columns = db._get_table_columns(doctype)
|
|
244
|
+
stamp = now()
|
|
245
|
+
if "creation" in columns and not doc.get("creation"):
|
|
246
|
+
doc["creation"] = stamp
|
|
247
|
+
if "modified" in columns and not doc.get("modified"):
|
|
248
|
+
doc["modified"] = stamp
|
|
249
|
+
|
|
226
250
|
# Party masters inherit the company's base currency when none is specified.
|
|
227
251
|
# Otherwise the Customer/Supplier default_currency column default ('USD')
|
|
228
252
|
# wins, so a non-USD company (e.g. CHF) gets USD customers that then force
|
|
@@ -261,6 +285,8 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
261
285
|
)
|
|
262
286
|
|
|
263
287
|
update_fields = {k: v for k, v in normalized.items() if k != "name"}
|
|
288
|
+
if "modified" in db._get_table_columns(doctype) and "modified" not in update_fields:
|
|
289
|
+
update_fields["modified"] = now()
|
|
264
290
|
if update_fields:
|
|
265
291
|
db.set_value(doctype, name, update_fields)
|
|
266
292
|
|
|
@@ -273,7 +299,7 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
273
299
|
# Query params list_masters consumes itself — anything else is treated as an
|
|
274
300
|
# ad-hoc equality field filter (validated against the master's real columns).
|
|
275
301
|
_MASTER_LIST_RESERVED = {
|
|
276
|
-
"limit", "offset", "include_disabled", "search", "search_fields",
|
|
302
|
+
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
277
303
|
}
|
|
278
304
|
|
|
279
305
|
|
|
@@ -308,6 +334,7 @@ def list_masters(
|
|
|
308
334
|
include_disabled: bool = False,
|
|
309
335
|
search: str | None = None,
|
|
310
336
|
search_fields: str | None = None,
|
|
337
|
+
fields: str | None = None,
|
|
311
338
|
_user: dict = _viewer,
|
|
312
339
|
):
|
|
313
340
|
doctype, _ = _get_table(master_type)
|
|
@@ -354,7 +381,17 @@ def list_masters(
|
|
|
354
381
|
|
|
355
382
|
# Deterministic order by the `name` PK (already indexed) — required for
|
|
356
383
|
# OFFSET pagination and the /adjacent prev/next contract.
|
|
357
|
-
|
|
384
|
+
requested_fields = [f.strip() for f in (fields or "").split(",") if f.strip()]
|
|
385
|
+
if requested_fields:
|
|
386
|
+
unknown = [f for f in requested_fields if f not in columns]
|
|
387
|
+
if unknown:
|
|
388
|
+
raise HTTPException(status_code=400, detail=f"Unknown list fields: {', '.join(unknown)}")
|
|
389
|
+
if "name" not in requested_fields:
|
|
390
|
+
requested_fields.insert(0, "name")
|
|
391
|
+
projection = ", ".join(f'"{f}"' for f in requested_fields)
|
|
392
|
+
else:
|
|
393
|
+
projection = "*"
|
|
394
|
+
query = f'SELECT {projection} FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
|
|
358
395
|
if offset:
|
|
359
396
|
query += f" OFFSET {int(offset)}"
|
|
360
397
|
rows = db.sql(query, params)
|
|
@@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse, Response
|
|
|
22
22
|
|
|
23
23
|
from lambda_erp import get_app_version
|
|
24
24
|
from api.auth import get_current_user
|
|
25
|
+
from api import services
|
|
25
26
|
from api import chat as chat_mod
|
|
26
27
|
from api.chat import TOOL_HANDLERS, build_tools
|
|
27
28
|
|
|
@@ -58,6 +59,8 @@ def _can_write(role) -> bool:
|
|
|
58
59
|
def _allowed(name: str, role) -> bool:
|
|
59
60
|
if name in _EXCLUDE:
|
|
60
61
|
return False
|
|
62
|
+
if name in services.REGISTERED_ACTIONS:
|
|
63
|
+
return services.registered_action_allowed(name, role)
|
|
61
64
|
if name in _ADMIN:
|
|
62
65
|
return role == "admin"
|
|
63
66
|
if name in _WRITE:
|
|
@@ -96,6 +99,7 @@ def _call(name: str, args: dict, user: dict):
|
|
|
96
99
|
# delete_master needs the caller's role (admin-only); handled by the chat's
|
|
97
100
|
# role-aware variant.
|
|
98
101
|
handlers["delete_master"] = lambda a: chat_mod._handle_delete_master(a, user)
|
|
102
|
+
handlers.update(services.registered_action_handlers(user))
|
|
99
103
|
handler = handlers.get(name)
|
|
100
104
|
if handler is None:
|
|
101
105
|
raise KeyError(name)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Bridge between FastAPI request data and lambda_erp Document classes."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
from lambda_erp.utils import _dict, now
|
|
@@ -89,6 +90,11 @@ MASTER_NAME_PREFIXES = {
|
|
|
89
90
|
"warehouse": "WH",
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
# High-volume plugin masters can opt out of sequential ids. Sequential naming
|
|
94
|
+
# must inspect existing suffixes after a process restart; opaque random ids keep
|
|
95
|
+
# inserts constant-time for index-scale reference tables.
|
|
96
|
+
MASTER_RANDOM_NAME_TYPES: set[str] = set()
|
|
97
|
+
|
|
92
98
|
# An Item's code is stored in the primary-key column `name`, but every
|
|
93
99
|
# transaction line and report references it as `item_code`. Accept that
|
|
94
100
|
# intuitive alias on the master API so callers (LLM tools, REST clients) can
|
|
@@ -98,6 +104,17 @@ MASTER_IDENTITY_ALIAS = {
|
|
|
98
104
|
"item": "item_code",
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
# Optional metadata for plugin masters. Backend consumers use the descriptions
|
|
108
|
+
# to teach chat/MCP about deployment-specific records; the frontend has a
|
|
109
|
+
# parallel build-time registry because Python plugin state is not available to
|
|
110
|
+
# the browser bundle.
|
|
111
|
+
MASTER_METADATA: dict[str, dict] = {}
|
|
112
|
+
|
|
113
|
+
# Reference checks registered alongside plugin masters. Each entry has the same
|
|
114
|
+
# shape as the core checks in api.routers.masters: (query, human label). Keeping
|
|
115
|
+
# this registry here avoids making plugins mutate router-private globals.
|
|
116
|
+
MASTER_REFERENCE_CHECKS: dict[str, list[tuple[str, str]]] = {}
|
|
117
|
+
|
|
101
118
|
# Slug <-> doctype name mapping
|
|
102
119
|
SLUG_TO_DOCTYPE = {}
|
|
103
120
|
DOCTYPE_TO_SLUG = {}
|
|
@@ -201,7 +218,11 @@ def document_columns(doctype_slug: str) -> set:
|
|
|
201
218
|
|
|
202
219
|
def register_master(slug: str, table: str, name_field: str, *,
|
|
203
220
|
name_prefix: str | None = None,
|
|
204
|
-
|
|
221
|
+
random_name: bool = False,
|
|
222
|
+
identity_alias: str | None = None,
|
|
223
|
+
description: str | None = None,
|
|
224
|
+
fields: list[str] | None = None,
|
|
225
|
+
reference_checks: list[tuple[str, str]] | None = None) -> None:
|
|
205
226
|
"""Register (or override) a master type — the master-side counterpart of
|
|
206
227
|
`register_doctype`.
|
|
207
228
|
|
|
@@ -221,8 +242,101 @@ def register_master(slug: str, table: str, name_field: str, *,
|
|
|
221
242
|
MASTER_TABLES[slug] = (table, name_field)
|
|
222
243
|
if name_prefix:
|
|
223
244
|
MASTER_NAME_PREFIXES[slug] = name_prefix
|
|
245
|
+
if random_name:
|
|
246
|
+
if not name_prefix:
|
|
247
|
+
raise ValueError("random_name requires name_prefix")
|
|
248
|
+
MASTER_RANDOM_NAME_TYPES.add(slug)
|
|
249
|
+
else:
|
|
250
|
+
MASTER_RANDOM_NAME_TYPES.discard(slug)
|
|
224
251
|
if identity_alias:
|
|
225
252
|
MASTER_IDENTITY_ALIAS[slug] = identity_alias
|
|
253
|
+
if description or fields:
|
|
254
|
+
MASTER_METADATA[slug] = {
|
|
255
|
+
"description": description or "",
|
|
256
|
+
"fields": list(fields or []),
|
|
257
|
+
}
|
|
258
|
+
if reference_checks is not None:
|
|
259
|
+
MASTER_REFERENCE_CHECKS[slug] = list(reference_checks)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- Registered actions ----------------------------------------------------
|
|
263
|
+
#
|
|
264
|
+
# Plugins sometimes need one deliberate transition that is richer than CRUD
|
|
265
|
+
# (for example, promoting reference data into a workflow document). Register it
|
|
266
|
+
# once here and it becomes available through REST, AI chat, and MCP. The
|
|
267
|
+
# frontend's master registry can point a button at the same action name.
|
|
268
|
+
|
|
269
|
+
REGISTERED_ACTIONS: dict[str, dict] = {}
|
|
270
|
+
_ACTION_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
271
|
+
_ROLE_LEVEL = {"viewer": 1, "manager": 2, "public_manager": 2, "admin": 3}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def register_action(name: str, handler, *, description: str,
|
|
275
|
+
parameters: dict | None = None,
|
|
276
|
+
minimum_role: str = "manager",
|
|
277
|
+
allow_public_manager: bool = False) -> None:
|
|
278
|
+
"""Register a deployment action across REST, chat, and MCP.
|
|
279
|
+
|
|
280
|
+
``handler`` receives one JSON-compatible argument dict and returns a
|
|
281
|
+
JSON-compatible result. ``parameters`` is a JSON Schema object for that
|
|
282
|
+
dict. Mutating actions should keep the default manager requirement; demo
|
|
283
|
+
``public_manager`` users are excluded unless explicitly opted in.
|
|
284
|
+
"""
|
|
285
|
+
if not _ACTION_NAME_RE.fullmatch(name):
|
|
286
|
+
raise ValueError(f"Invalid action name: {name!r}")
|
|
287
|
+
if minimum_role not in _ROLE_LEVEL:
|
|
288
|
+
raise ValueError(f"Invalid minimum action role: {minimum_role!r}")
|
|
289
|
+
REGISTERED_ACTIONS[name] = {
|
|
290
|
+
"handler": handler,
|
|
291
|
+
"description": description,
|
|
292
|
+
"parameters": parameters or {"type": "object", "properties": {}},
|
|
293
|
+
"minimum_role": minimum_role,
|
|
294
|
+
"allow_public_manager": allow_public_manager,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def registered_action_allowed(name: str, role: str | None) -> bool:
|
|
299
|
+
meta = REGISTERED_ACTIONS.get(name)
|
|
300
|
+
if not meta:
|
|
301
|
+
return False
|
|
302
|
+
if role == "public_manager" and not meta["allow_public_manager"]:
|
|
303
|
+
return False
|
|
304
|
+
return _ROLE_LEVEL.get(role or "", 0) >= _ROLE_LEVEL[meta["minimum_role"]]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def registered_action_tools() -> list[dict]:
|
|
308
|
+
"""OpenAI-style function tool schemas for all live registered actions."""
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
"type": "function",
|
|
312
|
+
"function": {
|
|
313
|
+
"name": name,
|
|
314
|
+
"description": meta["description"],
|
|
315
|
+
"parameters": meta["parameters"],
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
for name, meta in REGISTERED_ACTIONS.items()
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def run_registered_action(name: str, args: dict | None, user_info: dict | None = None):
|
|
323
|
+
meta = REGISTERED_ACTIONS.get(name)
|
|
324
|
+
if not meta:
|
|
325
|
+
raise KeyError(name)
|
|
326
|
+
role = (user_info or {}).get("role")
|
|
327
|
+
if not registered_action_allowed(name, role):
|
|
328
|
+
raise PermissionError(
|
|
329
|
+
f"Action '{name}' requires {meta['minimum_role']} access."
|
|
330
|
+
)
|
|
331
|
+
return meta["handler"](args or {})
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def registered_action_handlers(user_info: dict | None = None) -> dict:
|
|
335
|
+
"""Handlers bound to one caller, used by both chat and MCP dispatch."""
|
|
336
|
+
return {
|
|
337
|
+
name: (lambda args, action=name: run_registered_action(action, args, user_info))
|
|
338
|
+
for name in REGISTERED_ACTIONS
|
|
339
|
+
}
|
|
226
340
|
|
|
227
341
|
|
|
228
342
|
# Chat guidance for registered doctypes: slug -> {"description", "fields"}. The
|
|
@@ -284,6 +284,12 @@ export const api = {
|
|
|
284
284
|
body: JSON.stringify(data),
|
|
285
285
|
}),
|
|
286
286
|
|
|
287
|
+
runAction: (action: string, data: Record<string, any>) =>
|
|
288
|
+
request<any>(`/actions/${encodeURIComponent(action)}`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
body: JSON.stringify(data),
|
|
291
|
+
}),
|
|
292
|
+
|
|
287
293
|
deleteMaster: (type: string, name: string) =>
|
|
288
294
|
request<any>(`/masters/${type}/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
|
289
295
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|