lambda-erp 1.1.1__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.1 → lambda_erp-1.1.2}/PKG-INFO +1 -1
  2. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/chat.py +26 -59
  3. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/analytics.py +81 -11
  4. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/masters.py +25 -28
  5. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/services.py +31 -5
  6. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/database.py +6 -0
  7. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/pyproject.toml +1 -1
  8. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/.gitignore +0 -0
  9. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/LICENSE +0 -0
  10. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/README.md +0 -0
  11. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/__init__.py +0 -0
  12. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/attachments.py +0 -0
  13. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/auth.py +0 -0
  14. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/bootstrap.py +0 -0
  15. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/chat_links.py +0 -0
  16. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/demo_limits.py +0 -0
  17. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/deps.py +0 -0
  18. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/errors.py +0 -0
  19. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/list_values.py +0 -0
  20. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/main.py +0 -0
  21. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/oauth.py +0 -0
  22. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf.py +0 -0
  23. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_contract.py +0 -0
  24. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_exports.py +0 -0
  25. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_profiles.py +0 -0
  26. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/providers.py +0 -0
  27. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/remarks_md.py +0 -0
  28. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/__init__.py +0 -0
  29. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/accounting.py +0 -0
  30. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/actions.py +0 -0
  31. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/admin.py +0 -0
  32. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/availability.py +0 -0
  33. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/bank_reconciliation.py +0 -0
  34. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/bank_statements.py +0 -0
  35. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/chat_api.py +0 -0
  36. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/documents.py +0 -0
  37. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/mcp.py +0 -0
  38. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/proposals.py +0 -0
  39. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/reports.py +0 -0
  40. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/setup.py +0 -0
  41. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/_proposal_totals.html +0 -0
  42. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/_record_base.html +0 -0
  43. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/document.html +0 -0
  44. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/proposal.html +0 -0
  45. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/record.html +0 -0
  46. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/time_filters.py +0 -0
  47. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/tool_permissions.py +0 -0
  48. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/docs/agents/README.md +0 -0
  49. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/LICENSE +0 -0
  50. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/README.md +0 -0
  51. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/src/api/client.ts +0 -0
  52. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/__init__.py +0 -0
  53. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/__init__.py +0 -0
  54. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_account.py +0 -0
  55. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  56. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
  57. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_transaction.py +0 -0
  58. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/budget.py +0 -0
  59. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/camt.py +0 -0
  60. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  61. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/general_ledger.py +0 -0
  62. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/journal_entry.py +0 -0
  63. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/payment_entry.py +0 -0
  64. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/pos_invoice.py +0 -0
  65. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
  66. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/revaluation.py +0 -0
  67. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/sales_invoice.py +0 -0
  68. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/settlement.py +0 -0
  69. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/__init__.py +0 -0
  70. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/engine.py +0 -0
  71. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/pack.py +0 -0
  72. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  73. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  74. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  75. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  76. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  77. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  78. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/profiles.py +0 -0
  79. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/spine.py +0 -0
  80. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/subscription.py +0 -0
  81. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/__init__.py +0 -0
  82. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/asset.py +0 -0
  83. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/lifecycle.py +0 -0
  84. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/reservation.py +0 -0
  85. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/buying/__init__.py +0 -0
  86. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/buying/purchase_order.py +0 -0
  87. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/__init__.py +0 -0
  88. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/currency.py +0 -0
  89. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/defaults.py +0 -0
  90. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/item_price.py +0 -0
  91. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/item_prices.py +0 -0
  92. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/pricing_rule.py +0 -0
  93. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  94. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/exceptions.py +0 -0
  95. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/hooks.py +0 -0
  96. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/master_integrity.py +0 -0
  97. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/model.py +0 -0
  98. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/__init__.py +0 -0
  99. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/proposal.py +0 -0
  100. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/quotation.py +0 -0
  101. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/sales_order.py +0 -0
  102. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/simulation.py +0 -0
  103. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/__init__.py +0 -0
  104. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/delivery_note.py +0 -0
  105. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/purchase_receipt.py +0 -0
  106. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/stock_entry.py +0 -0
  107. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/stock_ledger.py +0 -0
  108. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/timestamps.py +0 -0
  109. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/utils.py +0 -0
  110. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/validation.py +0 -0
  111. {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/workflow.py +0 -0
  112. {lambda_erp-1.1.1 → 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.1
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
@@ -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}
@@ -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.1"
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
File without changes