lambda-erp 1.0.1__tar.gz → 1.1.0__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.0.1 → lambda_erp-1.1.0}/PKG-INFO +4 -1
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/README.md +3 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/chat.py +71 -36
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_profiles.py +6 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/documents.py +20 -15
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/masters.py +40 -13
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/services.py +111 -147
- lambda_erp-1.1.0/api/time_filters.py +101 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/pos_invoice.py +42 -17
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/purchase_invoice.py +31 -15
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/sales_invoice.py +37 -22
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/buying/purchase_order.py +11 -15
- lambda_erp-1.1.0/lambda_erp/controllers/defaults.py +114 -0
- lambda_erp-1.1.0/lambda_erp/controllers/item_price.py +247 -0
- lambda_erp-1.1.0/lambda_erp/controllers/pricing_rule.py +273 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/database.py +293 -32
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/model.py +20 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/quotation.py +15 -15
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/sales_order.py +12 -16
- lambda_erp-1.1.0/lambda_erp/timestamps.py +85 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/pyproject.toml +1 -1
- lambda_erp-1.0.1/lambda_erp/controllers/defaults.py +0 -51
- lambda_erp-1.0.1/lambda_erp/controllers/pricing_rule.py +0 -112
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/.gitignore +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/LICENSE +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/attachments.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/auth.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/bootstrap.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/demo_limits.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/deps.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/errors.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/list_values.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/main.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/oauth.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_contract.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_exports.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/providers.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/remarks_md.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/accounting.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/actions.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/admin.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/analytics.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/availability.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/mcp.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/proposals.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/reports.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/setup.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/document.html +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/proposal.html +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/record.html +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/tool_permissions.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/docs/agents/README.md +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/LICENSE +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/README.md +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.0.1 → lambda_erp-1.1.0}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -303,6 +303,9 @@ docs/agents/ # Invariants, gotchas, design decisions (LLM-readable)
|
|
|
303
303
|
|
|
304
304
|
`docs/agents/` is worth a read if you're going to contribute - it captures the invariants the code assumes but doesn't always enforce, plus the landmines that have bitten us.
|
|
305
305
|
|
|
306
|
+
[Recent changes and outreach](docs/time-filters.md): shared time filters for REST,
|
|
307
|
+
chat and MCP lists, with exact windows, pagination and timestamp-quality metadata.
|
|
308
|
+
|
|
306
309
|
---
|
|
307
310
|
|
|
308
311
|
## Building a customer deployment on top of the core
|
|
@@ -275,6 +275,9 @@ docs/agents/ # Invariants, gotchas, design decisions (LLM-readable)
|
|
|
275
275
|
|
|
276
276
|
`docs/agents/` is worth a read if you're going to contribute - it captures the invariants the code assumes but doesn't always enforce, plus the landmines that have bitten us.
|
|
277
277
|
|
|
278
|
+
[Recent changes and outreach](docs/time-filters.md): shared time filters for REST,
|
|
279
|
+
chat and MCP lists, with exact windows, pagination and timestamp-quality metadata.
|
|
280
|
+
|
|
278
281
|
---
|
|
279
282
|
|
|
280
283
|
## Building a customer deployment on top of the core
|
|
@@ -26,6 +26,7 @@ from dotenv import load_dotenv
|
|
|
26
26
|
from fastapi import APIRouter, Depends as _Depends, HTTPException, WebSocket, WebSocketDisconnect
|
|
27
27
|
from openai import OpenAI
|
|
28
28
|
|
|
29
|
+
from api.time_filters import TIME_FILTER_SCHEMA, time_filter_fields
|
|
29
30
|
from api import services
|
|
30
31
|
from api.tool_permissions import tool_allowed, tool_permission_error
|
|
31
32
|
from api.demo_limits import (
|
|
@@ -474,13 +475,16 @@ TOOLS = [
|
|
|
474
475
|
"type": "function",
|
|
475
476
|
"function": {
|
|
476
477
|
"name": "list_documents",
|
|
477
|
-
"description": "List or search documents of a given type.
|
|
478
|
+
"description": "List or search documents of a given type. With time_filter or include_meta=true returns {rows,total,has_more,next_offset,time_filter,coverage,warnings}; otherwise returns an array of document summaries (header fields only — child tables like 'items' and 'taxes' are NOT included to keep results compact). Use get_document to drill into a single document's line items. Results default to creation DESC, or the parsed time_filter field DESC when supplied. Page past `limit` with `offset`; trim the payload to only the columns you need with `fields`.",
|
|
478
479
|
"parameters": {
|
|
479
480
|
"type": "object",
|
|
480
481
|
"properties": {
|
|
482
|
+
"time_filter": TIME_FILTER_SCHEMA,
|
|
483
|
+
"include_meta": {"type": "boolean", "description": "Use true for complete lists or overviews: returns exact total and pagination/coverage metadata."},
|
|
484
|
+
"include_discarded": {"type": "boolean", "description": "Include discarded drafts when checking recent changes; default false."},
|
|
481
485
|
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
|
|
482
|
-
"filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, contains, like, not like, is null, is not null. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
|
|
483
|
-
"order_by": {"type": "string", "description": "Optional column to sort by
|
|
486
|
+
"filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, in, not in, contains, like, not like, is null, is not null. IN example: {\"type\": [\"in\", [\"call\", \"email\", \"note\"]]}. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
|
|
487
|
+
"order_by": {"type": "string", "description": "Optional column to sort by. Defaults to the time_filter field when supplied, otherwise creation."},
|
|
484
488
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
|
|
485
489
|
"limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
|
|
486
490
|
"offset": {"type": "integer", "description": "Skip this many rows before returning — for paging past `limit`. Default 0.", "default": 0},
|
|
@@ -494,7 +498,7 @@ TOOLS = [
|
|
|
494
498
|
"type": "function",
|
|
495
499
|
"function": {
|
|
496
500
|
"name": "get_document_fields",
|
|
497
|
-
"description": "List
|
|
501
|
+
"description": "List available columns, text fields and supported time_filter_fields of a document type. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns required fields, conditional business rules, child requirements, dynamic links and supported transient inputs. Check these before creating or updating a document; unknown fields are rejected.",
|
|
498
502
|
"parameters": {
|
|
499
503
|
"type": "object",
|
|
500
504
|
"properties": {
|
|
@@ -663,10 +667,12 @@ TOOLS = [
|
|
|
663
667
|
"type": "function",
|
|
664
668
|
"function": {
|
|
665
669
|
"name": "search_masters",
|
|
666
|
-
"description": "Search and filter master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). `query` is a case-insensitive free-text lookup with fuzzy fallback for simple misspellings. Use `filters` for deterministic field-aware queries and for combining different values across fields.
|
|
670
|
+
"description": "Search and filter master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). `query` is a case-insensitive free-text lookup with fuzzy fallback for simple misspellings. Use `filters` for deterministic field-aware queries and for combining different values across fields. With time_filter or include_meta=true returns a page object with rows, total, pagination and coverage (default limit 50, max 500; no fuzzy fallback). Legacy simple lookups return all matches as an array unless limit is supplied.",
|
|
667
671
|
"parameters": {
|
|
668
672
|
"type": "object",
|
|
669
673
|
"properties": {
|
|
674
|
+
"time_filter": TIME_FILTER_SCHEMA,
|
|
675
|
+
"include_meta": {"type": "boolean", "description": "Use true for overviews: returns rows, exact total, pagination and coverage. Automatically enabled with time_filter."},
|
|
670
676
|
"master_type": {"type": "string", "enum": MASTER_TYPES},
|
|
671
677
|
"query": {"type": "string", "description": "Search term (empty string returns all)", "default": ""},
|
|
672
678
|
"fields": {
|
|
@@ -679,9 +685,9 @@ TOOLS = [
|
|
|
679
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.",
|
|
680
686
|
"default": {},
|
|
681
687
|
},
|
|
682
|
-
"order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to name."},
|
|
688
|
+
"order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to the time_filter field when supplied, otherwise name."},
|
|
683
689
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
|
|
684
|
-
"limit": {"type": "integer", "description": "
|
|
690
|
+
"limit": {"type": "integer", "description": "Maximum results. With time_filter/include_meta: default 50, range 1–500. Legacy simple lookups have no cap when omitted."},
|
|
685
691
|
"offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
|
|
686
692
|
"result_fields": {
|
|
687
693
|
"type": "array",
|
|
@@ -1454,39 +1460,28 @@ def _validate_filter_columns(doctype_slug, filters) -> str | None:
|
|
|
1454
1460
|
|
|
1455
1461
|
|
|
1456
1462
|
def _handle_list_documents(args):
|
|
1457
|
-
# List views don't need child tables — strip them so the LLM can see more rows
|
|
1458
|
-
# within the tool-result budget. Use get_document to drill into one doc.
|
|
1459
1463
|
doctype = args["doctype"]
|
|
1460
|
-
filters = args.get("filters") or {}
|
|
1461
|
-
err = _validate_filter_columns(doctype, filters)
|
|
1462
|
-
if err:
|
|
1463
|
-
return {"error": err}
|
|
1464
|
-
order_by = args.get("order_by")
|
|
1465
|
-
cols = services.document_columns(doctype)
|
|
1466
|
-
if order_by and cols and order_by not in cols:
|
|
1467
|
-
return {"error": f"Unknown order_by column for {doctype}: {order_by}"}
|
|
1468
|
-
|
|
1469
|
-
cls_entry = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(doctype, ""))
|
|
1470
|
-
child_keys = list(cls_entry.CHILD_TABLES.keys()) if cls_entry and cls_entry.CHILD_TABLES else []
|
|
1471
|
-
|
|
1472
|
-
fields = args.get("fields")
|
|
1473
1464
|
try:
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
order_by=order_by,
|
|
1480
|
-
order=args.get("order", "desc"),
|
|
1481
|
-
fields=fields,
|
|
1465
|
+
kwargs = dict(
|
|
1466
|
+
filters=args.get("filters") or {}, limit=args.get("limit", 20),
|
|
1467
|
+
offset=args.get("offset", 0) or 0, order_by=args.get("order_by"),
|
|
1468
|
+
order=args.get("order", "desc"), fields=args.get("fields"),
|
|
1469
|
+
include_discarded=bool(args.get("include_discarded")),
|
|
1482
1470
|
)
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1471
|
+
if isinstance(kwargs["limit"], bool) or not isinstance(kwargs["limit"], int) or not 1 <= kwargs["limit"] <= 500:
|
|
1472
|
+
raise ValueError("limit must be an integer between 1 and 500")
|
|
1473
|
+
if not isinstance(kwargs["offset"], int) or isinstance(kwargs["offset"], bool) or kwargs["offset"] < 0:
|
|
1474
|
+
raise ValueError("offset must be a non-negative integer")
|
|
1475
|
+
meta = args.get("include_meta") or args.get("time_filter") is not None
|
|
1476
|
+
result = services.list_document_page(doctype, time_filter=args.get("time_filter"), **kwargs) if meta else services.list_documents(doctype, **kwargs)
|
|
1477
|
+
rows = result["rows"] if meta else result
|
|
1478
|
+
cls = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(doctype, ""))
|
|
1486
1479
|
for row in rows:
|
|
1487
|
-
for key in
|
|
1480
|
+
for key in (cls.CHILD_TABLES if cls else {}):
|
|
1488
1481
|
row.pop(key, None)
|
|
1489
|
-
|
|
1482
|
+
return result
|
|
1483
|
+
except (TypeError, ValueError) as exc:
|
|
1484
|
+
return {"error": str(exc)}
|
|
1490
1485
|
|
|
1491
1486
|
|
|
1492
1487
|
def _handle_generate_document_pdf(args, user=None):
|
|
@@ -1598,6 +1593,7 @@ def _handle_get_master_fields(args):
|
|
|
1598
1593
|
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1599
1594
|
return {
|
|
1600
1595
|
"master_type": master_type,
|
|
1596
|
+
"time_filter_fields": time_filter_fields(db, doctype),
|
|
1601
1597
|
"fields": sorted(db._get_table_columns(doctype)),
|
|
1602
1598
|
"text_fields": sorted(db._get_text_columns(doctype)),
|
|
1603
1599
|
# What search_masters searches when `fields` is omitted.
|
|
@@ -1613,6 +1609,7 @@ def _handle_get_master_fields(args):
|
|
|
1613
1609
|
|
|
1614
1610
|
def _handle_search_masters(args):
|
|
1615
1611
|
db = get_db()
|
|
1612
|
+
meta = bool(args.get("include_meta")) or args.get("time_filter") is not None
|
|
1616
1613
|
master_type = args["master_type"]
|
|
1617
1614
|
query = (args.get("query") or "").strip()
|
|
1618
1615
|
|
|
@@ -1626,6 +1623,13 @@ def _handle_search_masters(args):
|
|
|
1626
1623
|
limit = max(1, int(_raw_limit))
|
|
1627
1624
|
except (TypeError, ValueError):
|
|
1628
1625
|
limit = None
|
|
1626
|
+
if meta:
|
|
1627
|
+
limit = args.get("limit", 50)
|
|
1628
|
+
offset = args.get("offset", 0)
|
|
1629
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
|
|
1630
|
+
return {"error": "limit must be an integer between 1 and 500"}
|
|
1631
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1632
|
+
return {"error": "offset must be a non-negative integer"}
|
|
1629
1633
|
|
|
1630
1634
|
entry = services.MASTER_TABLES.get(master_type)
|
|
1631
1635
|
if not entry:
|
|
@@ -1685,11 +1689,14 @@ def _handle_search_masters(args):
|
|
|
1685
1689
|
limit=limit,
|
|
1686
1690
|
offset=offset,
|
|
1687
1691
|
fields=result_fields,
|
|
1688
|
-
with_total=
|
|
1692
|
+
with_total=meta,
|
|
1693
|
+
time_filter=args.get("time_filter"),
|
|
1689
1694
|
)
|
|
1690
1695
|
except (TypeError, ValueError) as exc:
|
|
1691
1696
|
return {"error": str(exc)}
|
|
1692
1697
|
rows = [dict(row) for row in result["rows"]]
|
|
1698
|
+
if meta:
|
|
1699
|
+
return result
|
|
1693
1700
|
if rows or not query:
|
|
1694
1701
|
return rows
|
|
1695
1702
|
|
|
@@ -2516,6 +2523,34 @@ Only state that something was done — created, changed, enabled/disabled, booke
|
|
|
2516
2523
|
|
|
2517
2524
|
**Path 1: single-record lookup → `list_documents` / `get_document`.** For "is SINV-0042 paid", "what did customer X order last", "show me the latest 5 purchase orders" — fetch the rows directly. Don't try to aggregate in your head unless there are only a handful of rows in front of you.
|
|
2518
2525
|
|
|
2526
|
+
**Recent changes and outreach:** For "last N hours" use `list_documents` or
|
|
2527
|
+
`search_masters` with `time_filter={{"field":"modified","last_hours":24}}`
|
|
2528
|
+
(or the requested hours), so the server resolves the exact window. Use `creation`
|
|
2529
|
+
for newly created records, `modified` for changed records, `occurred_at` for CRM
|
|
2530
|
+
interactions. Never substitute yesterday at midnight for the last 24 hours. Reuse
|
|
2531
|
+
returned `time_filter` since/until for subsequent pages and other types; for today
|
|
2532
|
+
or yesterday resolve local calendar boundaries using the user's known timezone
|
|
2533
|
+
(and get_current_time), asking if the timezone is unknown and affects the answer.
|
|
2534
|
+
For "what happened in this ERP", cover relevant documents, masters and registered
|
|
2535
|
+
CRM activities; query changes as well as creations. Do not claim no changes from
|
|
2536
|
+
a creation-only search. Describe which areas were checked; this is a current-record
|
|
2537
|
+
overview, not a complete audit trail of deletions, old field values or every edit.
|
|
2538
|
+
For outreach include relevant `note` entries describing draft preparation and
|
|
2539
|
+
scheduled sends, as well as calls/emails/meetings; a call/email-only filter misses
|
|
2540
|
+
planned outreach. Preserve distinctions between prepared, scheduled, sent and
|
|
2541
|
+
replied, and between interaction time and planned future send time. Load linked
|
|
2542
|
+
leads only to enrich the matching activities; unfiltered old lead state is not
|
|
2543
|
+
proof of activity during the period. Use supported IN filters to select multiple
|
|
2544
|
+
types; if a filter fails, fix it without silently dropping its restriction.
|
|
2545
|
+
For overviews use time_filter or include_meta=true, inspect total/has_more, and
|
|
2546
|
+
page when exhaustive detail is requested. Never count only the first page as the
|
|
2547
|
+
total. Mention coverage warnings: missing/invalid timestamps mean unknown history,
|
|
2548
|
+
not no activity. Unsupported fields/query errors are not empty results. Do not
|
|
2549
|
+
infer who changed what from modified alone; current fields are not before/after
|
|
2550
|
+
history. For core masters predating timestamp capture, creation can be unknown. Include
|
|
2551
|
+
disabled/discarded records when the overview requires them. Low-level SQL and some
|
|
2552
|
+
indirect document updates may not maintain modified; do not promise all changes.
|
|
2553
|
+
|
|
2519
2554
|
**Path 2: aggregated facts → `query_dataset`.** For "who is our top customer by revenue", "total sales this month", "outstanding AR by customer", "average invoice size", "count of POs per supplier" — anything that requires summing, counting, ranking, or grouping across many rows — call `query_dataset`. It runs a deterministic SQL aggregation server-side and returns the actual aggregated numbers you can cite in chat. NEVER try to compute a top-N or sum by eyeballing a `list_documents` sample — it defaults to 20 rows and will give a wrong answer on any meaningful dataset.
|
|
2520
2555
|
|
|
2521
2556
|
**Path 3: charts / complex reports → `create_custom_analytics_report`.** Only call this when the user **explicitly asks for** a chart, graph, visualization, dashboard, trend, pivot, breakdown, or saved report — or when the analysis genuinely needs a reusable report. Do **not** invoke it for a factual question `query_dataset` could answer in one call.
|
|
@@ -88,6 +88,12 @@ PROFILES.update({
|
|
|
88
88
|
tables=(PDFTable('monthly_distribution', ('month', 'percentage'), ('month', 'percentage')),)),
|
|
89
89
|
'Pricing Rule': PDFProfile('record', 'Pricing rule overview',
|
|
90
90
|
fields=('title', 'company', 'item_code', 'selling', 'buying', 'rate_or_discount', 'rate', 'discount_percentage', 'discount_amount', 'min_qty', 'valid_from', 'valid_upto', 'priority', 'enabled'), required=('title', 'rate_or_discount')),
|
|
91
|
+
'Price List': PDFProfile('record', 'Price list overview',
|
|
92
|
+
fields=('price_list_name', 'currency', 'selling', 'buying', 'enabled'),
|
|
93
|
+
required=('price_list_name', 'currency')),
|
|
94
|
+
'Item Price': PDFProfile('record', 'Item price overview',
|
|
95
|
+
fields=('item_code', 'price_list', 'customer', 'supplier', 'uom', 'min_qty', 'rate', 'valid_from', 'valid_upto', 'enabled'),
|
|
96
|
+
required=('item_code', 'price_list', 'rate')),
|
|
91
97
|
'Bank Account': PDFProfile('record', 'Bank account overview',
|
|
92
98
|
fields=('account_name', 'company', 'account', 'iban', 'currency', 'bank_name', 'bic', 'disabled'), required=('account_name', 'company', 'account', 'iban', 'currency')),
|
|
93
99
|
'Bank Transaction': PDFProfile('record', 'Bank transaction overview',
|
|
@@ -11,6 +11,7 @@ from api.services import (
|
|
|
11
11
|
discard_document,
|
|
12
12
|
convert_document,
|
|
13
13
|
list_documents,
|
|
14
|
+
list_document_page,
|
|
14
15
|
count_documents,
|
|
15
16
|
document_columns,
|
|
16
17
|
adjacent_documents,
|
|
@@ -21,6 +22,7 @@ from api.pdf import generate_pdf
|
|
|
21
22
|
from api.pdf_exports import create_pdf_export, get_pdf_export, pdf_content_disposition
|
|
22
23
|
from api.auth import require_role
|
|
23
24
|
from api.list_values import distinct_list_values
|
|
25
|
+
from api.time_filters import request_time_filter
|
|
24
26
|
from lambda_erp.database import get_db
|
|
25
27
|
|
|
26
28
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
|
@@ -34,7 +36,7 @@ _manager = Depends(require_role("manager"))
|
|
|
34
36
|
_LIST_RESERVED = {
|
|
35
37
|
"status", "party", "from_date", "to_date", "docstatus",
|
|
36
38
|
"include_discarded", "limit", "offset", "order_by", "order",
|
|
37
|
-
"date_field", "search", "search_fields", "fields",
|
|
39
|
+
"date_field", "search", "search_fields", "fields", "time_filter",
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
|
|
@@ -60,7 +62,8 @@ def list_docs(
|
|
|
60
62
|
search: str | None = None,
|
|
61
63
|
search_fields: str | None = None,
|
|
62
64
|
fields: str | None = None,
|
|
63
|
-
|
|
65
|
+
time_filter: str | None = None,
|
|
66
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
64
67
|
offset: int = Query(default=0, ge=0),
|
|
65
68
|
_user: dict = _viewer,
|
|
66
69
|
):
|
|
@@ -130,17 +133,15 @@ def list_docs(
|
|
|
130
133
|
if fields:
|
|
131
134
|
projection = [f.strip() for f in fields.split(",") if f.strip() and f.strip() in columns]
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"text_fields": sorted(get_db()._get_text_columns(doctype)),
|
|
143
|
-
}
|
|
136
|
+
try:
|
|
137
|
+
result = list_document_page(
|
|
138
|
+
doctype_slug, filters=filters, limit=limit, offset=offset,
|
|
139
|
+
include_discarded=include_discarded, order_by=order_by, order=order,
|
|
140
|
+
fields=projection, time_filter=request_time_filter(request),
|
|
141
|
+
)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
144
|
+
return {**result, "text_fields": sorted(get_db()._get_text_columns(doctype))}
|
|
144
145
|
|
|
145
146
|
|
|
146
147
|
@router.get("/{doctype_slug}/{name}/adjacent")
|
|
@@ -204,8 +205,12 @@ def adjacent_doc(
|
|
|
204
205
|
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
|
|
205
206
|
if order.lower() not in ("asc", "desc"):
|
|
206
207
|
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
try:
|
|
209
|
+
return adjacent_documents(doctype_slug, name, filters=filters,
|
|
210
|
+
include_discarded=include_discarded, order_by=order_by, order=order,
|
|
211
|
+
time_filter=request_time_filter(request))
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
209
214
|
|
|
210
215
|
|
|
211
216
|
@router.get("/{doctype_slug}/search")
|
|
@@ -18,6 +18,7 @@ from api.services import (
|
|
|
18
18
|
)
|
|
19
19
|
from api.auth import require_role, require_non_public_manager
|
|
20
20
|
from api.list_values import distinct_list_values
|
|
21
|
+
from api.time_filters import request_time_filter, resolve_time_filter, time_predicate
|
|
21
22
|
from lambda_erp.exceptions import ValidationError
|
|
22
23
|
from lambda_erp.validation import missing
|
|
23
24
|
|
|
@@ -66,6 +67,8 @@ DELETE_REFERENCE_CHECKS = {
|
|
|
66
67
|
('SELECT 1 FROM "POS Invoice" WHERE customer = ? LIMIT 1', "POS invoice"),
|
|
67
68
|
('SELECT 1 FROM "Payment Entry" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "payment entry"),
|
|
68
69
|
('SELECT 1 FROM "Subscription" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "subscription"),
|
|
70
|
+
('SELECT 1 FROM "Item Price" WHERE customer = ? LIMIT 1', "item price"),
|
|
71
|
+
('SELECT 1 FROM "Pricing Rule" WHERE customer = ? LIMIT 1', "pricing rule"),
|
|
69
72
|
],
|
|
70
73
|
"supplier": [
|
|
71
74
|
('SELECT 1 FROM "Purchase Order" WHERE supplier = ? LIMIT 1', "purchase order"),
|
|
@@ -87,6 +90,7 @@ DELETE_REFERENCE_CHECKS = {
|
|
|
87
90
|
('SELECT 1 FROM "Stock Ledger Entry" WHERE item_code = ? LIMIT 1', "stock ledger entry"),
|
|
88
91
|
('SELECT 1 FROM "Bin" WHERE item_code = ? LIMIT 1', "bin"),
|
|
89
92
|
('SELECT 1 FROM "Pricing Rule" WHERE item_code = ? LIMIT 1', "pricing rule"),
|
|
93
|
+
('SELECT 1 FROM "Item Price" WHERE item_code = ? LIMIT 1', "item price"),
|
|
90
94
|
('SELECT 1 FROM "Subscription Plan" WHERE item_code = ? LIMIT 1', "subscription plan"),
|
|
91
95
|
('SELECT 1 FROM "Asset" WHERE item_code = ? LIMIT 1', "asset"),
|
|
92
96
|
('SELECT 1 FROM "Reservation" WHERE item_code = ? LIMIT 1', "reservation"),
|
|
@@ -212,6 +216,8 @@ def _normalize_master_data(data: dict) -> dict:
|
|
|
212
216
|
|
|
213
217
|
|
|
214
218
|
MASTER_LINK_FIELDS = {
|
|
219
|
+
'customer': {'default_price_list': 'Price List'},
|
|
220
|
+
'company': {'default_price_list': 'Price List'},
|
|
215
221
|
'item': {'default_warehouse': 'Warehouse'},
|
|
216
222
|
'warehouse': {'company': 'Company', 'account': 'Account', 'parent_warehouse': 'Warehouse'},
|
|
217
223
|
'account': {'company': 'Company', 'parent_account': 'Account'},
|
|
@@ -303,6 +309,8 @@ def _create_master_record(master_type: str, data: dict) -> dict:
|
|
|
303
309
|
|
|
304
310
|
db = get_db()
|
|
305
311
|
doc = _normalize_master_data(data)
|
|
312
|
+
doc.pop("creation", None)
|
|
313
|
+
doc.pop("modified", None)
|
|
306
314
|
|
|
307
315
|
# Let callers set the code under its intuitive alias (item_code -> name).
|
|
308
316
|
# An explicit `name` still wins; the alias key is consumed either way so it
|
|
@@ -372,6 +380,8 @@ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
372
380
|
|
|
373
381
|
db = get_db()
|
|
374
382
|
normalized = _normalize_master_data(data)
|
|
383
|
+
normalized.pop("creation", None)
|
|
384
|
+
normalized.pop("modified", None)
|
|
375
385
|
|
|
376
386
|
# The alias (item_code) is the identity, not a mutable column. Allow it as a
|
|
377
387
|
# no-op when it matches the record being updated, but reject a rename — the
|
|
@@ -422,7 +432,7 @@ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
422
432
|
# suffix used by Smart Search.
|
|
423
433
|
_MASTER_LIST_RESERVED = {
|
|
424
434
|
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
425
|
-
"order_by", "order",
|
|
435
|
+
"order_by", "order", "time_filter",
|
|
426
436
|
}
|
|
427
437
|
|
|
428
438
|
|
|
@@ -471,6 +481,16 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
|
|
|
471
481
|
where_parts.append(clause)
|
|
472
482
|
params += sp
|
|
473
483
|
|
|
484
|
+
try:
|
|
485
|
+
window = resolve_time_filter(db, doctype, request_time_filter(request))
|
|
486
|
+
if window:
|
|
487
|
+
if window["field"] in _master_request_filters(db, doctype, request):
|
|
488
|
+
raise ValueError("Do not combine time_filter with a filter on the same timestamp field")
|
|
489
|
+
clause, bounds = time_predicate(window)
|
|
490
|
+
where_parts.append(clause)
|
|
491
|
+
params.extend(bounds)
|
|
492
|
+
except ValueError as exc:
|
|
493
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
474
494
|
return where_parts, params, columns
|
|
475
495
|
|
|
476
496
|
|
|
@@ -503,14 +523,15 @@ def _master_order(columns: set, order_by: str | None, order: str) -> tuple[str,
|
|
|
503
523
|
return order_by or "name", direction if order_by else "asc"
|
|
504
524
|
|
|
505
525
|
|
|
506
|
-
def _master_order_sql(column: str, direction: str, *, reverse: bool = False) -> str:
|
|
526
|
+
def _master_order_sql(column: str, direction: str, *, reverse: bool = False, expression: str | None = None) -> str:
|
|
507
527
|
"""Deterministic master ordering with NULL values consistently at the end."""
|
|
508
528
|
effective = direction
|
|
509
529
|
nulls = "LAST"
|
|
510
530
|
if reverse:
|
|
511
531
|
effective = "desc" if direction == "asc" else "asc"
|
|
512
532
|
nulls = "FIRST"
|
|
513
|
-
|
|
533
|
+
expression = expression or f'"{column}"'
|
|
534
|
+
sql = f'{expression} {effective.upper()}'
|
|
514
535
|
if column != "name":
|
|
515
536
|
sql += f" NULLS {nulls}, name {effective.upper()}"
|
|
516
537
|
return sql
|
|
@@ -541,12 +562,13 @@ def master_filter_values(
|
|
|
541
562
|
def list_masters(
|
|
542
563
|
master_type: str,
|
|
543
564
|
request: Request,
|
|
544
|
-
limit: int = Query(default=50, le=1000),
|
|
565
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
545
566
|
offset: int = Query(default=0, ge=0),
|
|
546
567
|
include_disabled: bool = False,
|
|
547
568
|
search: str | None = None,
|
|
548
569
|
search_fields: str | None = None,
|
|
549
570
|
fields: str | None = None,
|
|
571
|
+
time_filter: str | None = None,
|
|
550
572
|
order_by: str | None = None,
|
|
551
573
|
order: str = "asc",
|
|
552
574
|
_user: dict = _viewer,
|
|
@@ -572,6 +594,7 @@ def list_masters(
|
|
|
572
594
|
limit=limit,
|
|
573
595
|
offset=offset,
|
|
574
596
|
fields=requested_fields or None,
|
|
597
|
+
time_filter=request_time_filter(request),
|
|
575
598
|
)
|
|
576
599
|
except ValueError as exc:
|
|
577
600
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
@@ -623,10 +646,14 @@ def adjacent_master(
|
|
|
623
646
|
where_parts, params, columns = _master_list_where(
|
|
624
647
|
db, doctype, master_type, request, include_disabled, search, search_fields,
|
|
625
648
|
)
|
|
626
|
-
|
|
649
|
+
window = resolve_time_filter(db, doctype, request_time_filter(request))
|
|
650
|
+
sort_column, sort_direction = _master_order(columns, order_by or (window["field"] if window else None), order)
|
|
651
|
+
sort_expression = f'"{sort_column}"'
|
|
652
|
+
if window and sort_column == window["field"]:
|
|
653
|
+
sort_expression = f'erp_timestamp_epoch({sort_expression})'
|
|
627
654
|
base_where = (" AND ".join(where_parts)) if where_parts else "1 = 1"
|
|
628
655
|
current_rows = db.sql(
|
|
629
|
-
f'SELECT
|
|
656
|
+
f'SELECT {sort_expression} AS sort_value FROM "{doctype}" '
|
|
630
657
|
f"WHERE {base_where} AND name = ? LIMIT 1",
|
|
631
658
|
[*params, name],
|
|
632
659
|
)
|
|
@@ -647,26 +674,26 @@ def adjacent_master(
|
|
|
647
674
|
key_params = [name]
|
|
648
675
|
elif current_value is None:
|
|
649
676
|
if after:
|
|
650
|
-
keyset = f'
|
|
677
|
+
keyset = f'{sort_expression} IS NULL AND name {same_cmp} ?'
|
|
651
678
|
else:
|
|
652
679
|
keyset = (
|
|
653
|
-
f'(
|
|
654
|
-
f'(
|
|
680
|
+
f'({sort_expression} IS NOT NULL OR '
|
|
681
|
+
f'({sort_expression} IS NULL AND name {same_cmp} ?))'
|
|
655
682
|
)
|
|
656
683
|
key_params = [name]
|
|
657
684
|
else:
|
|
658
685
|
keyset = (
|
|
659
|
-
f'(
|
|
660
|
-
f'(
|
|
686
|
+
f'({sort_expression} {primary_cmp} ? OR '
|
|
687
|
+
f'({sort_expression} = ? AND name {same_cmp} ?)'
|
|
661
688
|
)
|
|
662
689
|
key_params = [current_value, current_value, name]
|
|
663
690
|
if after:
|
|
664
|
-
keyset += f' OR
|
|
691
|
+
keyset += f' OR {sort_expression} IS NULL'
|
|
665
692
|
keyset += ")"
|
|
666
693
|
|
|
667
694
|
rows = db.sql(
|
|
668
695
|
f'SELECT name FROM "{doctype}" WHERE {base_where} AND {keyset} '
|
|
669
|
-
f'ORDER BY {_master_order_sql(sort_column, sort_direction, reverse=not after)} LIMIT 1',
|
|
696
|
+
f'ORDER BY {_master_order_sql(sort_column, sort_direction, reverse=not after, expression=sort_expression)} LIMIT 1',
|
|
670
697
|
[*params, *key_params],
|
|
671
698
|
)
|
|
672
699
|
return rows[0]["name"] if rows else None
|