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.
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/PKG-INFO +1 -1
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/auth.py +15 -5
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/chat.py +26 -59
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/main.py +3 -0
- lambda_erp-1.1.3/api/mcp_oauth.py +423 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/oauth.py +5 -1
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/analytics.py +81 -11
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/masters.py +25 -28
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/mcp.py +58 -9
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/services.py +31 -5
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/src/api/client.ts +9 -5
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/database.py +30 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/pyproject.toml +1 -1
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/.gitignore +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/LICENSE +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/attachments.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/chat_links.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/deps.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/errors.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/list_values.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_contract.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/providers.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/document.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/templates/record.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/time_filters.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/frontend/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.3}/terraform/README.md +0 -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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
724
|
-
"Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company
|
|
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:**
|
|
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\":{\"
|
|
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).
|
|
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 ()) | (
|
|
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
|
-
|
|
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
|
|
1675
|
-
search_cols.append(
|
|
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")
|
|
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 = `
|
|
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")
|