lambda-erp 1.1.0__tar.gz → 1.1.2__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 (112) hide show
  1. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/PKG-INFO +1 -1
  2. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/chat.py +44 -71
  3. lambda_erp-1.1.2/api/chat_links.py +59 -0
  4. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/analytics.py +81 -11
  5. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/masters.py +25 -28
  6. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/services.py +31 -5
  7. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/database.py +6 -0
  8. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/pyproject.toml +1 -1
  9. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/.gitignore +0 -0
  10. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/LICENSE +0 -0
  11. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/README.md +0 -0
  12. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/__init__.py +0 -0
  13. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/attachments.py +0 -0
  14. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/auth.py +0 -0
  15. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/bootstrap.py +0 -0
  16. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/demo_limits.py +0 -0
  17. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/deps.py +0 -0
  18. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/errors.py +0 -0
  19. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/list_values.py +0 -0
  20. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/main.py +0 -0
  21. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/oauth.py +0 -0
  22. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf.py +0 -0
  23. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_contract.py +0 -0
  24. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_exports.py +0 -0
  25. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_profiles.py +0 -0
  26. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/providers.py +0 -0
  27. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/remarks_md.py +0 -0
  28. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/__init__.py +0 -0
  29. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/accounting.py +0 -0
  30. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/actions.py +0 -0
  31. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/admin.py +0 -0
  32. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/availability.py +0 -0
  33. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/bank_reconciliation.py +0 -0
  34. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/bank_statements.py +0 -0
  35. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/chat_api.py +0 -0
  36. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/documents.py +0 -0
  37. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/mcp.py +0 -0
  38. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/proposals.py +0 -0
  39. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/reports.py +0 -0
  40. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/setup.py +0 -0
  41. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/_proposal_totals.html +0 -0
  42. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/_record_base.html +0 -0
  43. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/document.html +0 -0
  44. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/proposal.html +0 -0
  45. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/record.html +0 -0
  46. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/time_filters.py +0 -0
  47. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/tool_permissions.py +0 -0
  48. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/docs/agents/README.md +0 -0
  49. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/LICENSE +0 -0
  50. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/README.md +0 -0
  51. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/src/api/client.ts +0 -0
  52. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/__init__.py +0 -0
  53. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/__init__.py +0 -0
  54. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_account.py +0 -0
  55. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  56. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
  57. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_transaction.py +0 -0
  58. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/budget.py +0 -0
  59. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/camt.py +0 -0
  60. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  61. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/general_ledger.py +0 -0
  62. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/journal_entry.py +0 -0
  63. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/payment_entry.py +0 -0
  64. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/pos_invoice.py +0 -0
  65. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
  66. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/revaluation.py +0 -0
  67. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/sales_invoice.py +0 -0
  68. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/settlement.py +0 -0
  69. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/__init__.py +0 -0
  70. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/engine.py +0 -0
  71. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/pack.py +0 -0
  72. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  73. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  74. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  75. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  76. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  77. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  78. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/profiles.py +0 -0
  79. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/spine.py +0 -0
  80. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/subscription.py +0 -0
  81. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/__init__.py +0 -0
  82. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/asset.py +0 -0
  83. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/lifecycle.py +0 -0
  84. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/reservation.py +0 -0
  85. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/buying/__init__.py +0 -0
  86. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/buying/purchase_order.py +0 -0
  87. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/__init__.py +0 -0
  88. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/currency.py +0 -0
  89. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/defaults.py +0 -0
  90. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/item_price.py +0 -0
  91. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/item_prices.py +0 -0
  92. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/pricing_rule.py +0 -0
  93. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  94. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/exceptions.py +0 -0
  95. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/hooks.py +0 -0
  96. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/master_integrity.py +0 -0
  97. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/model.py +0 -0
  98. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/__init__.py +0 -0
  99. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/proposal.py +0 -0
  100. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/quotation.py +0 -0
  101. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/sales_order.py +0 -0
  102. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/simulation.py +0 -0
  103. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/__init__.py +0 -0
  104. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/delivery_note.py +0 -0
  105. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/purchase_receipt.py +0 -0
  106. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/stock_entry.py +0 -0
  107. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/stock_ledger.py +0 -0
  108. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/timestamps.py +0 -0
  109. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/utils.py +0 -0
  110. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/validation.py +0 -0
  111. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/workflow.py +0 -0
  112. {lambda_erp-1.1.0 → lambda_erp-1.1.2}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -28,6 +28,7 @@ from openai import OpenAI
28
28
 
29
29
  from api.time_filters import TIME_FILTER_SCHEMA, time_filter_fields
30
30
  from api import services
31
+ from api.chat_links import with_record_view_urls
31
32
  from api.tool_permissions import tool_allowed, tool_permission_error
32
33
  from api.demo_limits import (
33
34
  demo_call_reserve_usd,
@@ -37,7 +38,8 @@ from api.demo_limits import (
37
38
  limiter as demo_limiter,
38
39
  )
39
40
  from api.providers import cost_of_openai_call, cost_of_transcription
40
- 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
41
43
  from lambda_erp.database import get_db
42
44
  from lambda_erp.utils import flt, now, nowdate
43
45
 
@@ -682,17 +684,17 @@ TOOLS = [
682
684
  },
683
685
  "filters": {
684
686
  "type": "object",
685
- "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.",
686
688
  "default": {},
687
689
  },
688
- "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."},
689
691
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
690
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."},
691
693
  "offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
692
694
  "result_fields": {
693
695
  "type": "array",
694
696
  "items": {"type": "string"},
695
- "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`.",
696
698
  },
697
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."},
698
700
  },
@@ -704,7 +706,7 @@ TOOLS = [
704
706
  "type": "function",
705
707
  "function": {
706
708
  "name": "get_master_fields",
707
- "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.",
708
710
  "parameters": {
709
711
  "type": "object",
710
712
  "properties": {
@@ -719,11 +721,11 @@ TOOLS = [
719
721
  "function": {
720
722
  "name": "create_master",
721
723
  "description": (
722
- "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"
723
- "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"
724
- "**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"
725
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"
726
- "**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"
727
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"
728
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"
729
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"
@@ -731,7 +733,7 @@ TOOLS = [
731
733
  "zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
732
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"
733
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"
734
- "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"
735
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\"}}"
736
738
  ),
737
739
  "parameters": {
@@ -749,7 +751,7 @@ TOOLS = [
749
751
  "function": {
750
752
  "name": "update_master",
751
753
  "description": (
752
- "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\"}}"
753
755
  ),
754
756
  "parameters": {
755
757
  "type": "object",
@@ -1022,8 +1024,9 @@ TOOLS = [
1022
1024
  "`measures` is an object of `{alias: [op, field]}` where `op` "
1023
1025
  "is one of sum, count, avg, min, max (count may omit field). "
1024
1026
  "`filters` is a dict keyed by field name — same shape as for "
1025
- "`create_custom_analytics_report` (equality, list for IN, "
1026
- "`{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 "
1027
1030
  "`{field, direction}` where `field` is a group_by field or a "
1028
1031
  "measure alias and `direction` is asc/desc.\n\n"
1029
1032
  "Example — top 5 customers by revenue:\n"
@@ -1069,7 +1072,7 @@ TOOLS = [
1069
1072
  "type": "object",
1070
1073
  "description": "Map of alias -> [op, field]. op ∈ sum|count|avg|min|max.",
1071
1074
  },
1072
- "filters": {"type": "object"},
1075
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1073
1076
  "order_by": {
1074
1077
  "type": "array",
1075
1078
  "items": {
@@ -1146,7 +1149,7 @@ TOOLS = [
1146
1149
  ],
1147
1150
  },
1148
1151
  "fields": {"type": "array", "items": {"type": "string"}},
1149
- "filters": {"type": "object"},
1152
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1150
1153
  "limit": {"type": "integer"},
1151
1154
  },
1152
1155
  "required": ["dataset"],
@@ -1232,7 +1235,7 @@ TOOLS = [
1232
1235
  ],
1233
1236
  },
1234
1237
  "fields": {"type": "array", "items": {"type": "string"}},
1235
- "filters": {"type": "object"},
1238
+ "filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
1236
1239
  "limit": {"type": "integer"},
1237
1240
  },
1238
1241
  "required": ["dataset"],
@@ -1459,6 +1462,7 @@ def _validate_filter_columns(doctype_slug, filters) -> str | None:
1459
1462
  return None
1460
1463
 
1461
1464
 
1465
+ @with_record_view_urls("doctype")
1462
1466
  def _handle_list_documents(args):
1463
1467
  doctype = args["doctype"]
1464
1468
  try:
@@ -1497,10 +1501,12 @@ def _handle_get_document_fields(args):
1497
1501
  return services.document_field_metadata(args["doctype"])
1498
1502
 
1499
1503
 
1504
+ @with_record_view_urls("doctype")
1500
1505
  def _handle_get_document(args):
1501
1506
  return services.load_document(args["doctype"], args["name"])
1502
1507
 
1503
1508
 
1509
+ @with_record_view_urls("doctype")
1504
1510
  def _handle_create_document(args):
1505
1511
  data = args.get("data")
1506
1512
  if not data:
@@ -1508,10 +1514,12 @@ def _handle_create_document(args):
1508
1514
  return services.create_document(args["doctype"], data)
1509
1515
 
1510
1516
 
1517
+ @with_record_view_urls("doctype")
1511
1518
  def _handle_update_document(args):
1512
1519
  return services.update_document(args["doctype"], args["name"], args.get("data", {}))
1513
1520
 
1514
1521
 
1522
+ @with_record_view_urls("doctype")
1515
1523
  def _handle_batch_update_documents(args):
1516
1524
  try:
1517
1525
  return services.batch_update_documents(args["doctype"], args.get("updates"))
@@ -1519,18 +1527,22 @@ def _handle_batch_update_documents(args):
1519
1527
  return {"error": str(e)}
1520
1528
 
1521
1529
 
1530
+ @with_record_view_urls("doctype")
1522
1531
  def _handle_submit_document(args):
1523
1532
  return services.submit_document(args["doctype"], args["name"])
1524
1533
 
1525
1534
 
1535
+ @with_record_view_urls("doctype")
1526
1536
  def _handle_cancel_document(args):
1527
1537
  return services.cancel_document(args["doctype"], args["name"])
1528
1538
 
1529
1539
 
1540
+ @with_record_view_urls("doctype")
1530
1541
  def _handle_discard_document(args):
1531
1542
  return services.discard_document(args["doctype"], args["name"])
1532
1543
 
1533
1544
 
1545
+ @with_record_view_urls("target_doctype")
1534
1546
  def _handle_convert_document(args):
1535
1547
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1536
1548
 
@@ -1593,6 +1605,7 @@ def _handle_get_master_fields(args):
1593
1605
  cls = services.DOCUMENT_CLASSES.get(doctype)
1594
1606
  return {
1595
1607
  "master_type": master_type,
1608
+ "field_aliases": services.master_field_aliases(master_type),
1596
1609
  "time_filter_fields": time_filter_fields(db, doctype),
1597
1610
  "fields": sorted(db._get_table_columns(doctype)),
1598
1611
  "text_fields": sorted(db._get_text_columns(doctype)),
@@ -1603,10 +1616,11 @@ def _handle_get_master_fields(args):
1603
1616
  "requirements": master_requirements(master_type),
1604
1617
  "link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
1605
1618
  "dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
1606
- "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))),
1607
1620
  }
1608
1621
 
1609
1622
 
1623
+ @with_record_view_urls("master_type", master=True)
1610
1624
  def _handle_search_masters(args):
1611
1625
  db = get_db()
1612
1626
  meta = bool(args.get("include_meta")) or args.get("time_filter") is not None
@@ -1650,18 +1664,13 @@ def _handle_search_masters(args):
1650
1664
  requested = args.get("fields") or []
1651
1665
  if requested:
1652
1666
  valid = columns
1653
- # The identity alias (e.g. "item_code") is what the model calls the code
1654
- # everywhere else — create_master, document lines, the asset/reservation
1655
- # prompt — so it naturally passes fields=["item_code"]. But the real
1656
- # column is the `name` PK; resolve the alias to `name` so that lookup
1657
- # works instead of erroring "None of fields ['item_code'] exist".
1658
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
1667
+ aliases = services.master_field_aliases(master_type)
1659
1668
  search_cols = []
1660
1669
  for c in requested:
1661
1670
  if c in valid:
1662
1671
  search_cols.append(c)
1663
- elif alias and c == alias and "name" in valid:
1664
- search_cols.append("name")
1672
+ elif c in aliases:
1673
+ search_cols.append(aliases[c])
1665
1674
  search_cols = sorted(set(search_cols))
1666
1675
  # Nothing resolved (all names unknown) — degrade to the default text
1667
1676
  # search rather than erroring: the value the caller meant is almost
@@ -1708,22 +1717,7 @@ def _handle_search_masters(args):
1708
1717
  return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
1709
1718
 
1710
1719
 
1711
- def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
1712
- """Return field names in `data` that aren't valid columns on the master's table."""
1713
- entry = services.MASTER_TABLES.get(master_type)
1714
- if not entry:
1715
- return []
1716
- doctype, _ = entry
1717
- from lambda_erp.database import get_db
1718
- valid = set(get_db()._get_table_columns(doctype))
1719
- # item_code is a recognized alias for the Item's `name` PK, not an unknown
1720
- # field — don't warn that it was ignored when it was actually honored.
1721
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
1722
- if alias:
1723
- valid.add(alias)
1724
- return [k for k in data.keys() if k not in valid]
1725
-
1726
-
1720
+ @with_record_view_urls("master_type", master=True)
1727
1721
  def _handle_create_master(args):
1728
1722
  master_type = args["master_type"]
1729
1723
  data = args.get("data")
@@ -1733,23 +1727,16 @@ def _handle_create_master(args):
1733
1727
  if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1734
1728
  data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1735
1729
 
1736
- ignored = _ignored_master_fields(master_type, data)
1737
-
1738
1730
  try:
1739
1731
  result = dict(create_master_record(master_type, data))
1740
1732
  except Exception as exc:
1741
1733
  detail = getattr(exc, "detail", None)
1742
1734
  return {"error": detail or str(exc)}
1743
1735
 
1744
- if ignored:
1745
- result["_warning"] = (
1746
- f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1747
- f"{ignored}. Call get_master_fields(master_type=\"{master_type}\") to see the real columns, "
1748
- f"then retry mapping those values onto valid field names."
1749
- )
1750
1736
  return result
1751
1737
 
1752
1738
 
1739
+ @with_record_view_urls("master_type", master=True)
1753
1740
  def _handle_update_master(args):
1754
1741
  master_type = args["master_type"]
1755
1742
  name = args.get("name")
@@ -1762,19 +1749,12 @@ def _handle_update_master(args):
1762
1749
  if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1763
1750
  data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1764
1751
 
1765
- ignored = _ignored_master_fields(master_type, data)
1766
-
1767
1752
  try:
1768
1753
  result = dict(update_master_record(master_type, name, data))
1769
1754
  except Exception as exc:
1770
1755
  detail = getattr(exc, "detail", None)
1771
1756
  return {"error": detail or str(exc)}
1772
1757
 
1773
- if ignored:
1774
- result["_warning"] = (
1775
- f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1776
- f"{ignored}. Retry the call using the correct field names."
1777
- )
1778
1758
  return result
1779
1759
 
1780
1760
 
@@ -2130,7 +2110,7 @@ def _handle_query_dataset(args):
2130
2110
  dataset=dataset,
2131
2111
  group_by=args.get("group_by") or [],
2132
2112
  measures=measures,
2133
- filters=args.get("filters") or {},
2113
+ filters=args.get("filters"),
2134
2114
  order_by=args.get("order_by") or [],
2135
2115
  limit=args.get("limit"),
2136
2116
  )
@@ -2464,18 +2444,11 @@ Examples:
2464
2444
  - Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
2465
2445
  - Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)"""
2466
2446
  document_links_section = """## Document & Master Links
2467
- When referencing records, always use clickable markdown links so the user can open them directly.
2468
-
2469
- **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
2470
- - **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
2471
- - **PDF files:** call generate_document_pdf. Only link the returned download_url after successful generation; never construct PDF links yourself.
2472
- The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
2473
-
2474
- **Master records** (customer, supplier, item, warehouse, company):
2475
- - **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
2476
- - NEVER use `/app/...` for masters — that path is only for transactional documents.
2477
-
2478
- Always include the view link after creating, submitting, converting, or updating a record. Call generate_document_pdf when a printable file is requested. Only confirm and link a successfully generated file."""
2447
+ Document and master tools return a canonical `view_url` on each record, including list/search rows and successful batch results. When linking a record, copy that exact value into a markdown link `[label](view_url)`. Never construct a record URL yourself, add a `#`, change the path, or re-encode the returned URL.
2448
+ `view_url` is tool output metadata, not a document/master field; never include it in create/update data.
2449
+ If `view_url` is null, refer to the record in plain text. For a projected document list or batch result missing parent-page information, call get_document to obtain its view_url if a link is needed. Some records have no page at all.
2450
+ Always include the returned view link, when available, after creating, submitting, converting, or updating a record.
2451
+ For PDF files, call generate_document_pdf and copy its returned download_url only after successful generation. Never construct PDF links yourself."""
2479
2452
 
2480
2453
  if user_role == "admin":
2481
2454
  role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
@@ -2832,8 +2805,8 @@ When creating a payment-entry, the `data` object MUST include:
2832
2805
 
2833
2806
  ## Master keys vs display names — CRITICAL
2834
2807
 
2835
- Every master record has a primary key (the `name` column) and a human-readable display field:
2836
- - **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")
2837
2810
  - **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
2838
2811
  - **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
2839
2812
  - **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
@@ -0,0 +1,59 @@
1
+ """Canonical record links on chat/MCP tool results, never persisted fields."""
2
+
3
+ from functools import wraps
4
+ from urllib.parse import quote, urlencode
5
+
6
+ from api import services
7
+
8
+
9
+ def record_view_url(record_type: str, record: dict, *, master: bool = False) -> str | None:
10
+ name = record.get("name")
11
+ if not name:
12
+ return None
13
+ if master:
14
+ if record_type not in services.MASTER_TABLES or record_type == "cost-center":
15
+ return None
16
+ if record_type == "account":
17
+ return "/reports/general-ledger?" + urlencode({"account": name})
18
+ return f"/masters/{quote(record_type, safe='')}/{quote(str(name), safe='')}"
19
+
20
+ slug = services.DOCTYPE_TO_SLUG.get(record_type, record_type)
21
+ if slug not in services.SLUG_TO_DOCTYPE:
22
+ return None
23
+ page = services.chat_doctype_page_info(slug)
24
+ if page:
25
+ if page["kind"] == "none":
26
+ return None
27
+ if page["kind"] == "via":
28
+ name = record.get(page["link_field"])
29
+ if not name:
30
+ # Projected list/batch results may omit the parent. The model
31
+ # can get_document to obtain it; do not guess a destination.
32
+ return None
33
+ slug = page["parent_slug"]
34
+ return f"/app/{quote(slug, safe='')}/{quote(str(name), safe='')}"
35
+
36
+
37
+ def with_record_view_urls(type_arg: str, *, master: bool = False):
38
+ """Enrich records and list/batch envelopes without changing service data."""
39
+ def decorate(handler):
40
+ @wraps(handler)
41
+ def wrapped(args):
42
+ result = handler(args)
43
+
44
+ def enrich(row):
45
+ if not isinstance(row, dict) or "error" in row or row.get("ok") is False:
46
+ return row
47
+ return {**row, "view_url": record_view_url(args.get(type_arg), row, master=master)}
48
+
49
+ if isinstance(result, list):
50
+ return [enrich(row) for row in result]
51
+ if isinstance(result, dict) and "error" not in result:
52
+ if "name" not in result:
53
+ for key in ("rows", "results"):
54
+ if isinstance(result.get(key), list):
55
+ return {**result, key: [enrich(row) for row in result[key]]}
56
+ return enrich(result)
57
+ return result
58
+ return wrapped
59
+ return decorate
@@ -9,7 +9,9 @@ This module now exposes two layers:
9
9
  """
10
10
 
11
11
  import json
12
+ import math
12
13
  import uuid
14
+ from datetime import date
13
15
  from typing import Any, Literal
14
16
 
15
17
  from fastapi import APIRouter, Depends, HTTPException, Query
@@ -308,11 +310,25 @@ def analytics(
308
310
  # ---------------------------------------------------------------------------
309
311
 
310
312
 
313
+ DATASET_FILTER_DESCRIPTION = (
314
+ 'Filters keyed by allowed dataset field. Scalar = equality. For membership prefer '
315
+ '{"item_code": ["in", ["ART-1", "ART-2"]]}; ["not in", [values]] is also supported. '
316
+ 'Legacy flat lists remain literal IN lists (even values named "in"). '
317
+ 'Lists contain at most 500 non-null scalar values; deeper nesting and other '
318
+ 'operator forms are rejected. Empty IN lists match nothing; empty NOT IN lists '
319
+ 'exclude nothing. Ranges use {"from": value, "to": value}, with either bound '
320
+ 'optional; date fields require real YYYY-MM-DD dates and from <= to. '
321
+ 'No other range keys are accepted. Omit unused filters; null/empty-string '
322
+ 'filters and blank from/to bounds are ignored. Invalid filters return errors, '
323
+ 'not zero counts; correct the request before making a factual claim.'
324
+ )
325
+
326
+
311
327
  class RuntimeDataRequest(BaseModel):
312
328
  name: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
313
329
  dataset: str
314
330
  fields: list[str] | None = Field(default=None, max_length=64)
315
- filters: dict[str, Any] = Field(default_factory=dict, max_length=32)
331
+ filters: dict[str, Any] = Field(default_factory=dict, max_length=32, description=DATASET_FILTER_DESCRIPTION)
316
332
  limit: int | None = None
317
333
 
318
334
 
@@ -438,6 +454,10 @@ class ReportDraftPayload(StrictReportModel):
438
454
  raise ValueError(
439
455
  f"Unknown fields for dataset '{request.dataset}': {', '.join(sorted(unknown))}"
440
456
  )
457
+ try:
458
+ _build_dataset_filter_clauses(dataset, request.filters, [])
459
+ except HTTPException as exc:
460
+ raise ValueError(str(exc.detail)) from exc
441
461
  request_fields[source] = fields
442
462
 
443
463
  source_fields = dict(request_fields)
@@ -937,30 +957,80 @@ SEMANTIC_DATASETS: dict[str, dict[str, Any]] = {
937
957
 
938
958
 
939
959
  def _build_dataset_filter_clauses(spec: dict[str, Any], filters: dict[str, Any], params: list[Any]) -> list[str]:
960
+ if filters is None:
961
+ filters = {}
962
+ if not isinstance(filters, dict):
963
+ raise HTTPException(400, "Dataset filters must be an object keyed by field name")
940
964
  clauses = list(spec.get("default_where", []))
941
- for field, value in (filters or {}).items():
965
+ for field, value in filters.items():
942
966
  if field not in spec["filter_fields"]:
943
967
  raise HTTPException(400, f"Dataset '{spec['label']}' does not allow filtering on '{field}'")
944
968
  column = spec["field_sql"][field]
969
+
970
+ def invalid(reason):
971
+ raise HTTPException(400, f"Invalid filter for '{field}': {reason}")
972
+
973
+ def scalar(value):
974
+ if not isinstance(value, (str, int, float, bool)) or (
975
+ isinstance(value, float) and not math.isfinite(value)
976
+ ):
977
+ invalid("expected a non-null scalar value, not an object or nested list")
978
+ if spec["fields"][field] == "date":
979
+ try:
980
+ if not isinstance(value, str) or date.fromisoformat(value).isoformat() != value:
981
+ raise ValueError()
982
+ except ValueError:
983
+ invalid("expected a valid ISO date (YYYY-MM-DD)")
984
+ return value
985
+
945
986
  if value is None or value == "":
946
987
  continue
947
988
  if isinstance(value, dict):
948
- if "from" in value and value["from"] not in (None, ""):
989
+ if not value or set(value) - {"from", "to"}:
990
+ invalid("range objects may contain only 'from' and 'to'")
991
+ lower = value.get("from")
992
+ upper = value.get("to")
993
+ has_lower, has_upper = lower not in (None, ""), upper not in (None, "")
994
+ if has_lower:
995
+ scalar(lower)
996
+ if has_upper:
997
+ scalar(upper)
998
+ if has_lower and has_upper:
999
+ try:
1000
+ if lower > upper:
1001
+ invalid("range 'from' must not be after 'to'")
1002
+ except TypeError:
1003
+ invalid("range bounds must have comparable types")
1004
+ if has_lower:
949
1005
  clauses.append(f"{column} >= ?")
950
- params.append(value["from"])
951
- if "to" in value and value["to"] not in (None, ""):
1006
+ params.append(lower)
1007
+ if has_upper:
952
1008
  clauses.append(f"{column} <= ?")
953
- params.append(value["to"])
1009
+ params.append(upper)
954
1010
  continue
955
1011
  if isinstance(value, list):
1012
+ # Flat lists are the existing literal-IN contract, even if a code
1013
+ # happens to be named 'in'. Only a nested list identifies the
1014
+ # explicit [operator, values] form used by other ERP tools.
1015
+ op = "IN"
1016
+ if any(isinstance(part, (list, dict)) for part in value):
1017
+ if (len(value) != 2 or not isinstance(value[0], str)
1018
+ or value[0].lower() not in {"in", "not in"}
1019
+ or not isinstance(value[1], list)):
1020
+ invalid('use a flat value list or ["in", [values]] / ["not in", [values]]')
1021
+ op, value = value[0].upper(), value[1]
1022
+ if len(value) > 500:
1023
+ invalid("membership lists may contain at most 500 values")
1024
+ values = [scalar(part) for part in value]
956
1025
  if not value:
1026
+ clauses.append("1=0" if op == "IN" else "1=1")
957
1027
  continue
958
- placeholders = ", ".join("?" for _ in value)
959
- clauses.append(f"{column} IN ({placeholders})")
960
- params.extend(value)
1028
+ placeholders = ", ".join("?" for _ in values)
1029
+ clauses.append(f"{column} {op} ({placeholders})")
1030
+ params.extend(values)
961
1031
  continue
962
1032
  clauses.append(f"{column} = ?")
963
- params.append(value)
1033
+ params.append(scalar(value))
964
1034
  return clauses
965
1035
 
966
1036
 
@@ -1067,7 +1137,7 @@ def aggregate_semantic_dataset(
1067
1137
  select_parts.append(f'{op.upper()}({spec["field_sql"][field]}) AS "{sql_name}"')
1068
1138
 
1069
1139
  params: list[Any] = []
1070
- where = _build_dataset_filter_clauses(spec, filters or {}, params)
1140
+ where = _build_dataset_filter_clauses(spec, filters, params)
1071
1141
 
1072
1142
  order_clauses: list[str] = []
1073
1143
  for entry in order_by or []:
@@ -14,7 +14,7 @@ from api.services import (
14
14
  MASTER_IDENTITY_ALIAS,
15
15
  MASTER_REFERENCE_CHECKS,
16
16
  _filter_atom, _search_clause, _where_from_filters,
17
- list_master_records, master_search_columns, parse_list_filter,
17
+ list_master_records, master_search_columns, parse_list_filter, master_field_aliases,
18
18
  )
19
19
  from api.auth import require_role, require_non_public_manager
20
20
  from api.list_values import distinct_list_values
@@ -31,9 +31,10 @@ _admin = Depends(require_role("admin"))
31
31
 
32
32
  def _echo_identity_alias(master_type: str, row):
33
33
  """Mirror the master's `name` back under its intuitive alias (item_code)."""
34
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
35
- if alias and isinstance(row, dict) and row.get("name") is not None:
36
- row[alias] = row["name"]
34
+ if isinstance(row, dict):
35
+ for alias, column in master_field_aliases(master_type).items():
36
+ if row.get(column) is not None:
37
+ row[alias] = row[column]
37
38
  return row
38
39
 
39
40
  DELETE_REFERENCE_CHECKS = {
@@ -313,11 +314,11 @@ def _create_master_record(master_type: str, data: dict) -> dict:
313
314
  doc.pop("modified", None)
314
315
 
315
316
  # Let callers set the code under its intuitive alias (item_code -> name).
316
- # An explicit `name` still wins; the alias key is consumed either way so it
317
- # isn't reported as an ignored field.
318
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
319
- if alias:
317
+ # Conflicting identities must never silently select one of the records.
318
+ for alias in master_field_aliases(master_type):
320
319
  alias_val = doc.pop(alias, None)
320
+ if alias_val and doc.get("name") and alias_val != doc["name"]:
321
+ raise ValidationError(f"Conflicting name and {alias}; both must identify the same {master_type}")
321
322
  if alias_val and not doc.get("name"):
322
323
  doc["name"] = alias_val
323
324
 
@@ -386,9 +387,8 @@ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
386
387
  # The alias (item_code) is the identity, not a mutable column. Allow it as a
387
388
  # no-op when it matches the record being updated, but reject a rename — the
388
389
  # code is a primary key referenced across every transaction.
389
- alias = MASTER_IDENTITY_ALIAS.get(master_type)
390
- if alias and alias in normalized:
391
- alias_val = normalized.pop(alias)
390
+ for alias in master_field_aliases(master_type):
391
+ alias_val = normalized.pop(alias, None)
392
392
  if alias_val and alias_val != name:
393
393
  raise HTTPException(
394
394
  status_code=422,
@@ -454,24 +454,15 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
454
454
  where_parts += wp
455
455
  params += ps
456
456
 
457
- for key, value in request.query_params.items():
458
- if key in _MASTER_LIST_RESERVED:
459
- continue
460
- try:
461
- field, parsed_value = parse_list_filter(db, doctype, key, value)
462
- except KeyError:
463
- raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
464
- except TypeError:
465
- raise HTTPException(
466
- status_code=400,
467
- detail=f"Contains filter is only supported for text fields: {key}",
468
- )
457
+ for field, parsed_value in _master_request_filters(db, doctype, request, master_type).items():
469
458
  clause, clause_params = _filter_atom(field, parsed_value)
470
459
  where_parts.append(clause)
471
460
  params.extend(clause_params)
472
461
 
473
462
  if search:
474
463
  requested = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
464
+ aliases = master_field_aliases(master_type)
465
+ requested = list(dict.fromkeys(aliases.get(f, f) for f in requested))
475
466
  unknown = [f for f in requested if f not in columns]
476
467
  if unknown:
477
468
  raise HTTPException(status_code=400, detail=f"Unknown search field(s): {', '.join(unknown)}")
@@ -484,7 +475,7 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
484
475
  try:
485
476
  window = resolve_time_filter(db, doctype, request_time_filter(request))
486
477
  if window:
487
- if window["field"] in _master_request_filters(db, doctype, request):
478
+ if window["field"] in _master_request_filters(db, doctype, request, master_type):
488
479
  raise ValueError("Do not combine time_filter with a filter on the same timestamp field")
489
480
  clause, bounds = time_predicate(window)
490
481
  where_parts.append(clause)
@@ -494,14 +485,17 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
494
485
  return where_parts, params, columns
495
486
 
496
487
 
497
- def _master_request_filters(db, doctype: str, request: Request) -> dict:
488
+ def _master_request_filters(db, doctype: str, request: Request, master_type: str) -> dict:
498
489
  """Parse the REST query-string field filters into the shared internal form."""
499
490
  filters = {}
491
+ aliases = master_field_aliases(master_type)
500
492
  for key, value in request.query_params.items():
501
493
  if key in _MASTER_LIST_RESERVED:
502
494
  continue
503
495
  try:
504
- field, parsed_value = parse_list_filter(db, doctype, key, value)
496
+ suffix = "__contains" if key.endswith("__contains") else ""
497
+ base = key[:-len(suffix)] if suffix else key
498
+ field, parsed_value = parse_list_filter(db, doctype, aliases.get(base, base) + suffix, value)
505
499
  except KeyError:
506
500
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
507
501
  except TypeError:
@@ -509,6 +503,8 @@ def _master_request_filters(db, doctype: str, request: Request) -> dict:
509
503
  status_code=400,
510
504
  detail=f"Contains filter is only supported for text fields: {key}",
511
505
  )
506
+ if field in filters and filters[field] != parsed_value:
507
+ raise HTTPException(status_code=400, detail=f"Conflicting filters for {field} and its alias")
512
508
  filters[field] = parsed_value
513
509
  return filters
514
510
 
@@ -552,7 +548,7 @@ def master_filter_values(
552
548
  raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
553
549
  db = get_db()
554
550
  try:
555
- values = distinct_list_values(db, doctype, field, q, limit)
551
+ values = distinct_list_values(db, doctype, master_field_aliases(master_type).get(field, field), q, limit)
556
552
  except KeyError:
557
553
  raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
558
554
  return {"values": values}
@@ -577,7 +573,7 @@ def list_masters(
577
573
  if not doctype:
578
574
  return {"detail": f"Unknown master type: {master_type}"}
579
575
  db = get_db()
580
- field_filters = _master_request_filters(db, doctype, request)
576
+ field_filters = _master_request_filters(db, doctype, request, master_type)
581
577
  requested_search_fields = [
582
578
  field.strip() for field in (search_fields or "").split(",") if field.strip()
583
579
  ]
@@ -647,6 +643,7 @@ def adjacent_master(
647
643
  db, doctype, master_type, request, include_disabled, search, search_fields,
648
644
  )
649
645
  window = resolve_time_filter(db, doctype, request_time_filter(request))
646
+ order_by = master_field_aliases(master_type).get(order_by, order_by)
650
647
  sort_column, sort_direction = _master_order(columns, order_by or (window["field"] if window else None), order)
651
648
  sort_expression = f'"{sort_column}"'
652
649
  if window and sort_column == window["field"]:
@@ -1066,6 +1066,27 @@ def _master_sort_sql(column: str, direction: str) -> str:
1066
1066
  return sql
1067
1067
 
1068
1068
 
1069
+ def master_field_aliases(master_type: str) -> dict[str, str]:
1070
+ """Registered virtual fields only; a real column always keeps its meaning."""
1071
+ entry = MASTER_TABLES.get(master_type)
1072
+ if not entry:
1073
+ return {}
1074
+ columns = get_db()._get_table_columns(entry[0])
1075
+ alias = MASTER_IDENTITY_ALIAS.get(master_type)
1076
+ return {alias: "name"} if alias and alias not in columns and "name" in columns else {}
1077
+
1078
+
1079
+ def resolve_master_filters(master_type: str, filters: dict) -> dict:
1080
+ aliases = master_field_aliases(master_type)
1081
+ resolved = {}
1082
+ for key, value in filters.items():
1083
+ column = aliases.get(key, key)
1084
+ if column in resolved and resolved[column] != value:
1085
+ raise ValueError(f"Conflicting filters for {column} and its alias")
1086
+ resolved[column] = value
1087
+ return resolved
1088
+
1089
+
1069
1090
  def list_master_records(
1070
1091
  master_type: str,
1071
1092
  *,
@@ -1095,11 +1116,12 @@ def list_master_records(
1095
1116
  window = resolve_time_filter(db, doctype, time_filter)
1096
1117
  columns = db._get_table_columns(doctype)
1097
1118
  text_fields = sorted(db._get_text_columns(doctype))
1119
+ aliases = master_field_aliases(master_type)
1098
1120
 
1099
- db_filters = {
1121
+ db_filters = resolve_master_filters(master_type, {
1100
1122
  key: value for key, value in (filters or {}).items()
1101
1123
  if value is not None and value != ""
1102
- }
1124
+ })
1103
1125
  unknown_filters = [key for key in db_filters if key not in columns]
1104
1126
  if unknown_filters:
1105
1127
  raise ValueError(
@@ -1114,7 +1136,7 @@ def list_master_records(
1114
1136
  where_parts.insert(0, '"disabled" = ?')
1115
1137
  params.insert(0, 0)
1116
1138
 
1117
- requested_search_fields = list(search_fields or [])
1139
+ requested_search_fields = list(dict.fromkeys(aliases.get(field, field) for field in (search_fields or [])))
1118
1140
  unknown_search = [field for field in requested_search_fields if field not in columns]
1119
1141
  if unknown_search:
1120
1142
  raise ValueError(f"Unknown search field(s): {', '.join(unknown_search)}")
@@ -1126,14 +1148,15 @@ def list_master_records(
1126
1148
  where_parts.append(search_where)
1127
1149
  params.extend(search_params)
1128
1150
 
1129
- sort_column = order_by or "name"
1151
+ sort_column = aliases.get(order_by, order_by) or "name"
1130
1152
  if sort_column not in columns:
1131
1153
  raise ValueError(f"Unknown order_by column for {master_type}: {sort_column}")
1132
1154
  direction = str(order).lower()
1133
1155
  if direction not in ("asc", "desc"):
1134
1156
  raise ValueError("order must be 'asc' or 'desc'")
1135
1157
 
1136
- requested_fields = list(fields or [])
1158
+ requested_aliases = {field: aliases[field] for field in (fields or []) if field in aliases}
1159
+ requested_fields = list(dict.fromkeys(aliases.get(field, field) for field in (fields or [])))
1137
1160
  unknown_fields = [field for field in requested_fields if field not in columns]
1138
1161
  if unknown_fields:
1139
1162
  raise ValueError(f"Unknown result field(s): {', '.join(unknown_fields)}")
@@ -1169,6 +1192,9 @@ def list_master_records(
1169
1192
  query += f" OFFSET {max(0, int(offset))}"
1170
1193
 
1171
1194
  rows = db.sql(query, params)
1195
+ for row in rows:
1196
+ for alias, column in requested_aliases.items():
1197
+ row[alias] = row[column]
1172
1198
  result = page_metadata(rows, total, limit, max(0, int(offset)), window, unknown) if total is not None else {"rows": rows}
1173
1199
  return {**result, "text_fields": text_fields}
1174
1200
 
@@ -408,6 +408,7 @@ class Database:
408
408
  default_currency TEXT DEFAULT 'USD',
409
409
  default_price_list TEXT,
410
410
  credit_limit REAL DEFAULT 0,
411
+ website TEXT,
411
412
  email TEXT,
412
413
  phone TEXT,
413
414
  address TEXT,
@@ -2850,6 +2851,10 @@ def _m037_pricing_record_lifecycle(db: "Database") -> None:
2850
2851
  db.ensure_column(table, "discarded", "INTEGER DEFAULT 0")
2851
2852
 
2852
2853
 
2854
+ def _m038_customer_website(db: "Database") -> None:
2855
+ db.ensure_column("Customer", "website", "TEXT")
2856
+
2857
+
2853
2858
  Database.MIGRATIONS = [
2854
2859
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2855
2860
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2888,6 +2893,7 @@ Database.MIGRATIONS = [
2888
2893
  (35, "pricing_rule_dimensions", _m035_pricing_rule_dimensions),
2889
2894
  (36, "external_identity_integrity", _m036_external_identity_integrity),
2890
2895
  (37, "pricing_record_lifecycle", _m037_pricing_record_lifecycle),
2896
+ (38, "customer_website", _m038_customer_website),
2891
2897
  ]
2892
2898
 
2893
2899
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.1.0"
3
+ version = "1.1.2"
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
File without changes
File without changes
File without changes
File without changes
File without changes