lambda-erp 0.8.12__tar.gz → 0.8.13__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 (89) hide show
  1. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/chat.py +28 -9
  3. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/main.py +2 -1
  4. lambda_erp-0.8.13/api/routers/actions.py +23 -0
  5. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/masters.py +29 -4
  6. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/mcp.py +4 -0
  7. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/services.py +103 -1
  8. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/src/api/client.ts +6 -0
  9. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/pyproject.toml +1 -1
  10. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/.gitignore +0 -0
  11. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/LICENSE +0 -0
  12. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/README.md +0 -0
  13. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/__init__.py +0 -0
  14. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/attachments.py +0 -0
  15. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/auth.py +0 -0
  16. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/bootstrap.py +0 -0
  17. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/demo_limits.py +0 -0
  18. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/deps.py +0 -0
  19. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/errors.py +0 -0
  20. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/oauth.py +0 -0
  21. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/pdf.py +0 -0
  22. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/providers.py +0 -0
  23. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/remarks_md.py +0 -0
  24. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/__init__.py +0 -0
  25. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/accounting.py +0 -0
  26. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/admin.py +0 -0
  27. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/analytics.py +0 -0
  28. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/availability.py +0 -0
  29. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/bank_reconciliation.py +0 -0
  30. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/chat_api.py +0 -0
  31. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/documents.py +0 -0
  32. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/proposals.py +0 -0
  33. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/reports.py +0 -0
  34. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/setup.py +0 -0
  35. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/templates/document.html +0 -0
  36. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/templates/proposal.html +0 -0
  37. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/docs/agents/README.md +0 -0
  38. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/LICENSE +0 -0
  39. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/README.md +0 -0
  40. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/__init__.py +0 -0
  41. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/__init__.py +0 -0
  42. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/bank_transaction.py +0 -0
  43. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/budget.py +0 -0
  44. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  45. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/general_ledger.py +0 -0
  46. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/journal_entry.py +0 -0
  47. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/payment_entry.py +0 -0
  48. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/pos_invoice.py +0 -0
  49. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/purchase_invoice.py +0 -0
  50. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/revaluation.py +0 -0
  51. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/sales_invoice.py +0 -0
  52. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/__init__.py +0 -0
  53. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/engine.py +0 -0
  54. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/pack.py +0 -0
  55. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  56. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  57. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  58. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  59. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  60. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  61. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/profiles.py +0 -0
  62. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/spine.py +0 -0
  63. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/subscription.py +0 -0
  64. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/__init__.py +0 -0
  65. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/asset.py +0 -0
  66. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/reservation.py +0 -0
  67. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/buying/__init__.py +0 -0
  68. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/buying/purchase_order.py +0 -0
  69. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/__init__.py +0 -0
  70. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/currency.py +0 -0
  71. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/defaults.py +0 -0
  72. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/pricing_rule.py +0 -0
  73. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  74. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/database.py +0 -0
  75. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/exceptions.py +0 -0
  76. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/hooks.py +0 -0
  77. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/model.py +0 -0
  78. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/__init__.py +0 -0
  79. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/proposal.py +0 -0
  80. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/quotation.py +0 -0
  81. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/sales_order.py +0 -0
  82. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/simulation.py +0 -0
  83. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/__init__.py +0 -0
  84. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/delivery_note.py +0 -0
  85. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/purchase_receipt.py +0 -0
  86. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/stock_entry.py +0 -0
  87. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/stock_ledger.py +0 -0
  88. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/utils.py +0 -0
  89. {lambda_erp-0.8.12 → lambda_erp-0.8.13}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.12
3
+ Version: 0.8.13
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -1260,8 +1260,8 @@ def _chat_doctype_section() -> str:
1260
1260
  )
1261
1261
 
1262
1262
 
1263
- def build_tools():
1264
- """TOOLS with the doctype/master enums widened from the live registries.
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
- if not extra_docs and not extra_masters:
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 = "".join(
2057
- f"\n- **{services.MASTER_TABLES[m][0]}** (master_type `{m}`): key = `name`, "
2058
- f"display = `{services.MASTER_TABLES[m][1]}`"
2059
- for m in extra_masters
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))
@@ -4,12 +4,13 @@ import re
4
4
 
5
5
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
6
6
  from lambda_erp.database import get_db
7
- from lambda_erp.utils import _dict
7
+ from lambda_erp.utils import _dict, now
8
8
  # The per-type registries live in api.services so `register_master` can extend
9
9
  # them; imported here (not moved) so existing `from api.routers.masters import
10
10
  # MASTER_IDENTITY_ALIAS`-style imports keep working.
11
11
  from api.services import (
12
12
  MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS,
13
+ MASTER_REFERENCE_CHECKS,
13
14
  _search_clause, _where_from_filters, master_search_columns, count_query_cached,
14
15
  )
15
16
  from api.auth import require_role, require_non_public_manager
@@ -145,7 +146,11 @@ def _with_active_filter(db, doctype: str, filters: dict | None = None) -> dict |
145
146
 
146
147
  def _find_reference(master_type: str, name: str) -> str | None:
147
148
  db = get_db()
148
- for query, label in DELETE_REFERENCE_CHECKS.get(master_type, []):
149
+ checks = [
150
+ *DELETE_REFERENCE_CHECKS.get(master_type, []),
151
+ *MASTER_REFERENCE_CHECKS.get(master_type, []),
152
+ ]
153
+ for query, label in checks:
149
154
  params = [name] if query.count("?") == 1 else [name] * query.count("?")
150
155
  try:
151
156
  if db.sql(query, params):
@@ -223,6 +228,13 @@ def create_master_record(master_type: str, data: dict) -> dict:
223
228
  if db.exists(doctype, doc["name"]):
224
229
  raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
225
230
 
231
+ columns = db._get_table_columns(doctype)
232
+ stamp = now()
233
+ if "creation" in columns and not doc.get("creation"):
234
+ doc["creation"] = stamp
235
+ if "modified" in columns and not doc.get("modified"):
236
+ doc["modified"] = stamp
237
+
226
238
  # Party masters inherit the company's base currency when none is specified.
227
239
  # Otherwise the Customer/Supplier default_currency column default ('USD')
228
240
  # wins, so a non-USD company (e.g. CHF) gets USD customers that then force
@@ -261,6 +273,8 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
261
273
  )
262
274
 
263
275
  update_fields = {k: v for k, v in normalized.items() if k != "name"}
276
+ if "modified" in db._get_table_columns(doctype) and "modified" not in update_fields:
277
+ update_fields["modified"] = now()
264
278
  if update_fields:
265
279
  db.set_value(doctype, name, update_fields)
266
280
 
@@ -273,7 +287,7 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
273
287
  # Query params list_masters consumes itself — anything else is treated as an
274
288
  # ad-hoc equality field filter (validated against the master's real columns).
275
289
  _MASTER_LIST_RESERVED = {
276
- "limit", "offset", "include_disabled", "search", "search_fields",
290
+ "limit", "offset", "include_disabled", "search", "search_fields", "fields",
277
291
  }
278
292
 
279
293
 
@@ -308,6 +322,7 @@ def list_masters(
308
322
  include_disabled: bool = False,
309
323
  search: str | None = None,
310
324
  search_fields: str | None = None,
325
+ fields: str | None = None,
311
326
  _user: dict = _viewer,
312
327
  ):
313
328
  doctype, _ = _get_table(master_type)
@@ -354,7 +369,17 @@ def list_masters(
354
369
 
355
370
  # Deterministic order by the `name` PK (already indexed) — required for
356
371
  # OFFSET pagination and the /adjacent prev/next contract.
357
- query = f'SELECT * FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
372
+ requested_fields = [f.strip() for f in (fields or "").split(",") if f.strip()]
373
+ if requested_fields:
374
+ unknown = [f for f in requested_fields if f not in columns]
375
+ if unknown:
376
+ raise HTTPException(status_code=400, detail=f"Unknown list fields: {', '.join(unknown)}")
377
+ if "name" not in requested_fields:
378
+ requested_fields.insert(0, "name")
379
+ projection = ", ".join(f'"{f}"' for f in requested_fields)
380
+ else:
381
+ projection = "*"
382
+ query = f'SELECT {projection} FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
358
383
  if offset:
359
384
  query += f" OFFSET {int(offset)}"
360
385
  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
@@ -98,6 +99,17 @@ MASTER_IDENTITY_ALIAS = {
98
99
  "item": "item_code",
99
100
  }
100
101
 
102
+ # Optional metadata for plugin masters. Backend consumers use the descriptions
103
+ # to teach chat/MCP about deployment-specific records; the frontend has a
104
+ # parallel build-time registry because Python plugin state is not available to
105
+ # the browser bundle.
106
+ MASTER_METADATA: dict[str, dict] = {}
107
+
108
+ # Reference checks registered alongside plugin masters. Each entry has the same
109
+ # shape as the core checks in api.routers.masters: (query, human label). Keeping
110
+ # this registry here avoids making plugins mutate router-private globals.
111
+ MASTER_REFERENCE_CHECKS: dict[str, list[tuple[str, str]]] = {}
112
+
101
113
  # Slug <-> doctype name mapping
102
114
  SLUG_TO_DOCTYPE = {}
103
115
  DOCTYPE_TO_SLUG = {}
@@ -201,7 +213,10 @@ def document_columns(doctype_slug: str) -> set:
201
213
 
202
214
  def register_master(slug: str, table: str, name_field: str, *,
203
215
  name_prefix: str | None = None,
204
- identity_alias: str | None = None) -> None:
216
+ identity_alias: str | None = None,
217
+ description: str | None = None,
218
+ fields: list[str] | None = None,
219
+ reference_checks: list[tuple[str, str]] | None = None) -> None:
205
220
  """Register (or override) a master type — the master-side counterpart of
206
221
  `register_doctype`.
207
222
 
@@ -223,6 +238,93 @@ def register_master(slug: str, table: str, name_field: str, *,
223
238
  MASTER_NAME_PREFIXES[slug] = name_prefix
224
239
  if identity_alias:
225
240
  MASTER_IDENTITY_ALIAS[slug] = identity_alias
241
+ if description or fields:
242
+ MASTER_METADATA[slug] = {
243
+ "description": description or "",
244
+ "fields": list(fields or []),
245
+ }
246
+ if reference_checks is not None:
247
+ MASTER_REFERENCE_CHECKS[slug] = list(reference_checks)
248
+
249
+
250
+ # --- Registered actions ----------------------------------------------------
251
+ #
252
+ # Plugins sometimes need one deliberate transition that is richer than CRUD
253
+ # (for example, promoting reference data into a workflow document). Register it
254
+ # once here and it becomes available through REST, AI chat, and MCP. The
255
+ # frontend's master registry can point a button at the same action name.
256
+
257
+ REGISTERED_ACTIONS: dict[str, dict] = {}
258
+ _ACTION_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
259
+ _ROLE_LEVEL = {"viewer": 1, "manager": 2, "public_manager": 2, "admin": 3}
260
+
261
+
262
+ def register_action(name: str, handler, *, description: str,
263
+ parameters: dict | None = None,
264
+ minimum_role: str = "manager",
265
+ allow_public_manager: bool = False) -> None:
266
+ """Register a deployment action across REST, chat, and MCP.
267
+
268
+ ``handler`` receives one JSON-compatible argument dict and returns a
269
+ JSON-compatible result. ``parameters`` is a JSON Schema object for that
270
+ dict. Mutating actions should keep the default manager requirement; demo
271
+ ``public_manager`` users are excluded unless explicitly opted in.
272
+ """
273
+ if not _ACTION_NAME_RE.fullmatch(name):
274
+ raise ValueError(f"Invalid action name: {name!r}")
275
+ if minimum_role not in _ROLE_LEVEL:
276
+ raise ValueError(f"Invalid minimum action role: {minimum_role!r}")
277
+ REGISTERED_ACTIONS[name] = {
278
+ "handler": handler,
279
+ "description": description,
280
+ "parameters": parameters or {"type": "object", "properties": {}},
281
+ "minimum_role": minimum_role,
282
+ "allow_public_manager": allow_public_manager,
283
+ }
284
+
285
+
286
+ def registered_action_allowed(name: str, role: str | None) -> bool:
287
+ meta = REGISTERED_ACTIONS.get(name)
288
+ if not meta:
289
+ return False
290
+ if role == "public_manager" and not meta["allow_public_manager"]:
291
+ return False
292
+ return _ROLE_LEVEL.get(role or "", 0) >= _ROLE_LEVEL[meta["minimum_role"]]
293
+
294
+
295
+ def registered_action_tools() -> list[dict]:
296
+ """OpenAI-style function tool schemas for all live registered actions."""
297
+ return [
298
+ {
299
+ "type": "function",
300
+ "function": {
301
+ "name": name,
302
+ "description": meta["description"],
303
+ "parameters": meta["parameters"],
304
+ },
305
+ }
306
+ for name, meta in REGISTERED_ACTIONS.items()
307
+ ]
308
+
309
+
310
+ def run_registered_action(name: str, args: dict | None, user_info: dict | None = None):
311
+ meta = REGISTERED_ACTIONS.get(name)
312
+ if not meta:
313
+ raise KeyError(name)
314
+ role = (user_info or {}).get("role")
315
+ if not registered_action_allowed(name, role):
316
+ raise PermissionError(
317
+ f"Action '{name}' requires {meta['minimum_role']} access."
318
+ )
319
+ return meta["handler"](args or {})
320
+
321
+
322
+ def registered_action_handlers(user_info: dict | None = None) -> dict:
323
+ """Handlers bound to one caller, used by both chat and MCP dispatch."""
324
+ return {
325
+ name: (lambda args, action=name: run_registered_action(action, args, user_info))
326
+ for name in REGISTERED_ACTIONS
327
+ }
226
328
 
227
329
 
228
330
  # 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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.12"
3
+ version = "0.8.13"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes