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.
Files changed (113) hide show
  1. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/PKG-INFO +4 -1
  2. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/README.md +3 -0
  3. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/chat.py +71 -36
  4. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_profiles.py +6 -0
  5. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/documents.py +20 -15
  6. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/masters.py +40 -13
  7. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/services.py +111 -147
  8. lambda_erp-1.1.0/api/time_filters.py +101 -0
  9. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/pos_invoice.py +42 -17
  10. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/purchase_invoice.py +31 -15
  11. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/sales_invoice.py +37 -22
  12. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/buying/purchase_order.py +11 -15
  13. lambda_erp-1.1.0/lambda_erp/controllers/defaults.py +114 -0
  14. lambda_erp-1.1.0/lambda_erp/controllers/item_price.py +247 -0
  15. lambda_erp-1.1.0/lambda_erp/controllers/pricing_rule.py +273 -0
  16. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/database.py +293 -32
  17. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/model.py +20 -0
  18. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/quotation.py +15 -15
  19. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/sales_order.py +12 -16
  20. lambda_erp-1.1.0/lambda_erp/timestamps.py +85 -0
  21. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/pyproject.toml +1 -1
  22. lambda_erp-1.0.1/lambda_erp/controllers/defaults.py +0 -51
  23. lambda_erp-1.0.1/lambda_erp/controllers/pricing_rule.py +0 -112
  24. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/.gitignore +0 -0
  25. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/LICENSE +0 -0
  26. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/__init__.py +0 -0
  27. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/attachments.py +0 -0
  28. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/auth.py +0 -0
  29. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/bootstrap.py +0 -0
  30. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/demo_limits.py +0 -0
  31. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/deps.py +0 -0
  32. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/errors.py +0 -0
  33. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/list_values.py +0 -0
  34. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/main.py +0 -0
  35. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/oauth.py +0 -0
  36. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf.py +0 -0
  37. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_contract.py +0 -0
  38. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/pdf_exports.py +0 -0
  39. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/providers.py +0 -0
  40. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/remarks_md.py +0 -0
  41. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/__init__.py +0 -0
  42. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/accounting.py +0 -0
  43. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/actions.py +0 -0
  44. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/admin.py +0 -0
  45. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/analytics.py +0 -0
  46. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/availability.py +0 -0
  47. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/bank_reconciliation.py +0 -0
  48. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/bank_statements.py +0 -0
  49. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/chat_api.py +0 -0
  50. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/mcp.py +0 -0
  51. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/proposals.py +0 -0
  52. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/reports.py +0 -0
  53. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/routers/setup.py +0 -0
  54. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/_proposal_totals.html +0 -0
  55. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/_record_base.html +0 -0
  56. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/document.html +0 -0
  57. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/proposal.html +0 -0
  58. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/templates/record.html +0 -0
  59. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/api/tool_permissions.py +0 -0
  60. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/docs/agents/README.md +0 -0
  61. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/LICENSE +0 -0
  62. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/README.md +0 -0
  63. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/frontend/src/api/client.ts +0 -0
  64. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/__init__.py +0 -0
  65. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/__init__.py +0 -0
  66. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_account.py +0 -0
  67. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  68. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
  69. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  70. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/budget.py +0 -0
  71. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/camt.py +0 -0
  72. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  73. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/general_ledger.py +0 -0
  74. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/journal_entry.py +0 -0
  75. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/payment_entry.py +0 -0
  76. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/revaluation.py +0 -0
  77. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/settlement.py +0 -0
  78. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  79. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/engine.py +0 -0
  80. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/pack.py +0 -0
  81. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  82. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  83. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  84. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  85. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  86. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  87. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  88. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/setup/spine.py +0 -0
  89. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/accounting/subscription.py +0 -0
  90. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/__init__.py +0 -0
  91. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/asset.py +0 -0
  92. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/lifecycle.py +0 -0
  93. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/assets/reservation.py +0 -0
  94. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/buying/__init__.py +0 -0
  95. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/__init__.py +0 -0
  96. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/currency.py +0 -0
  97. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/item_prices.py +0 -0
  98. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  99. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/exceptions.py +0 -0
  100. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/hooks.py +0 -0
  101. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/master_integrity.py +0 -0
  102. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/__init__.py +0 -0
  103. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/selling/proposal.py +0 -0
  104. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/simulation.py +0 -0
  105. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/__init__.py +0 -0
  106. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/delivery_note.py +0 -0
  107. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  108. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/stock_entry.py +0 -0
  109. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/stock/stock_ledger.py +0 -0
  110. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/utils.py +0 -0
  111. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/validation.py +0 -0
  112. {lambda_erp-1.0.1 → lambda_erp-1.1.0}/lambda_erp/workflow.py +0 -0
  113. {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.1
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. 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 are ordered by creation DESC (newest first). Page past `limit` with `offset`; trim the payload to only the columns you need with `fields`.",
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 (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
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 the available columns of a document type and identify which are text fields. 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.",
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. Returns ALL matching records by default (no cap); pass `limit` only to bound a large list.",
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": "Optional max number of results. OMIT for no cap — returns ALL matches (e.g. to see the entire chart of accounts). Pass a number only to bound a large list."},
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
- rows = services.list_documents(
1475
- doctype,
1476
- filters=filters,
1477
- limit=args.get("limit", 20),
1478
- offset=args.get("offset", 0) or 0,
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
- except ValueError as e: # bad filter operator / malformed operator list
1484
- return {"error": str(e)}
1485
- if not fields: # projection already excludes child tables
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 child_keys:
1480
+ for key in (cls.CHILD_TABLES if cls else {}):
1488
1481
  row.pop(key, None)
1489
- return rows
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=False,
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
- limit: int = Query(default=50, le=500),
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
- rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset,
134
- include_discarded=include_discarded, order_by=order_by, order=order,
135
- fields=projection)
136
- total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
137
- return {
138
- "rows": rows,
139
- "total": total,
140
- "limit": limit,
141
- "offset": offset,
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
- return adjacent_documents(doctype_slug, name, filters=filters,
208
- include_discarded=include_discarded, order_by=order_by, order=order)
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
- sql = f'"{column}" {effective.upper()}'
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
- sort_column, sort_direction = _master_order(columns, order_by, order)
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 "{sort_column}" AS sort_value FROM "{doctype}" '
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'"{sort_column}" IS NULL AND name {same_cmp} ?'
677
+ keyset = f'{sort_expression} IS NULL AND name {same_cmp} ?'
651
678
  else:
652
679
  keyset = (
653
- f'("{sort_column}" IS NOT NULL OR '
654
- f'("{sort_column}" IS NULL AND name {same_cmp} ?))'
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'("{sort_column}" {primary_cmp} ? OR '
660
- f'("{sort_column}" = ? AND name {same_cmp} ?)'
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 "{sort_column}" IS NULL'
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