lambda-erp 1.1.1__tar.gz → 1.1.3__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 (113) hide show
  1. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/PKG-INFO +1 -1
  2. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/auth.py +15 -5
  3. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/chat.py +26 -59
  4. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/main.py +3 -0
  5. lambda_erp-1.1.3/api/mcp_oauth.py +423 -0
  6. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/oauth.py +5 -1
  7. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/analytics.py +81 -11
  8. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/masters.py +25 -28
  9. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/mcp.py +58 -9
  10. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/services.py +31 -5
  11. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/src/api/client.ts +9 -5
  12. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/database.py +30 -0
  13. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/pyproject.toml +1 -1
  14. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/.gitignore +0 -0
  15. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/LICENSE +0 -0
  16. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/README.md +0 -0
  17. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/__init__.py +0 -0
  18. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/attachments.py +0 -0
  19. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/bootstrap.py +0 -0
  20. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/chat_links.py +0 -0
  21. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/demo_limits.py +0 -0
  22. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/deps.py +0 -0
  23. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/errors.py +0 -0
  24. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/list_values.py +0 -0
  25. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf.py +0 -0
  26. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_contract.py +0 -0
  27. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_exports.py +0 -0
  28. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_profiles.py +0 -0
  29. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/providers.py +0 -0
  30. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/remarks_md.py +0 -0
  31. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/__init__.py +0 -0
  32. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/accounting.py +0 -0
  33. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/actions.py +0 -0
  34. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/admin.py +0 -0
  35. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/availability.py +0 -0
  36. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/bank_reconciliation.py +0 -0
  37. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/bank_statements.py +0 -0
  38. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/chat_api.py +0 -0
  39. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/documents.py +0 -0
  40. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/proposals.py +0 -0
  41. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/reports.py +0 -0
  42. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/setup.py +0 -0
  43. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/_proposal_totals.html +0 -0
  44. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/_record_base.html +0 -0
  45. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/document.html +0 -0
  46. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/proposal.html +0 -0
  47. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/record.html +0 -0
  48. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/time_filters.py +0 -0
  49. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/tool_permissions.py +0 -0
  50. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/docs/agents/README.md +0 -0
  51. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/LICENSE +0 -0
  52. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/README.md +0 -0
  53. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/__init__.py +0 -0
  54. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/__init__.py +0 -0
  55. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_account.py +0 -0
  56. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  57. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
  58. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_transaction.py +0 -0
  59. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/budget.py +0 -0
  60. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/camt.py +0 -0
  61. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  62. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/general_ledger.py +0 -0
  63. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/journal_entry.py +0 -0
  64. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/payment_entry.py +0 -0
  65. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/pos_invoice.py +0 -0
  66. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
  67. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/revaluation.py +0 -0
  68. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/sales_invoice.py +0 -0
  69. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/settlement.py +0 -0
  70. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/__init__.py +0 -0
  71. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/engine.py +0 -0
  72. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/pack.py +0 -0
  73. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  74. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  75. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  76. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  77. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  78. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  79. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/profiles.py +0 -0
  80. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/spine.py +0 -0
  81. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/subscription.py +0 -0
  82. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/__init__.py +0 -0
  83. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/asset.py +0 -0
  84. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/lifecycle.py +0 -0
  85. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/reservation.py +0 -0
  86. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/buying/__init__.py +0 -0
  87. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/buying/purchase_order.py +0 -0
  88. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/__init__.py +0 -0
  89. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/currency.py +0 -0
  90. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/defaults.py +0 -0
  91. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/item_price.py +0 -0
  92. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/item_prices.py +0 -0
  93. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/pricing_rule.py +0 -0
  94. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  95. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/exceptions.py +0 -0
  96. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/hooks.py +0 -0
  97. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/master_integrity.py +0 -0
  98. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/model.py +0 -0
  99. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/__init__.py +0 -0
  100. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/proposal.py +0 -0
  101. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/quotation.py +0 -0
  102. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/sales_order.py +0 -0
  103. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/simulation.py +0 -0
  104. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/__init__.py +0 -0
  105. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/delivery_note.py +0 -0
  106. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/purchase_receipt.py +0 -0
  107. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/stock_entry.py +0 -0
  108. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/stock_ledger.py +0 -0
  109. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/timestamps.py +0 -0
  110. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/utils.py +0 -0
  111. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/validation.py +0 -0
  112. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/workflow.py +0 -0
  113. {lambda_erp-1.1.1 → lambda_erp-1.1.3}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ import uuid
9
9
  import secrets
10
10
  import hashlib
11
11
  from datetime import datetime, timedelta, timezone
12
+ from typing import Literal
12
13
 
13
14
  import bcrypt
14
15
  from jose import jwt, JWTError
@@ -259,12 +260,18 @@ def refresh_auth_principal(principal: dict | None) -> dict | None:
259
260
  else owner["role"]
260
261
  )
261
262
  owner["role"] = effective_role
262
- return make_auth_principal(
263
+ scope_role = principal.get("oauth_scope_role")
264
+ if scope_role:
265
+ owner["role"] = min((owner["role"], scope_role), key=_role_rank)
266
+ refreshed = make_auth_principal(
263
267
  owner,
264
268
  API_KEY_CREDENTIAL,
265
269
  name=principal.get("name") or key.get("session_owner") or owner["name"],
266
270
  api_key_id=key["id"],
267
271
  )
272
+ if scope_role:
273
+ refreshed["oauth_scope_role"] = scope_role
274
+ return refreshed
268
275
 
269
276
  user_id = principal.get("user_id") or principal.get("name")
270
277
  user = db.get_value(
@@ -981,6 +988,7 @@ class ApiKeyCreate(BaseModel):
981
988
  # Optional role CAP. Defaults to the creator's own role; may only be equal
982
989
  # or lower — a key can never out-rank its owner.
983
990
  role: str | None = None
991
+ app_type: Literal['claude_code', 'codex', 'other'] = 'other'
984
992
 
985
993
 
986
994
  def _serialize_api_key(row: dict) -> dict:
@@ -997,6 +1005,7 @@ def _serialize_api_key(row: dict) -> dict:
997
1005
  "created_at": row.get("created_at"),
998
1006
  "last_used_at": row.get("last_used_at"),
999
1007
  "revoked": bool(row.get("revoked")),
1008
+ "app_type": row.get("app_type") or "other",
1000
1009
  }
1001
1010
 
1002
1011
 
@@ -1024,7 +1033,7 @@ def list_api_keys(user: dict = Depends(require_interactive_user)):
1024
1033
  db = get_db()
1025
1034
  base = (
1026
1035
  'SELECT k.id, k.name, k.owner, k.role, k.key_prefix, k.created_at, '
1027
- 'k.last_used_at, k.revoked, u.full_name AS owner_full_name, u.email AS owner_email '
1036
+ 'k.last_used_at, k.revoked, k.app_type, u.full_name AS owner_full_name, u.email AS owner_email '
1028
1037
  'FROM "Api Key" k LEFT JOIN "User" u ON u.name = k.owner '
1029
1038
  )
1030
1039
  if user["role"] == "admin":
@@ -1068,15 +1077,16 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_
1068
1077
  created_at = now()
1069
1078
  db.sql(
1070
1079
  'INSERT INTO "Api Key" '
1071
- '(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
1072
- 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
1073
- [key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None],
1080
+ '(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked, app_type) '
1081
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)',
1082
+ [key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None, data.app_type],
1074
1083
  )
1075
1084
  db.conn.commit()
1076
1085
  row = {
1077
1086
  "id": key_id, "name": name, "owner": user["name"], "role": role, "key_prefix": key_prefix,
1078
1087
  "created_at": created_at, "last_used_at": None, "revoked": 0,
1079
1088
  "owner_full_name": user.get("full_name"), "owner_email": user.get("email"),
1089
+ "app_type": data.app_type,
1080
1090
  }
1081
1091
  result = _serialize_api_key(row)
1082
1092
  result["is_mine"] = True
@@ -38,7 +38,8 @@ from api.demo_limits import (
38
38
  limiter as demo_limiter,
39
39
  )
40
40
  from api.providers import cost_of_openai_call, cost_of_transcription
41
- from api.routers.masters import create_master_record, update_master_record, delete_master_record, MASTER_IDENTITY_ALIAS
41
+ from api.routers.masters import create_master_record, update_master_record, delete_master_record
42
+ from api.routers.analytics import DATASET_FILTER_DESCRIPTION
42
43
  from lambda_erp.database import get_db
43
44
  from lambda_erp.utils import flt, now, nowdate
44
45
 
@@ -683,17 +684,17 @@ TOOLS = [
683
684
  },
684
685
  "filters": {
685
686
  "type": "object",
686
- "description": "Optional deterministic filters on any real column, ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
687
+ "description": "Optional deterministic filters on any real column or registered alias (item_code = name), ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
687
688
  "default": {},
688
689
  },
689
- "order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to the time_filter field when supplied, otherwise name."},
690
+ "order_by": {"type": "string", "description": "Optional real column or registered alias (item_code = name) to sort by. Defaults to the time_filter field when supplied, otherwise name."},
690
691
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
691
692
  "limit": {"type": "integer", "description": "Maximum results. With time_filter/include_meta: default 50, range 1–500. Legacy simple lookups have no cap when omitted."},
692
693
  "offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
693
694
  "result_fields": {
694
695
  "type": "array",
695
696
  "items": {"type": "string"},
696
- "description": "Optional result projection. Returns only these real columns plus name. This is separate from `fields`, which selects columns searched by `query`.",
697
+ "description": "Optional result projection. Returns requested columns plus name. Registered aliases are accepted: item_code resolves to name and is also returned as item_code when requested. This is separate from `fields`, which selects columns searched by `query`.",
697
698
  },
698
699
  "include_disabled": {"type": "boolean", "description": "Default false (active records only). Set true to ALSO return disabled/archived records — needed to find a record you must inspect, re-enable, or reference, or to answer 'what's disabled'. Retry with this if a lookup for a record you know exists comes back empty."},
699
700
  },
@@ -705,7 +706,7 @@ TOOLS = [
705
706
  "type": "function",
706
707
  "function": {
707
708
  "name": "get_master_fields",
708
- "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` or `filters` to search_masters, and before building a create/update payload. Returns all fields, text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
709
+ "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields`, `filters`, `result_fields` or `order_by` to search_masters, and before building a create/update payload. Returns real fields, field_aliases (e.g. item_code maps to name), text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
709
710
  "parameters": {
710
711
  "type": "object",
711
712
  "properties": {
@@ -720,11 +721,11 @@ TOOLS = [
720
721
  "function": {
721
722
  "name": "create_master",
722
723
  "description": (
723
- "Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are silently dropped.\n\n"
724
- "Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company/Account/Cost Center have NO auto-id, so `name` is required for them.\n\n"
725
- "**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
724
+ "Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are rejected. Call get_master_fields for the live schema, including extension fields.\n\n"
725
+ "Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company defaults it to company_name. Account/Cost Center require an explicit `name`.\n\n"
726
+ "**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, website, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
726
727
  "**Supplier fields:** name (optional custom id; auto SUPP-NNN), supplier_name (required), supplier_group, default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
727
- "**Item fields:** item_code (the unique item code/id, e.g. \"SVC-SPARK\" — optional, may use ANY prefix, not just ITEM; auto-generated as ITEM-NNN if omitted), item_name (required, the human-readable name), item_group, stock_uom (prefer one of the units listed under 'Item Units' in the system context — exact spelling; a new unit is allowed when none fits), standard_rate, is_stock_item, default_warehouse, description.\n"
728
+ "**Item fields:** name (the unique item code/id, e.g. \"SVC-SPARK\" — optional, may use ANY prefix, not just ITEM; auto-generated as ITEM-NNN if omitted), item_name (required, the human-readable name), item_group, stock_uom (prefer one of the units listed under 'Item Units' in the system context — exact spelling; a new unit is allowed when none fits), standard_rate, is_stock_item, default_warehouse, description.\n"
728
729
  "**Warehouse fields:** name (optional custom id; auto WH-NNN), warehouse_name (required), company, parent_warehouse (omit or null when not needed), address, city, zip_code, country.\n"
729
730
  "**Company fields:** name (the company id — optional, defaults to company_name), company_name (required), default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
730
731
  "**Account fields:** name (REQUIRED — full account id, conventionally \"<account_name> - <company abbr>\", e.g. \"Marketing Expenses - LAMB\"), account_name (required), company (required), root_type (Asset/Liability/Equity/Income/Expense), report_type (\"Balance Sheet\" or \"Profit and Loss\"), account_type (e.g. Receivable, Payable, Bank, Cash, Stock, Tax), parent_account, account_currency, is_group (0/1).\n"
@@ -732,7 +733,7 @@ TOOLS = [
732
733
  "zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
733
734
  "When the user names a contact person at a customer (e.g. \"Kontakt/Ansprechpartner ist Marlene Voss, 079 123 45 67\"), put the person's name in contact_person and their phone/email in contact_phone/contact_email — NOT in the company-level phone/email. There is no separate contact tool; contact-person data lives on these Customer columns, so never claim you cannot store a contact person.\n"
734
735
  "Supplier example: {\"master_type\":\"supplier\",\"data\":{\"supplier_name\":\"Schlafteq\",\"email\":\"jacob@schlafteq.ch\",\"phone\":\"+1 555-0104\",\"address\":\"145 Harbor Rd\",\"city\":\"Seattle\",\"zip_code\":\"98101\",\"country\":\"US\",\"tax_id\":\"98-7654321\"}}\n"
735
- "Item example (custom code): {\"master_type\":\"item\",\"data\":{\"item_code\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}\n"
736
+ "Item example (custom code): {\"master_type\":\"item\",\"data\":{\"name\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}\n"
736
737
  "Customer example (with contact person): {\"master_type\":\"customer\",\"data\":{\"customer_name\":\"Foglio AG\",\"address\":\"Seeweg 12\",\"city\":\"Bramblewick\",\"zip_code\":\"9999\",\"contact_person\":\"Marlene Voss\",\"contact_phone\":\"079 123 45 67\"}}"
737
738
  ),
738
739
  "parameters": {
@@ -750,7 +751,7 @@ TOOLS = [
750
751
  "function": {
751
752
  "name": "update_master",
752
753
  "description": (
753
- "Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
754
+ "Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, website, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
754
755
  ),
755
756
  "parameters": {
756
757
  "type": "object",
@@ -1023,8 +1024,9 @@ TOOLS = [
1023
1024
  "`measures` is an object of `{alias: [op, field]}` where `op` "
1024
1025
  "is one of sum, count, avg, min, max (count may omit field). "
1025
1026
  "`filters` is a dict keyed by field name — same shape as for "
1026
- "`create_custom_analytics_report` (equality, list for IN, "
1027
- "`{from, to}` for ranges). `order_by` is a list of "
1027
+ "`create_custom_analytics_report` (equality, [\"in\", [values]] or a flat list for IN, "
1028
+ "[\"not in\", [values]], `{from, to}` for ranges). Invalid filter shapes "
1029
+ "are errors, not evidence of zero stock or activity. `order_by` is a list of "
1028
1030
  "`{field, direction}` where `field` is a group_by field or a "
1029
1031
  "measure alias and `direction` is asc/desc.\n\n"
1030
1032
  "Example — top 5 customers by revenue:\n"
@@ -1070,7 +1072,7 @@ TOOLS = [
1070
1072
  "type": "object",
1071
1073
  "description": "Map of alias -> [op, field]. op ∈ sum|count|avg|min|max.",
1072
1074
  },
1073
- "filters": {"type": "object"},
1075
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1074
1076
  "order_by": {
1075
1077
  "type": "array",
1076
1078
  "items": {
@@ -1147,7 +1149,7 @@ TOOLS = [
1147
1149
  ],
1148
1150
  },
1149
1151
  "fields": {"type": "array", "items": {"type": "string"}},
1150
- "filters": {"type": "object"},
1152
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1151
1153
  "limit": {"type": "integer"},
1152
1154
  },
1153
1155
  "required": ["dataset"],
@@ -1233,7 +1235,7 @@ TOOLS = [
1233
1235
  ],
1234
1236
  },
1235
1237
  "fields": {"type": "array", "items": {"type": "string"}},
1236
- "filters": {"type": "object"},
1238
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1237
1239
  "limit": {"type": "integer"},
1238
1240
  },
1239
1241
  "required": ["dataset"],
@@ -1603,6 +1605,7 @@ def _handle_get_master_fields(args):
1603
1605
  cls = services.DOCUMENT_CLASSES.get(doctype)
1604
1606
  return {
1605
1607
  "master_type": master_type,
1608
+ "field_aliases": services.master_field_aliases(master_type),
1606
1609
  "time_filter_fields": time_filter_fields(db, doctype),
1607
1610
  "fields": sorted(db._get_table_columns(doctype)),
1608
1611
  "text_fields": sorted(db._get_text_columns(doctype)),
@@ -1613,7 +1616,7 @@ def _handle_get_master_fields(args):
1613
1616
  "requirements": master_requirements(master_type),
1614
1617
  "link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
1615
1618
  "dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
1616
- "input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | ({services.MASTER_IDENTITY_ALIAS[master_type]} if master_type in services.MASTER_IDENTITY_ALIAS else set())),
1619
+ "input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | set(services.master_field_aliases(master_type))),
1617
1620
  }
1618
1621
 
1619
1622
 
@@ -1661,18 +1664,13 @@ def _handle_search_masters(args):
1661
1664
  requested = args.get("fields") or []
1662
1665
  if requested:
1663
1666
  valid = columns
1664
- # The identity alias (e.g. "item_code") is what the model calls the code
1665
- # everywhere else — create_master, document lines, the asset/reservation
1666
- # prompt — so it naturally passes fields=["item_code"]. But the real
1667
- # column is the `name` PK; resolve the alias to `name` so that lookup
1668
- # works instead of erroring "None of fields ['item_code'] exist".
1669
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
1667
+ aliases = services.master_field_aliases(master_type)
1670
1668
  search_cols = []
1671
1669
  for c in requested:
1672
1670
  if c in valid:
1673
1671
  search_cols.append(c)
1674
- elif alias and c == alias and "name" in valid:
1675
- search_cols.append("name")
1672
+ elif c in aliases:
1673
+ search_cols.append(aliases[c])
1676
1674
  search_cols = sorted(set(search_cols))
1677
1675
  # Nothing resolved (all names unknown) — degrade to the default text
1678
1676
  # search rather than erroring: the value the caller meant is almost
@@ -1719,22 +1717,6 @@ def _handle_search_masters(args):
1719
1717
  return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
1720
1718
 
1721
1719
 
1722
- def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
1723
- """Return field names in `data` that aren't valid columns on the master's table."""
1724
- entry = services.MASTER_TABLES.get(master_type)
1725
- if not entry:
1726
- return []
1727
- doctype, _ = entry
1728
- from lambda_erp.database import get_db
1729
- valid = set(get_db()._get_table_columns(doctype))
1730
- # item_code is a recognized alias for the Item's `name` PK, not an unknown
1731
- # field — don't warn that it was ignored when it was actually honored.
1732
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
1733
- if alias:
1734
- valid.add(alias)
1735
- return [k for k in data.keys() if k not in valid]
1736
-
1737
-
1738
1720
  @with_record_view_urls("master_type", master=True)
1739
1721
  def _handle_create_master(args):
1740
1722
  master_type = args["master_type"]
@@ -1745,20 +1727,12 @@ def _handle_create_master(args):
1745
1727
  if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1746
1728
  data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1747
1729
 
1748
- ignored = _ignored_master_fields(master_type, data)
1749
-
1750
1730
  try:
1751
1731
  result = dict(create_master_record(master_type, data))
1752
1732
  except Exception as exc:
1753
1733
  detail = getattr(exc, "detail", None)
1754
1734
  return {"error": detail or str(exc)}
1755
1735
 
1756
- if ignored:
1757
- result["_warning"] = (
1758
- f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1759
- f"{ignored}. Call get_master_fields(master_type=\"{master_type}\") to see the real columns, "
1760
- f"then retry mapping those values onto valid field names."
1761
- )
1762
1736
  return result
1763
1737
 
1764
1738
 
@@ -1775,19 +1749,12 @@ def _handle_update_master(args):
1775
1749
  if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1776
1750
  data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1777
1751
 
1778
- ignored = _ignored_master_fields(master_type, data)
1779
-
1780
1752
  try:
1781
1753
  result = dict(update_master_record(master_type, name, data))
1782
1754
  except Exception as exc:
1783
1755
  detail = getattr(exc, "detail", None)
1784
1756
  return {"error": detail or str(exc)}
1785
1757
 
1786
- if ignored:
1787
- result["_warning"] = (
1788
- f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1789
- f"{ignored}. Retry the call using the correct field names."
1790
- )
1791
1758
  return result
1792
1759
 
1793
1760
 
@@ -2143,7 +2110,7 @@ def _handle_query_dataset(args):
2143
2110
  dataset=dataset,
2144
2111
  group_by=args.get("group_by") or [],
2145
2112
  measures=measures,
2146
- filters=args.get("filters") or {},
2113
+ filters=args.get("filters"),
2147
2114
  order_by=args.get("order_by") or [],
2148
2115
  limit=args.get("limit"),
2149
2116
  )
@@ -2838,8 +2805,8 @@ When creating a payment-entry, the `data` object MUST include:
2838
2805
 
2839
2806
  ## Master keys vs display names — CRITICAL
2840
2807
 
2841
- Every master record has a primary key (the `name` column) and a human-readable display field:
2842
- - **Item:** key = `item_code` (e.g. `SVC-005`), display = `item_name` (e.g. "Project Management")
2808
+ Every master record has a primary key (the `name` column) and a human-readable display field. `get_master_fields` lists real columns separately from `field_aliases`. Prefer canonical `name` in new master calls and result projections; aliases remain for compatibility. Registered aliases work in master search fields, filters, sorting and result projection; requesting an alias returns it alongside `name`. Unknown result fields are errors:
2809
+ - **Item:** stored key = `name` (e.g. `SVC-005`); `item_code` is its registered alias and the item-reference field on document lines; display = `item_name` (e.g. "Project Management")
2843
2810
  - **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
2844
2811
  - **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
2845
2812
  - **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
@@ -17,6 +17,7 @@ from lambda_erp.database import setup
17
17
  from api.errors import register_exception_handlers
18
18
  from api.auth import router as auth_router, COOKIE_NAME, decode_token
19
19
  from api.oauth import router as oauth_router
20
+ from api.mcp_oauth import router as mcp_oauth_router, OAuthError, oauth_error_handler
20
21
  from api.attachments import router as attachments_router
21
22
  from api.chat import chat_websocket, router as chat_router
22
23
  from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
@@ -101,10 +102,12 @@ app.add_middleware(
101
102
 
102
103
  # Exception handlers
103
104
  register_exception_handlers(app)
105
+ app.add_exception_handler(OAuthError, oauth_error_handler)
104
106
 
105
107
  # Routers
106
108
  app.include_router(auth_router, prefix="/api")
107
109
  app.include_router(oauth_router, prefix="/api")
110
+ app.include_router(mcp_oauth_router)
108
111
  app.include_router(attachments_router, prefix="/api")
109
112
  app.include_router(documents.router, prefix="/api")
110
113
  app.include_router(proposals.router, prefix="/api")