lambda-erp 1.0.0__tar.gz → 1.0.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.0.0 → lambda_erp-1.0.2}/PKG-INFO +4 -1
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/README.md +3 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/chat.py +71 -36
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/documents.py +20 -15
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/masters.py +49 -13
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/services.py +108 -147
- lambda_erp-1.0.2/api/time_filters.py +101 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/database.py +67 -20
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/model.py +20 -0
- lambda_erp-1.0.2/lambda_erp/timestamps.py +85 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/pyproject.toml +1 -1
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/.gitignore +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/LICENSE +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/attachments.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/auth.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/bootstrap.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/demo_limits.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/deps.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/errors.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/list_values.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/main.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/oauth.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/pdf.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/pdf_contract.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/pdf_exports.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/providers.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/remarks_md.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/accounting.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/actions.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/admin.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/analytics.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/availability.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/mcp.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/proposals.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/reports.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/routers/setup.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/templates/document.html +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/templates/proposal.html +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/templates/record.html +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/api/tool_permissions.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/docs/agents/README.md +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/frontend/LICENSE +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/frontend/README.md +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.0.0 → lambda_erp-1.0.2}/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.0.2
|
|
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.
|
|
@@ -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
|
|
|
@@ -275,6 +276,20 @@ def _validate_master_input(master_type, doctype, data, name=None):
|
|
|
275
276
|
for field, table in MASTER_LINK_FIELDS.get(master_type, {}).items():
|
|
276
277
|
if merged.get(field) and not db.exists(table, merged[field]):
|
|
277
278
|
raise ValidationError(f'{doctype}: {field} must reference an existing {table}')
|
|
279
|
+
if master_type == 'company' and data.get('default_sales_tax_template'):
|
|
280
|
+
template = db.get_value('Tax Template', data['default_sales_tax_template'], ['company', 'tax_type'])
|
|
281
|
+
if not template or template['company'] != (name or data.get('name')) or template['tax_type'] != 'Sales':
|
|
282
|
+
raise ValidationError('Default sales tax template must be a Sales template belonging to this Company')
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@router.get('/company/{name}/sales-tax-templates')
|
|
286
|
+
def company_sales_tax_templates(name: str, _user: dict = _viewer):
|
|
287
|
+
"""Choices for the company default, using existing tax templates only."""
|
|
288
|
+
db = get_db()
|
|
289
|
+
if not db.exists('Company', name):
|
|
290
|
+
raise HTTPException(status_code=404, detail='Company not found')
|
|
291
|
+
return db.get_all('Tax Template', filters={'company': name, 'tax_type': 'Sales'},
|
|
292
|
+
fields=['name', 'title'], order_by='title, name')
|
|
278
293
|
|
|
279
294
|
|
|
280
295
|
def create_master_record(master_type: str, data: dict) -> dict:
|
|
@@ -289,6 +304,8 @@ def _create_master_record(master_type: str, data: dict) -> dict:
|
|
|
289
304
|
|
|
290
305
|
db = get_db()
|
|
291
306
|
doc = _normalize_master_data(data)
|
|
307
|
+
doc.pop("creation", None)
|
|
308
|
+
doc.pop("modified", None)
|
|
292
309
|
|
|
293
310
|
# Let callers set the code under its intuitive alias (item_code -> name).
|
|
294
311
|
# An explicit `name` still wins; the alias key is consumed either way so it
|
|
@@ -358,6 +375,8 @@ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
358
375
|
|
|
359
376
|
db = get_db()
|
|
360
377
|
normalized = _normalize_master_data(data)
|
|
378
|
+
normalized.pop("creation", None)
|
|
379
|
+
normalized.pop("modified", None)
|
|
361
380
|
|
|
362
381
|
# The alias (item_code) is the identity, not a mutable column. Allow it as a
|
|
363
382
|
# no-op when it matches the record being updated, but reject a rename — the
|
|
@@ -408,7 +427,7 @@ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
408
427
|
# suffix used by Smart Search.
|
|
409
428
|
_MASTER_LIST_RESERVED = {
|
|
410
429
|
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
411
|
-
"order_by", "order",
|
|
430
|
+
"order_by", "order", "time_filter",
|
|
412
431
|
}
|
|
413
432
|
|
|
414
433
|
|
|
@@ -457,6 +476,16 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
|
|
|
457
476
|
where_parts.append(clause)
|
|
458
477
|
params += sp
|
|
459
478
|
|
|
479
|
+
try:
|
|
480
|
+
window = resolve_time_filter(db, doctype, request_time_filter(request))
|
|
481
|
+
if window:
|
|
482
|
+
if window["field"] in _master_request_filters(db, doctype, request):
|
|
483
|
+
raise ValueError("Do not combine time_filter with a filter on the same timestamp field")
|
|
484
|
+
clause, bounds = time_predicate(window)
|
|
485
|
+
where_parts.append(clause)
|
|
486
|
+
params.extend(bounds)
|
|
487
|
+
except ValueError as exc:
|
|
488
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
460
489
|
return where_parts, params, columns
|
|
461
490
|
|
|
462
491
|
|
|
@@ -489,14 +518,15 @@ def _master_order(columns: set, order_by: str | None, order: str) -> tuple[str,
|
|
|
489
518
|
return order_by or "name", direction if order_by else "asc"
|
|
490
519
|
|
|
491
520
|
|
|
492
|
-
def _master_order_sql(column: str, direction: str, *, reverse: bool = False) -> str:
|
|
521
|
+
def _master_order_sql(column: str, direction: str, *, reverse: bool = False, expression: str | None = None) -> str:
|
|
493
522
|
"""Deterministic master ordering with NULL values consistently at the end."""
|
|
494
523
|
effective = direction
|
|
495
524
|
nulls = "LAST"
|
|
496
525
|
if reverse:
|
|
497
526
|
effective = "desc" if direction == "asc" else "asc"
|
|
498
527
|
nulls = "FIRST"
|
|
499
|
-
|
|
528
|
+
expression = expression or f'"{column}"'
|
|
529
|
+
sql = f'{expression} {effective.upper()}'
|
|
500
530
|
if column != "name":
|
|
501
531
|
sql += f" NULLS {nulls}, name {effective.upper()}"
|
|
502
532
|
return sql
|
|
@@ -527,12 +557,13 @@ def master_filter_values(
|
|
|
527
557
|
def list_masters(
|
|
528
558
|
master_type: str,
|
|
529
559
|
request: Request,
|
|
530
|
-
limit: int = Query(default=50, le=1000),
|
|
560
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
531
561
|
offset: int = Query(default=0, ge=0),
|
|
532
562
|
include_disabled: bool = False,
|
|
533
563
|
search: str | None = None,
|
|
534
564
|
search_fields: str | None = None,
|
|
535
565
|
fields: str | None = None,
|
|
566
|
+
time_filter: str | None = None,
|
|
536
567
|
order_by: str | None = None,
|
|
537
568
|
order: str = "asc",
|
|
538
569
|
_user: dict = _viewer,
|
|
@@ -558,6 +589,7 @@ def list_masters(
|
|
|
558
589
|
limit=limit,
|
|
559
590
|
offset=offset,
|
|
560
591
|
fields=requested_fields or None,
|
|
592
|
+
time_filter=request_time_filter(request),
|
|
561
593
|
)
|
|
562
594
|
except ValueError as exc:
|
|
563
595
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
@@ -609,10 +641,14 @@ def adjacent_master(
|
|
|
609
641
|
where_parts, params, columns = _master_list_where(
|
|
610
642
|
db, doctype, master_type, request, include_disabled, search, search_fields,
|
|
611
643
|
)
|
|
612
|
-
|
|
644
|
+
window = resolve_time_filter(db, doctype, request_time_filter(request))
|
|
645
|
+
sort_column, sort_direction = _master_order(columns, order_by or (window["field"] if window else None), order)
|
|
646
|
+
sort_expression = f'"{sort_column}"'
|
|
647
|
+
if window and sort_column == window["field"]:
|
|
648
|
+
sort_expression = f'erp_timestamp_epoch({sort_expression})'
|
|
613
649
|
base_where = (" AND ".join(where_parts)) if where_parts else "1 = 1"
|
|
614
650
|
current_rows = db.sql(
|
|
615
|
-
f'SELECT
|
|
651
|
+
f'SELECT {sort_expression} AS sort_value FROM "{doctype}" '
|
|
616
652
|
f"WHERE {base_where} AND name = ? LIMIT 1",
|
|
617
653
|
[*params, name],
|
|
618
654
|
)
|
|
@@ -633,26 +669,26 @@ def adjacent_master(
|
|
|
633
669
|
key_params = [name]
|
|
634
670
|
elif current_value is None:
|
|
635
671
|
if after:
|
|
636
|
-
keyset = f'
|
|
672
|
+
keyset = f'{sort_expression} IS NULL AND name {same_cmp} ?'
|
|
637
673
|
else:
|
|
638
674
|
keyset = (
|
|
639
|
-
f'(
|
|
640
|
-
f'(
|
|
675
|
+
f'({sort_expression} IS NOT NULL OR '
|
|
676
|
+
f'({sort_expression} IS NULL AND name {same_cmp} ?))'
|
|
641
677
|
)
|
|
642
678
|
key_params = [name]
|
|
643
679
|
else:
|
|
644
680
|
keyset = (
|
|
645
|
-
f'(
|
|
646
|
-
f'(
|
|
681
|
+
f'({sort_expression} {primary_cmp} ? OR '
|
|
682
|
+
f'({sort_expression} = ? AND name {same_cmp} ?)'
|
|
647
683
|
)
|
|
648
684
|
key_params = [current_value, current_value, name]
|
|
649
685
|
if after:
|
|
650
|
-
keyset += f' OR
|
|
686
|
+
keyset += f' OR {sort_expression} IS NULL'
|
|
651
687
|
keyset += ")"
|
|
652
688
|
|
|
653
689
|
rows = db.sql(
|
|
654
690
|
f'SELECT name FROM "{doctype}" WHERE {base_where} AND {keyset} '
|
|
655
|
-
f'ORDER BY {_master_order_sql(sort_column, sort_direction, reverse=not after)} LIMIT 1',
|
|
691
|
+
f'ORDER BY {_master_order_sql(sort_column, sort_direction, reverse=not after, expression=sort_expression)} LIMIT 1',
|
|
656
692
|
[*params, *key_params],
|
|
657
693
|
)
|
|
658
694
|
return rows[0]["name"] if rows else None
|