lambda-erp 1.1.0__tar.gz → 1.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/PKG-INFO +1 -1
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/chat.py +44 -71
- lambda_erp-1.1.2/api/chat_links.py +59 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/analytics.py +81 -11
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/masters.py +25 -28
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/services.py +31 -5
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/database.py +6 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/pyproject.toml +1 -1
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/.gitignore +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/LICENSE +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/README.md +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/attachments.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/auth.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/deps.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/errors.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/list_values.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/main.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/oauth.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_contract.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/providers.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/mcp.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/document.html +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/templates/record.html +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/time_filters.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/README.md +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.0 → lambda_erp-1.1.2}/terraform/README.md +0 -0
|
@@ -28,6 +28,7 @@ from openai import OpenAI
|
|
|
28
28
|
|
|
29
29
|
from api.time_filters import TIME_FILTER_SCHEMA, time_filter_fields
|
|
30
30
|
from api import services
|
|
31
|
+
from api.chat_links import with_record_view_urls
|
|
31
32
|
from api.tool_permissions import tool_allowed, tool_permission_error
|
|
32
33
|
from api.demo_limits import (
|
|
33
34
|
demo_call_reserve_usd,
|
|
@@ -37,7 +38,8 @@ from api.demo_limits import (
|
|
|
37
38
|
limiter as demo_limiter,
|
|
38
39
|
)
|
|
39
40
|
from api.providers import cost_of_openai_call, cost_of_transcription
|
|
40
|
-
from api.routers.masters import create_master_record, update_master_record, delete_master_record
|
|
41
|
+
from api.routers.masters import create_master_record, update_master_record, delete_master_record
|
|
42
|
+
from api.routers.analytics import DATASET_FILTER_DESCRIPTION
|
|
41
43
|
from lambda_erp.database import get_db
|
|
42
44
|
from lambda_erp.utils import flt, now, nowdate
|
|
43
45
|
|
|
@@ -682,17 +684,17 @@ TOOLS = [
|
|
|
682
684
|
},
|
|
683
685
|
"filters": {
|
|
684
686
|
"type": "object",
|
|
685
|
-
"description": "Optional deterministic filters on any real column, ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
|
|
687
|
+
"description": "Optional deterministic filters on any real column or registered alias (item_code = name), ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
|
|
686
688
|
"default": {},
|
|
687
689
|
},
|
|
688
|
-
"order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to the time_filter field when supplied, otherwise name."},
|
|
690
|
+
"order_by": {"type": "string", "description": "Optional real column or registered alias (item_code = name) to sort by. Defaults to the time_filter field when supplied, otherwise name."},
|
|
689
691
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
|
|
690
692
|
"limit": {"type": "integer", "description": "Maximum results. With time_filter/include_meta: default 50, range 1–500. Legacy simple lookups have no cap when omitted."},
|
|
691
693
|
"offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
|
|
692
694
|
"result_fields": {
|
|
693
695
|
"type": "array",
|
|
694
696
|
"items": {"type": "string"},
|
|
695
|
-
"description": "Optional result projection. Returns
|
|
697
|
+
"description": "Optional result projection. Returns requested columns plus name. Registered aliases are accepted: item_code resolves to name and is also returned as item_code when requested. This is separate from `fields`, which selects columns searched by `query`.",
|
|
696
698
|
},
|
|
697
699
|
"include_disabled": {"type": "boolean", "description": "Default false (active records only). Set true to ALSO return disabled/archived records — needed to find a record you must inspect, re-enable, or reference, or to answer 'what's disabled'. Retry with this if a lookup for a record you know exists comes back empty."},
|
|
698
700
|
},
|
|
@@ -704,7 +706,7 @@ TOOLS = [
|
|
|
704
706
|
"type": "function",
|
|
705
707
|
"function": {
|
|
706
708
|
"name": "get_master_fields",
|
|
707
|
-
"description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` or `
|
|
709
|
+
"description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields`, `filters`, `result_fields` or `order_by` to search_masters, and before building a create/update payload. Returns real fields, field_aliases (e.g. item_code maps to name), text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
|
|
708
710
|
"parameters": {
|
|
709
711
|
"type": "object",
|
|
710
712
|
"properties": {
|
|
@@ -719,11 +721,11 @@ TOOLS = [
|
|
|
719
721
|
"function": {
|
|
720
722
|
"name": "create_master",
|
|
721
723
|
"description": (
|
|
722
|
-
"Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are
|
|
723
|
-
"Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company
|
|
724
|
-
"**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
|
|
724
|
+
"Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are rejected. Call get_master_fields for the live schema, including extension fields.\n\n"
|
|
725
|
+
"Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company defaults it to company_name. Account/Cost Center require an explicit `name`.\n\n"
|
|
726
|
+
"**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, website, email, phone, address, city, zip_code, country, tax_id, contact_person (named contact at the customer), contact_email, contact_phone.\n"
|
|
725
727
|
"**Supplier fields:** name (optional custom id; auto SUPP-NNN), supplier_name (required), supplier_group, default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
|
|
726
|
-
"**Item fields:**
|
|
728
|
+
"**Item fields:** name (the unique item code/id, e.g. \"SVC-SPARK\" — optional, may use ANY prefix, not just ITEM; auto-generated as ITEM-NNN if omitted), item_name (required, the human-readable name), item_group, stock_uom (prefer one of the units listed under 'Item Units' in the system context — exact spelling; a new unit is allowed when none fits), standard_rate, is_stock_item, default_warehouse, description.\n"
|
|
727
729
|
"**Warehouse fields:** name (optional custom id; auto WH-NNN), warehouse_name (required), company, parent_warehouse (omit or null when not needed), address, city, zip_code, country.\n"
|
|
728
730
|
"**Company fields:** name (the company id — optional, defaults to company_name), company_name (required), default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
|
|
729
731
|
"**Account fields:** name (REQUIRED — full account id, conventionally \"<account_name> - <company abbr>\", e.g. \"Marketing Expenses - LAMB\"), account_name (required), company (required), root_type (Asset/Liability/Equity/Income/Expense), report_type (\"Balance Sheet\" or \"Profit and Loss\"), account_type (e.g. Receivable, Payable, Bank, Cash, Stock, Tax), parent_account, account_currency, is_group (0/1).\n"
|
|
@@ -731,7 +733,7 @@ TOOLS = [
|
|
|
731
733
|
"zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
|
|
732
734
|
"When the user names a contact person at a customer (e.g. \"Kontakt/Ansprechpartner ist Marlene Voss, 079 123 45 67\"), put the person's name in contact_person and their phone/email in contact_phone/contact_email — NOT in the company-level phone/email. There is no separate contact tool; contact-person data lives on these Customer columns, so never claim you cannot store a contact person.\n"
|
|
733
735
|
"Supplier example: {\"master_type\":\"supplier\",\"data\":{\"supplier_name\":\"Schlafteq\",\"email\":\"jacob@schlafteq.ch\",\"phone\":\"+1 555-0104\",\"address\":\"145 Harbor Rd\",\"city\":\"Seattle\",\"zip_code\":\"98101\",\"country\":\"US\",\"tax_id\":\"98-7654321\"}}\n"
|
|
734
|
-
"Item example (custom code): {\"master_type\":\"item\",\"data\":{\"
|
|
736
|
+
"Item example (custom code): {\"master_type\":\"item\",\"data\":{\"name\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}\n"
|
|
735
737
|
"Customer example (with contact person): {\"master_type\":\"customer\",\"data\":{\"customer_name\":\"Foglio AG\",\"address\":\"Seeweg 12\",\"city\":\"Bramblewick\",\"zip_code\":\"9999\",\"contact_person\":\"Marlene Voss\",\"contact_phone\":\"079 123 45 67\"}}"
|
|
736
738
|
),
|
|
737
739
|
"parameters": {
|
|
@@ -749,7 +751,7 @@ TOOLS = [
|
|
|
749
751
|
"function": {
|
|
750
752
|
"name": "update_master",
|
|
751
753
|
"description": (
|
|
752
|
-
"Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
|
|
754
|
+
"Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, website, email, phone, address, city, zip_code, country, tax_id, contact_person, contact_email, contact_phone, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
|
|
753
755
|
),
|
|
754
756
|
"parameters": {
|
|
755
757
|
"type": "object",
|
|
@@ -1022,8 +1024,9 @@ TOOLS = [
|
|
|
1022
1024
|
"`measures` is an object of `{alias: [op, field]}` where `op` "
|
|
1023
1025
|
"is one of sum, count, avg, min, max (count may omit field). "
|
|
1024
1026
|
"`filters` is a dict keyed by field name — same shape as for "
|
|
1025
|
-
"`create_custom_analytics_report` (equality, list for IN, "
|
|
1026
|
-
"`{from, to}` for ranges).
|
|
1027
|
+
"`create_custom_analytics_report` (equality, [\"in\", [values]] or a flat list for IN, "
|
|
1028
|
+
"[\"not in\", [values]], `{from, to}` for ranges). Invalid filter shapes "
|
|
1029
|
+
"are errors, not evidence of zero stock or activity. `order_by` is a list of "
|
|
1027
1030
|
"`{field, direction}` where `field` is a group_by field or a "
|
|
1028
1031
|
"measure alias and `direction` is asc/desc.\n\n"
|
|
1029
1032
|
"Example — top 5 customers by revenue:\n"
|
|
@@ -1069,7 +1072,7 @@ TOOLS = [
|
|
|
1069
1072
|
"type": "object",
|
|
1070
1073
|
"description": "Map of alias -> [op, field]. op ∈ sum|count|avg|min|max.",
|
|
1071
1074
|
},
|
|
1072
|
-
"filters": {"type": "object"},
|
|
1075
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1073
1076
|
"order_by": {
|
|
1074
1077
|
"type": "array",
|
|
1075
1078
|
"items": {
|
|
@@ -1146,7 +1149,7 @@ TOOLS = [
|
|
|
1146
1149
|
],
|
|
1147
1150
|
},
|
|
1148
1151
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
1149
|
-
"filters": {"type": "object"},
|
|
1152
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1150
1153
|
"limit": {"type": "integer"},
|
|
1151
1154
|
},
|
|
1152
1155
|
"required": ["dataset"],
|
|
@@ -1232,7 +1235,7 @@ TOOLS = [
|
|
|
1232
1235
|
],
|
|
1233
1236
|
},
|
|
1234
1237
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
1235
|
-
"filters": {"type": "object"},
|
|
1238
|
+
"filters": {"type": "object", "description": DATASET_FILTER_DESCRIPTION},
|
|
1236
1239
|
"limit": {"type": "integer"},
|
|
1237
1240
|
},
|
|
1238
1241
|
"required": ["dataset"],
|
|
@@ -1459,6 +1462,7 @@ def _validate_filter_columns(doctype_slug, filters) -> str | None:
|
|
|
1459
1462
|
return None
|
|
1460
1463
|
|
|
1461
1464
|
|
|
1465
|
+
@with_record_view_urls("doctype")
|
|
1462
1466
|
def _handle_list_documents(args):
|
|
1463
1467
|
doctype = args["doctype"]
|
|
1464
1468
|
try:
|
|
@@ -1497,10 +1501,12 @@ def _handle_get_document_fields(args):
|
|
|
1497
1501
|
return services.document_field_metadata(args["doctype"])
|
|
1498
1502
|
|
|
1499
1503
|
|
|
1504
|
+
@with_record_view_urls("doctype")
|
|
1500
1505
|
def _handle_get_document(args):
|
|
1501
1506
|
return services.load_document(args["doctype"], args["name"])
|
|
1502
1507
|
|
|
1503
1508
|
|
|
1509
|
+
@with_record_view_urls("doctype")
|
|
1504
1510
|
def _handle_create_document(args):
|
|
1505
1511
|
data = args.get("data")
|
|
1506
1512
|
if not data:
|
|
@@ -1508,10 +1514,12 @@ def _handle_create_document(args):
|
|
|
1508
1514
|
return services.create_document(args["doctype"], data)
|
|
1509
1515
|
|
|
1510
1516
|
|
|
1517
|
+
@with_record_view_urls("doctype")
|
|
1511
1518
|
def _handle_update_document(args):
|
|
1512
1519
|
return services.update_document(args["doctype"], args["name"], args.get("data", {}))
|
|
1513
1520
|
|
|
1514
1521
|
|
|
1522
|
+
@with_record_view_urls("doctype")
|
|
1515
1523
|
def _handle_batch_update_documents(args):
|
|
1516
1524
|
try:
|
|
1517
1525
|
return services.batch_update_documents(args["doctype"], args.get("updates"))
|
|
@@ -1519,18 +1527,22 @@ def _handle_batch_update_documents(args):
|
|
|
1519
1527
|
return {"error": str(e)}
|
|
1520
1528
|
|
|
1521
1529
|
|
|
1530
|
+
@with_record_view_urls("doctype")
|
|
1522
1531
|
def _handle_submit_document(args):
|
|
1523
1532
|
return services.submit_document(args["doctype"], args["name"])
|
|
1524
1533
|
|
|
1525
1534
|
|
|
1535
|
+
@with_record_view_urls("doctype")
|
|
1526
1536
|
def _handle_cancel_document(args):
|
|
1527
1537
|
return services.cancel_document(args["doctype"], args["name"])
|
|
1528
1538
|
|
|
1529
1539
|
|
|
1540
|
+
@with_record_view_urls("doctype")
|
|
1530
1541
|
def _handle_discard_document(args):
|
|
1531
1542
|
return services.discard_document(args["doctype"], args["name"])
|
|
1532
1543
|
|
|
1533
1544
|
|
|
1545
|
+
@with_record_view_urls("target_doctype")
|
|
1534
1546
|
def _handle_convert_document(args):
|
|
1535
1547
|
return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
|
|
1536
1548
|
|
|
@@ -1593,6 +1605,7 @@ def _handle_get_master_fields(args):
|
|
|
1593
1605
|
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1594
1606
|
return {
|
|
1595
1607
|
"master_type": master_type,
|
|
1608
|
+
"field_aliases": services.master_field_aliases(master_type),
|
|
1596
1609
|
"time_filter_fields": time_filter_fields(db, doctype),
|
|
1597
1610
|
"fields": sorted(db._get_table_columns(doctype)),
|
|
1598
1611
|
"text_fields": sorted(db._get_text_columns(doctype)),
|
|
@@ -1603,10 +1616,11 @@ def _handle_get_master_fields(args):
|
|
|
1603
1616
|
"requirements": master_requirements(master_type),
|
|
1604
1617
|
"link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
|
|
1605
1618
|
"dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
|
|
1606
|
-
"input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | (
|
|
1619
|
+
"input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | set(services.master_field_aliases(master_type))),
|
|
1607
1620
|
}
|
|
1608
1621
|
|
|
1609
1622
|
|
|
1623
|
+
@with_record_view_urls("master_type", master=True)
|
|
1610
1624
|
def _handle_search_masters(args):
|
|
1611
1625
|
db = get_db()
|
|
1612
1626
|
meta = bool(args.get("include_meta")) or args.get("time_filter") is not None
|
|
@@ -1650,18 +1664,13 @@ def _handle_search_masters(args):
|
|
|
1650
1664
|
requested = args.get("fields") or []
|
|
1651
1665
|
if requested:
|
|
1652
1666
|
valid = columns
|
|
1653
|
-
|
|
1654
|
-
# everywhere else — create_master, document lines, the asset/reservation
|
|
1655
|
-
# prompt — so it naturally passes fields=["item_code"]. But the real
|
|
1656
|
-
# column is the `name` PK; resolve the alias to `name` so that lookup
|
|
1657
|
-
# works instead of erroring "None of fields ['item_code'] exist".
|
|
1658
|
-
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
1667
|
+
aliases = services.master_field_aliases(master_type)
|
|
1659
1668
|
search_cols = []
|
|
1660
1669
|
for c in requested:
|
|
1661
1670
|
if c in valid:
|
|
1662
1671
|
search_cols.append(c)
|
|
1663
|
-
elif
|
|
1664
|
-
search_cols.append(
|
|
1672
|
+
elif c in aliases:
|
|
1673
|
+
search_cols.append(aliases[c])
|
|
1665
1674
|
search_cols = sorted(set(search_cols))
|
|
1666
1675
|
# Nothing resolved (all names unknown) — degrade to the default text
|
|
1667
1676
|
# search rather than erroring: the value the caller meant is almost
|
|
@@ -1708,22 +1717,7 @@ def _handle_search_masters(args):
|
|
|
1708
1717
|
return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
|
|
1709
1718
|
|
|
1710
1719
|
|
|
1711
|
-
|
|
1712
|
-
"""Return field names in `data` that aren't valid columns on the master's table."""
|
|
1713
|
-
entry = services.MASTER_TABLES.get(master_type)
|
|
1714
|
-
if not entry:
|
|
1715
|
-
return []
|
|
1716
|
-
doctype, _ = entry
|
|
1717
|
-
from lambda_erp.database import get_db
|
|
1718
|
-
valid = set(get_db()._get_table_columns(doctype))
|
|
1719
|
-
# item_code is a recognized alias for the Item's `name` PK, not an unknown
|
|
1720
|
-
# field — don't warn that it was ignored when it was actually honored.
|
|
1721
|
-
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
1722
|
-
if alias:
|
|
1723
|
-
valid.add(alias)
|
|
1724
|
-
return [k for k in data.keys() if k not in valid]
|
|
1725
|
-
|
|
1726
|
-
|
|
1720
|
+
@with_record_view_urls("master_type", master=True)
|
|
1727
1721
|
def _handle_create_master(args):
|
|
1728
1722
|
master_type = args["master_type"]
|
|
1729
1723
|
data = args.get("data")
|
|
@@ -1733,23 +1727,16 @@ def _handle_create_master(args):
|
|
|
1733
1727
|
if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
|
|
1734
1728
|
data = {k: v for k, v in data.items() if k != "parent_warehouse"}
|
|
1735
1729
|
|
|
1736
|
-
ignored = _ignored_master_fields(master_type, data)
|
|
1737
|
-
|
|
1738
1730
|
try:
|
|
1739
1731
|
result = dict(create_master_record(master_type, data))
|
|
1740
1732
|
except Exception as exc:
|
|
1741
1733
|
detail = getattr(exc, "detail", None)
|
|
1742
1734
|
return {"error": detail or str(exc)}
|
|
1743
1735
|
|
|
1744
|
-
if ignored:
|
|
1745
|
-
result["_warning"] = (
|
|
1746
|
-
f"These fields were IGNORED because they are not valid columns on the {master_type}: "
|
|
1747
|
-
f"{ignored}. Call get_master_fields(master_type=\"{master_type}\") to see the real columns, "
|
|
1748
|
-
f"then retry mapping those values onto valid field names."
|
|
1749
|
-
)
|
|
1750
1736
|
return result
|
|
1751
1737
|
|
|
1752
1738
|
|
|
1739
|
+
@with_record_view_urls("master_type", master=True)
|
|
1753
1740
|
def _handle_update_master(args):
|
|
1754
1741
|
master_type = args["master_type"]
|
|
1755
1742
|
name = args.get("name")
|
|
@@ -1762,19 +1749,12 @@ def _handle_update_master(args):
|
|
|
1762
1749
|
if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
|
|
1763
1750
|
data = {k: v for k, v in data.items() if k != "parent_warehouse"}
|
|
1764
1751
|
|
|
1765
|
-
ignored = _ignored_master_fields(master_type, data)
|
|
1766
|
-
|
|
1767
1752
|
try:
|
|
1768
1753
|
result = dict(update_master_record(master_type, name, data))
|
|
1769
1754
|
except Exception as exc:
|
|
1770
1755
|
detail = getattr(exc, "detail", None)
|
|
1771
1756
|
return {"error": detail or str(exc)}
|
|
1772
1757
|
|
|
1773
|
-
if ignored:
|
|
1774
|
-
result["_warning"] = (
|
|
1775
|
-
f"These fields were IGNORED because they are not valid columns on the {master_type}: "
|
|
1776
|
-
f"{ignored}. Retry the call using the correct field names."
|
|
1777
|
-
)
|
|
1778
1758
|
return result
|
|
1779
1759
|
|
|
1780
1760
|
|
|
@@ -2130,7 +2110,7 @@ def _handle_query_dataset(args):
|
|
|
2130
2110
|
dataset=dataset,
|
|
2131
2111
|
group_by=args.get("group_by") or [],
|
|
2132
2112
|
measures=measures,
|
|
2133
|
-
filters=args.get("filters")
|
|
2113
|
+
filters=args.get("filters"),
|
|
2134
2114
|
order_by=args.get("order_by") or [],
|
|
2135
2115
|
limit=args.get("limit"),
|
|
2136
2116
|
)
|
|
@@ -2464,18 +2444,11 @@ Examples:
|
|
|
2464
2444
|
- Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
|
|
2465
2445
|
- Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)"""
|
|
2466
2446
|
document_links_section = """## Document & Master Links
|
|
2467
|
-
When
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
|
|
2473
|
-
|
|
2474
|
-
**Master records** (customer, supplier, item, warehouse, company):
|
|
2475
|
-
- **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
|
|
2476
|
-
- NEVER use `/app/...` for masters — that path is only for transactional documents.
|
|
2477
|
-
|
|
2478
|
-
Always include the view link after creating, submitting, converting, or updating a record. Call generate_document_pdf when a printable file is requested. Only confirm and link a successfully generated file."""
|
|
2447
|
+
Document and master tools return a canonical `view_url` on each record, including list/search rows and successful batch results. When linking a record, copy that exact value into a markdown link `[label](view_url)`. Never construct a record URL yourself, add a `#`, change the path, or re-encode the returned URL.
|
|
2448
|
+
`view_url` is tool output metadata, not a document/master field; never include it in create/update data.
|
|
2449
|
+
If `view_url` is null, refer to the record in plain text. For a projected document list or batch result missing parent-page information, call get_document to obtain its view_url if a link is needed. Some records have no page at all.
|
|
2450
|
+
Always include the returned view link, when available, after creating, submitting, converting, or updating a record.
|
|
2451
|
+
For PDF files, call generate_document_pdf and copy its returned download_url only after successful generation. Never construct PDF links yourself."""
|
|
2479
2452
|
|
|
2480
2453
|
if user_role == "admin":
|
|
2481
2454
|
role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
|
|
@@ -2832,8 +2805,8 @@ When creating a payment-entry, the `data` object MUST include:
|
|
|
2832
2805
|
|
|
2833
2806
|
## Master keys vs display names — CRITICAL
|
|
2834
2807
|
|
|
2835
|
-
Every master record has a primary key (the `name` column) and a human-readable display field:
|
|
2836
|
-
- **Item:** key = `
|
|
2808
|
+
Every master record has a primary key (the `name` column) and a human-readable display field. `get_master_fields` lists real columns separately from `field_aliases`. Prefer canonical `name` in new master calls and result projections; aliases remain for compatibility. Registered aliases work in master search fields, filters, sorting and result projection; requesting an alias returns it alongside `name`. Unknown result fields are errors:
|
|
2809
|
+
- **Item:** stored key = `name` (e.g. `SVC-005`); `item_code` is its registered alias and the item-reference field on document lines; display = `item_name` (e.g. "Project Management")
|
|
2837
2810
|
- **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
|
|
2838
2811
|
- **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
|
|
2839
2812
|
- **Warehouse / Company / Account / Cost Center:** key = `name`{extension_master_keys}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Canonical record links on chat/MCP tool results, never persisted fields."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
from api import services
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def record_view_url(record_type: str, record: dict, *, master: bool = False) -> str | None:
|
|
10
|
+
name = record.get("name")
|
|
11
|
+
if not name:
|
|
12
|
+
return None
|
|
13
|
+
if master:
|
|
14
|
+
if record_type not in services.MASTER_TABLES or record_type == "cost-center":
|
|
15
|
+
return None
|
|
16
|
+
if record_type == "account":
|
|
17
|
+
return "/reports/general-ledger?" + urlencode({"account": name})
|
|
18
|
+
return f"/masters/{quote(record_type, safe='')}/{quote(str(name), safe='')}"
|
|
19
|
+
|
|
20
|
+
slug = services.DOCTYPE_TO_SLUG.get(record_type, record_type)
|
|
21
|
+
if slug not in services.SLUG_TO_DOCTYPE:
|
|
22
|
+
return None
|
|
23
|
+
page = services.chat_doctype_page_info(slug)
|
|
24
|
+
if page:
|
|
25
|
+
if page["kind"] == "none":
|
|
26
|
+
return None
|
|
27
|
+
if page["kind"] == "via":
|
|
28
|
+
name = record.get(page["link_field"])
|
|
29
|
+
if not name:
|
|
30
|
+
# Projected list/batch results may omit the parent. The model
|
|
31
|
+
# can get_document to obtain it; do not guess a destination.
|
|
32
|
+
return None
|
|
33
|
+
slug = page["parent_slug"]
|
|
34
|
+
return f"/app/{quote(slug, safe='')}/{quote(str(name), safe='')}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def with_record_view_urls(type_arg: str, *, master: bool = False):
|
|
38
|
+
"""Enrich records and list/batch envelopes without changing service data."""
|
|
39
|
+
def decorate(handler):
|
|
40
|
+
@wraps(handler)
|
|
41
|
+
def wrapped(args):
|
|
42
|
+
result = handler(args)
|
|
43
|
+
|
|
44
|
+
def enrich(row):
|
|
45
|
+
if not isinstance(row, dict) or "error" in row or row.get("ok") is False:
|
|
46
|
+
return row
|
|
47
|
+
return {**row, "view_url": record_view_url(args.get(type_arg), row, master=master)}
|
|
48
|
+
|
|
49
|
+
if isinstance(result, list):
|
|
50
|
+
return [enrich(row) for row in result]
|
|
51
|
+
if isinstance(result, dict) and "error" not in result:
|
|
52
|
+
if "name" not in result:
|
|
53
|
+
for key in ("rows", "results"):
|
|
54
|
+
if isinstance(result.get(key), list):
|
|
55
|
+
return {**result, key: [enrich(row) for row in result[key]]}
|
|
56
|
+
return enrich(result)
|
|
57
|
+
return result
|
|
58
|
+
return wrapped
|
|
59
|
+
return decorate
|
|
@@ -9,7 +9,9 @@ This module now exposes two layers:
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import math
|
|
12
13
|
import uuid
|
|
14
|
+
from datetime import date
|
|
13
15
|
from typing import Any, Literal
|
|
14
16
|
|
|
15
17
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
@@ -308,11 +310,25 @@ def analytics(
|
|
|
308
310
|
# ---------------------------------------------------------------------------
|
|
309
311
|
|
|
310
312
|
|
|
313
|
+
DATASET_FILTER_DESCRIPTION = (
|
|
314
|
+
'Filters keyed by allowed dataset field. Scalar = equality. For membership prefer '
|
|
315
|
+
'{"item_code": ["in", ["ART-1", "ART-2"]]}; ["not in", [values]] is also supported. '
|
|
316
|
+
'Legacy flat lists remain literal IN lists (even values named "in"). '
|
|
317
|
+
'Lists contain at most 500 non-null scalar values; deeper nesting and other '
|
|
318
|
+
'operator forms are rejected. Empty IN lists match nothing; empty NOT IN lists '
|
|
319
|
+
'exclude nothing. Ranges use {"from": value, "to": value}, with either bound '
|
|
320
|
+
'optional; date fields require real YYYY-MM-DD dates and from <= to. '
|
|
321
|
+
'No other range keys are accepted. Omit unused filters; null/empty-string '
|
|
322
|
+
'filters and blank from/to bounds are ignored. Invalid filters return errors, '
|
|
323
|
+
'not zero counts; correct the request before making a factual claim.'
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
311
327
|
class RuntimeDataRequest(BaseModel):
|
|
312
328
|
name: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
313
329
|
dataset: str
|
|
314
330
|
fields: list[str] | None = Field(default=None, max_length=64)
|
|
315
|
-
filters: dict[str, Any] = Field(default_factory=dict, max_length=32)
|
|
331
|
+
filters: dict[str, Any] = Field(default_factory=dict, max_length=32, description=DATASET_FILTER_DESCRIPTION)
|
|
316
332
|
limit: int | None = None
|
|
317
333
|
|
|
318
334
|
|
|
@@ -438,6 +454,10 @@ class ReportDraftPayload(StrictReportModel):
|
|
|
438
454
|
raise ValueError(
|
|
439
455
|
f"Unknown fields for dataset '{request.dataset}': {', '.join(sorted(unknown))}"
|
|
440
456
|
)
|
|
457
|
+
try:
|
|
458
|
+
_build_dataset_filter_clauses(dataset, request.filters, [])
|
|
459
|
+
except HTTPException as exc:
|
|
460
|
+
raise ValueError(str(exc.detail)) from exc
|
|
441
461
|
request_fields[source] = fields
|
|
442
462
|
|
|
443
463
|
source_fields = dict(request_fields)
|
|
@@ -937,30 +957,80 @@ SEMANTIC_DATASETS: dict[str, dict[str, Any]] = {
|
|
|
937
957
|
|
|
938
958
|
|
|
939
959
|
def _build_dataset_filter_clauses(spec: dict[str, Any], filters: dict[str, Any], params: list[Any]) -> list[str]:
|
|
960
|
+
if filters is None:
|
|
961
|
+
filters = {}
|
|
962
|
+
if not isinstance(filters, dict):
|
|
963
|
+
raise HTTPException(400, "Dataset filters must be an object keyed by field name")
|
|
940
964
|
clauses = list(spec.get("default_where", []))
|
|
941
|
-
for field, value in
|
|
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
|