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.
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/PKG-INFO +1 -1
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/chat.py +26 -59
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/analytics.py +81 -11
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/masters.py +25 -28
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/services.py +31 -5
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/database.py +6 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/pyproject.toml +1 -1
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/.gitignore +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/LICENSE +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/attachments.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/auth.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/chat_links.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/deps.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/errors.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/list_values.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/main.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/oauth.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_contract.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/providers.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/mcp.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/document.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/templates/record.html +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/time_filters.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/README.md +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.1 → lambda_erp-1.1.2}/terraform/README.md +0 -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
|
|
41
|
+
from api.routers.masters import create_master_record, update_master_record, delete_master_record
|
|
42
|
+
from api.routers.analytics import DATASET_FILTER_DESCRIPTION
|
|
42
43
|
from lambda_erp.database import get_db
|
|
43
44
|
from lambda_erp.utils import flt, now, nowdate
|
|
44
45
|
|
|
@@ -683,17 +684,17 @@ TOOLS = [
|
|
|
683
684
|
},
|
|
684
685
|
"filters": {
|
|
685
686
|
"type": "object",
|
|
686
|
-
"description": "Optional deterministic filters on any real column, ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
|
|
687
|
+
"description": "Optional deterministic filters on any real column or registered alias (item_code = name), ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
|
|
687
688
|
"default": {},
|
|
688
689
|
},
|
|
689
|
-
"order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to the time_filter field when supplied, otherwise name."},
|
|
690
|
+
"order_by": {"type": "string", "description": "Optional real column or registered alias (item_code = name) to sort by. Defaults to the time_filter field when supplied, otherwise name."},
|
|
690
691
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
|
|
691
692
|
"limit": {"type": "integer", "description": "Maximum results. With time_filter/include_meta: default 50, range 1–500. Legacy simple lookups have no cap when omitted."},
|
|
692
693
|
"offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
|
|
693
694
|
"result_fields": {
|
|
694
695
|
"type": "array",
|
|
695
696
|
"items": {"type": "string"},
|
|
696
|
-
"description": "Optional result projection. Returns
|
|
697
|
+
"description": "Optional result projection. Returns requested columns plus name. Registered aliases are accepted: item_code resolves to name and is also returned as item_code when requested. This is separate from `fields`, which selects columns searched by `query`.",
|
|
697
698
|
},
|
|
698
699
|
"include_disabled": {"type": "boolean", "description": "Default false (active records only). Set true to ALSO return disabled/archived records — needed to find a record you must inspect, re-enable, or reference, or to answer 'what's disabled'. Retry with this if a lookup for a record you know exists comes back empty."},
|
|
699
700
|
},
|
|
@@ -705,7 +706,7 @@ TOOLS = [
|
|
|
705
706
|
"type": "function",
|
|
706
707
|
"function": {
|
|
707
708
|
"name": "get_master_fields",
|
|
708
|
-
"description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` or `
|
|
709
|
+
"description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields`, `filters`, `result_fields` or `order_by` to search_masters, and before building a create/update payload. Returns real fields, field_aliases (e.g. item_code maps to name), text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
|
|
709
710
|
"parameters": {
|
|
710
711
|
"type": "object",
|
|
711
712
|
"properties": {
|
|
@@ -720,11 +721,11 @@ TOOLS = [
|
|
|
720
721
|
"function": {
|
|
721
722
|
"name": "create_master",
|
|
722
723
|
"description": (
|
|
723
|
-
"Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are
|
|
724
|
-
"Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company
|
|
725
|
-
"**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
|
|
724
|
+
"Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are rejected. Call get_master_fields for the live schema, including extension fields.\n\n"
|
|
725
|
+
"Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company defaults it to company_name. Account/Cost Center require an explicit `name`.\n\n"
|
|
726
|
+
"**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, website, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
|
|
726
727
|
"**Supplier fields:** name (optional custom id; auto SUPP-NNN), supplier_name (required), supplier_group, default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
|
|
727
|
-
"**Item fields:**
|
|
728
|
+
"**Item fields:** name (the unique item code/id, e.g. \"SVC-SPARK\" — optional, may use ANY prefix, not just ITEM; auto-generated as ITEM-NNN if omitted), item_name (required, the human-readable name), item_group, stock_uom (prefer one of the units listed under 'Item Units' in the system context — exact spelling; a new unit is allowed when none fits), standard_rate, is_stock_item, default_warehouse, description.\n"
|
|
728
729
|
"**Warehouse fields:** name (optional custom id; auto WH-NNN), warehouse_name (required), company, parent_warehouse (omit or null when not needed), address, city, zip_code, country.\n"
|
|
729
730
|
"**Company fields:** name (the company id — optional, defaults to company_name), company_name (required), default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
|
|
730
731
|
"**Account fields:** name (REQUIRED — full account id, conventionally \"<account_name> - <company abbr>\", e.g. \"Marketing Expenses - LAMB\"), account_name (required), company (required), root_type (Asset/Liability/Equity/Income/Expense), report_type (\"Balance Sheet\" or \"Profit and Loss\"), account_type (e.g. Receivable, Payable, Bank, Cash, Stock, Tax), parent_account, account_currency, is_group (0/1).\n"
|
|
@@ -732,7 +733,7 @@ TOOLS = [
|
|
|
732
733
|
"zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
|
|
733
734
|
"When the user names a contact person at a customer (e.g. \"Kontakt/Ansprechpartner ist Marlene Voss, 079 123 45 67\"), put the person's name in contact_person and their phone/email in contact_phone/contact_email — NOT in the company-level phone/email. There is no separate contact tool; contact-person data lives on these Customer columns, so never claim you cannot store a contact person.\n"
|
|
734
735
|
"Supplier example: {\"master_type\":\"supplier\",\"data\":{\"supplier_name\":\"Schlafteq\",\"email\":\"jacob@schlafteq.ch\",\"phone\":\"+1 555-0104\",\"address\":\"145 Harbor Rd\",\"city\":\"Seattle\",\"zip_code\":\"98101\",\"country\":\"US\",\"tax_id\":\"98-7654321\"}}\n"
|
|
735
|
-
"Item example (custom code): {\"master_type\":\"item\",\"data\":{\"
|
|
736
|
+
"Item example (custom code): {\"master_type\":\"item\",\"data\":{\"name\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}\n"
|
|
736
737
|
"Customer example (with contact person): {\"master_type\":\"customer\",\"data\":{\"customer_name\":\"Foglio AG\",\"address\":\"Seeweg 12\",\"city\":\"Bramblewick\",\"zip_code\":\"9999\",\"contact_person\":\"Marlene Voss\",\"contact_phone\":\"079 123 45 67\"}}"
|
|
737
738
|
),
|
|
738
739
|
"parameters": {
|
|
@@ -750,7 +751,7 @@ TOOLS = [
|
|
|
750
751
|
"function": {
|
|
751
752
|
"name": "update_master",
|
|
752
753
|
"description": (
|
|
753
|
-
"Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
|
|
754
|
+
"Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, website, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
|
|
754
755
|
),
|
|
755
756
|
"parameters": {
|
|
756
757
|
"type": "object",
|
|
@@ -1023,8 +1024,9 @@ TOOLS = [
|
|
|
1023
1024
|
"`measures` is an object of `{alias: [op, field]}` where `op` "
|
|
1024
1025
|
"is one of sum, count, avg, min, max (count may omit field). "
|
|
1025
1026
|
"`filters` is a dict keyed by field name — same shape as for "
|
|
1026
|
-
"`create_custom_analytics_report` (equality, list for IN, "
|
|
1027
|
-
"`{from, to}` for ranges).
|
|
1027
|
+
"`create_custom_analytics_report` (equality, [\"in\", [values]] or a flat list for IN, "
|
|
1028
|
+
"[\"not in\", [values]], `{from, to}` for ranges). Invalid filter shapes "
|
|
1029
|
+
"are errors, not evidence of zero stock or activity. `order_by` is a list of "
|
|
1028
1030
|
"`{field, direction}` where `field` is a group_by field or a "
|
|
1029
1031
|
"measure alias and `direction` is asc/desc.\n\n"
|
|
1030
1032
|
"Example — top 5 customers by revenue:\n"
|
|
@@ -1070,7 +1072,7 @@ TOOLS = [
|
|
|
1070
1072
|
"type": "object",
|
|
1071
1073
|
"description": "Map of alias -> [op, field]. op ∈ sum|count|avg|min|max.",
|
|
1072
1074
|
},
|
|
1073
|
-
"filters": {"type": "object"},
|
|
1075
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1074
1076
|
"order_by": {
|
|
1075
1077
|
"type": "array",
|
|
1076
1078
|
"items": {
|
|
@@ -1147,7 +1149,7 @@ TOOLS = [
|
|
|
1147
1149
|
],
|
|
1148
1150
|
},
|
|
1149
1151
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
1150
|
-
"filters": {"type": "object"},
|
|
1152
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1151
1153
|
"limit": {"type": "integer"},
|
|
1152
1154
|
},
|
|
1153
1155
|
"required": ["dataset"],
|
|
@@ -1233,7 +1235,7 @@ TOOLS = [
|
|
|
1233
1235
|
],
|
|
1234
1236
|
},
|
|
1235
1237
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
1236
|
-
"filters": {"type": "object"},
|
|
1238
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1237
1239
|
"limit": {"type": "integer"},
|
|
1238
1240
|
},
|
|
1239
1241
|
"required": ["dataset"],
|
|
@@ -1603,6 +1605,7 @@ def _handle_get_master_fields(args):
|
|
|
1603
1605
|
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1604
1606
|
return {
|
|
1605
1607
|
"master_type": master_type,
|
|
1608
|
+
"field_aliases": services.master_field_aliases(master_type),
|
|
1606
1609
|
"time_filter_fields": time_filter_fields(db, doctype),
|
|
1607
1610
|
"fields": sorted(db._get_table_columns(doctype)),
|
|
1608
1611
|
"text_fields": sorted(db._get_text_columns(doctype)),
|
|
@@ -1613,7 +1616,7 @@ def _handle_get_master_fields(args):
|
|
|
1613
1616
|
"requirements": master_requirements(master_type),
|
|
1614
1617
|
"link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
|
|
1615
1618
|
"dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
|
|
1616
|
-
"input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | (
|
|
1619
|
+
"input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | set(services.master_field_aliases(master_type))),
|
|
1617
1620
|
}
|
|
1618
1621
|
|
|
1619
1622
|
|
|
@@ -1661,18 +1664,13 @@ def _handle_search_masters(args):
|
|
|
1661
1664
|
requested = args.get("fields") or []
|
|
1662
1665
|
if requested:
|
|
1663
1666
|
valid = columns
|
|
1664
|
-
|
|
1665
|
-
# everywhere else — create_master, document lines, the asset/reservation
|
|
1666
|
-
# prompt — so it naturally passes fields=["item_code"]. But the real
|
|
1667
|
-
# column is the `name` PK; resolve the alias to `name` so that lookup
|
|
1668
|
-
# works instead of erroring "None of fields ['item_code'] exist".
|
|
1669
|
-
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
1667
|
+
aliases = services.master_field_aliases(master_type)
|
|
1670
1668
|
search_cols = []
|
|
1671
1669
|
for c in requested:
|
|
1672
1670
|
if c in valid:
|
|
1673
1671
|
search_cols.append(c)
|
|
1674
|
-
elif
|
|
1675
|
-
search_cols.append(
|
|
1672
|
+
elif c in aliases:
|
|
1673
|
+
search_cols.append(aliases[c])
|
|
1676
1674
|
search_cols = sorted(set(search_cols))
|
|
1677
1675
|
# Nothing resolved (all names unknown) — degrade to the default text
|
|
1678
1676
|
# search rather than erroring: the value the caller meant is almost
|
|
@@ -1719,22 +1717,6 @@ def _handle_search_masters(args):
|
|
|
1719
1717
|
return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
|
|
1720
1718
|
|
|
1721
1719
|
|
|
1722
|
-
def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
|
|
1723
|
-
"""Return field names in `data` that aren't valid columns on the master's table."""
|
|
1724
|
-
entry = services.MASTER_TABLES.get(master_type)
|
|
1725
|
-
if not entry:
|
|
1726
|
-
return []
|
|
1727
|
-
doctype, _ = entry
|
|
1728
|
-
from lambda_erp.database import get_db
|
|
1729
|
-
valid = set(get_db()._get_table_columns(doctype))
|
|
1730
|
-
# item_code is a recognized alias for the Item's `name` PK, not an unknown
|
|
1731
|
-
# field — don't warn that it was ignored when it was actually honored.
|
|
1732
|
-
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
1733
|
-
if alias:
|
|
1734
|
-
valid.add(alias)
|
|
1735
|
-
return [k for k in data.keys() if k not in valid]
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
1720
|
@with_record_view_urls("master_type", master=True)
|
|
1739
1721
|
def _handle_create_master(args):
|
|
1740
1722
|
master_type = args["master_type"]
|
|
@@ -1745,20 +1727,12 @@ def _handle_create_master(args):
|
|
|
1745
1727
|
if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
|
|
1746
1728
|
data = {k: v for k, v in data.items() if k != "parent_warehouse"}
|
|
1747
1729
|
|
|
1748
|
-
ignored = _ignored_master_fields(master_type, data)
|
|
1749
|
-
|
|
1750
1730
|
try:
|
|
1751
1731
|
result = dict(create_master_record(master_type, data))
|
|
1752
1732
|
except Exception as exc:
|
|
1753
1733
|
detail = getattr(exc, "detail", None)
|
|
1754
1734
|
return {"error": detail or str(exc)}
|
|
1755
1735
|
|
|
1756
|
-
if ignored:
|
|
1757
|
-
result["_warning"] = (
|
|
1758
|
-
f"These fields were IGNORED because they are not valid columns on the {master_type}: "
|
|
1759
|
-
f"{ignored}. Call get_master_fields(master_type=\"{master_type}\") to see the real columns, "
|
|
1760
|
-
f"then retry mapping those values onto valid field names."
|
|
1761
|
-
)
|
|
1762
1736
|
return result
|
|
1763
1737
|
|
|
1764
1738
|
|
|
@@ -1775,19 +1749,12 @@ def _handle_update_master(args):
|
|
|
1775
1749
|
if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
|
|
1776
1750
|
data = {k: v for k, v in data.items() if k != "parent_warehouse"}
|
|
1777
1751
|
|
|
1778
|
-
ignored = _ignored_master_fields(master_type, data)
|
|
1779
|
-
|
|
1780
1752
|
try:
|
|
1781
1753
|
result = dict(update_master_record(master_type, name, data))
|
|
1782
1754
|
except Exception as exc:
|
|
1783
1755
|
detail = getattr(exc, "detail", None)
|
|
1784
1756
|
return {"error": detail or str(exc)}
|
|
1785
1757
|
|
|
1786
|
-
if ignored:
|
|
1787
|
-
result["_warning"] = (
|
|
1788
|
-
f"These fields were IGNORED because they are not valid columns on the {master_type}: "
|
|
1789
|
-
f"{ignored}. Retry the call using the correct field names."
|
|
1790
|
-
)
|
|
1791
1758
|
return result
|
|
1792
1759
|
|
|
1793
1760
|
|
|
@@ -2143,7 +2110,7 @@ def _handle_query_dataset(args):
|
|
|
2143
2110
|
dataset=dataset,
|
|
2144
2111
|
group_by=args.get("group_by") or [],
|
|
2145
2112
|
measures=measures,
|
|
2146
|
-
filters=args.get("filters")
|
|
2113
|
+
filters=args.get("filters"),
|
|
2147
2114
|
order_by=args.get("order_by") or [],
|
|
2148
2115
|
limit=args.get("limit"),
|
|
2149
2116
|
)
|
|
@@ -2838,8 +2805,8 @@ When creating a payment-entry, the `data` object MUST include:
|
|
|
2838
2805
|
|
|
2839
2806
|
## Master keys vs display names — CRITICAL
|
|
2840
2807
|
|
|
2841
|
-
Every master record has a primary key (the `name` column) and a human-readable display field:
|
|
2842
|
-
- **Item:** key = `
|
|
2808
|
+
Every master record has a primary key (the `name` column) and a human-readable display field. `get_master_fields` lists real columns separately from `field_aliases`. Prefer canonical `name` in new master calls and result projections; aliases remain for compatibility. Registered aliases work in master search fields, filters, sorting and result projection; requesting an alias returns it alongside `name`. Unknown result fields are errors:
|
|
2809
|
+
- **Item:** stored key = `name` (e.g. `SVC-005`); `item_code` is its registered alias and the item-reference field on document lines; display = `item_name` (e.g. "Project Management")
|
|
2843
2810
|
- **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
|
|
2844
2811
|
- **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
|
|
2845
2812
|
- **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
|
|
@@ -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
|
|
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
|
|
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(
|
|
951
|
-
if
|
|
1006
|
+
params.append(lower)
|
|
1007
|
+
if has_upper:
|
|
952
1008
|
clauses.append(f"{column} <= ?")
|
|
953
|
-
params.append(
|
|
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
|
|
959
|
-
clauses.append(f"{column}
|
|
960
|
-
params.extend(
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
#
|
|
317
|
-
|
|
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
|
|
390
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|