lambda-erp 0.8.19__tar.gz → 0.8.21__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 (90) hide show
  1. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/PKG-INFO +5 -5
  2. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/README.md +4 -4
  3. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/auth.py +1 -1
  4. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/chat.py +87 -67
  5. lambda_erp-0.8.21/api/list_values.py +25 -0
  6. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/availability.py +3 -2
  7. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/chat_api.py +1 -1
  8. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/documents.py +56 -9
  9. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/masters.py +62 -39
  10. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/services.py +206 -11
  11. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/src/api/client.ts +14 -6
  12. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/revaluation.py +1 -1
  13. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/database.py +1 -1
  14. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/pyproject.toml +1 -1
  15. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/.gitignore +0 -0
  16. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/LICENSE +0 -0
  17. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/__init__.py +0 -0
  18. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/attachments.py +0 -0
  19. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/bootstrap.py +0 -0
  20. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/demo_limits.py +0 -0
  21. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/deps.py +0 -0
  22. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/errors.py +0 -0
  23. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/main.py +0 -0
  24. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/oauth.py +0 -0
  25. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/pdf.py +0 -0
  26. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/providers.py +0 -0
  27. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/remarks_md.py +0 -0
  28. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/__init__.py +0 -0
  29. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/accounting.py +0 -0
  30. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/actions.py +0 -0
  31. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/admin.py +0 -0
  32. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/analytics.py +0 -0
  33. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/bank_reconciliation.py +0 -0
  34. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/mcp.py +0 -0
  35. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/proposals.py +0 -0
  36. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/reports.py +0 -0
  37. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/setup.py +0 -0
  38. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/templates/document.html +0 -0
  39. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/templates/proposal.html +0 -0
  40. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/docs/agents/README.md +0 -0
  41. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/LICENSE +0 -0
  42. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/README.md +0 -0
  43. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/__init__.py +0 -0
  44. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/__init__.py +0 -0
  45. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/bank_transaction.py +0 -0
  46. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/budget.py +0 -0
  47. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  48. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/general_ledger.py +0 -0
  49. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/journal_entry.py +0 -0
  50. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/payment_entry.py +0 -0
  51. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/pos_invoice.py +0 -0
  52. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
  53. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/sales_invoice.py +0 -0
  54. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/__init__.py +0 -0
  55. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/engine.py +0 -0
  56. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/pack.py +0 -0
  57. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  58. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  59. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  60. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  61. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  62. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  63. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/profiles.py +0 -0
  64. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/spine.py +0 -0
  65. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/subscription.py +0 -0
  66. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/__init__.py +0 -0
  67. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/asset.py +0 -0
  68. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/reservation.py +0 -0
  69. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/buying/__init__.py +0 -0
  70. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/buying/purchase_order.py +0 -0
  71. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/__init__.py +0 -0
  72. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/currency.py +0 -0
  73. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/defaults.py +0 -0
  74. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/pricing_rule.py +0 -0
  75. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  76. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/exceptions.py +0 -0
  77. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/hooks.py +0 -0
  78. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/model.py +0 -0
  79. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/__init__.py +0 -0
  80. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/proposal.py +0 -0
  81. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/quotation.py +0 -0
  82. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/sales_order.py +0 -0
  83. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/simulation.py +0 -0
  84. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/__init__.py +0 -0
  85. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/delivery_note.py +0 -0
  86. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/purchase_receipt.py +0 -0
  87. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/stock_entry.py +0 -0
  88. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/stock_ledger.py +0 -0
  89. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/utils.py +0 -0
  90. {lambda_erp-0.8.19 → lambda_erp-0.8.21}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.19
3
+ Version: 0.8.21
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -92,7 +92,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
92
92
  ▼
93
93
  ┌──────────────────────────────────────────┐
94
94
  │ LLM orchestrator │
95
- │ - GPT-5.4 drives the reasoning loop │
95
+ │ - GPT-5.6 drives the reasoning loop │
96
96
  │ - Tool-use: document CRUD, search, │
97
97
  │ reports, aggregations, analytics │
98
98
  │ - Delegates JS generation to Anthropic │
@@ -449,9 +449,9 @@ replacement file.
449
449
  Both the backend seams (document classes, lifecycle hooks, converters, plugin
450
450
  loading) and the frontend seams (doctype/route/nav/component registries,
451
451
  branding, configurable API base, Tailwind preset) are implemented. The backend
452
- also builds a clean pip wheel and the frontend a `@lambda-development/erp-core` npm library
453
- — see [`docs/packaging-distribution-plan.md`](docs/packaging-distribution-plan.md)
454
- for the publish path.
452
+ ships as a pip package and the frontend as the
453
+ `@lambda-development/erp-core` npm package. See
454
+ [`docs/releasing.md`](docs/releasing.md) for the release process.
455
455
 
456
456
  ---
457
457
 
@@ -64,7 +64,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
64
64
  ▼
65
65
  ┌──────────────────────────────────────────┐
66
66
  │ LLM orchestrator │
67
- │ - GPT-5.4 drives the reasoning loop │
67
+ │ - GPT-5.6 drives the reasoning loop │
68
68
  │ - Tool-use: document CRUD, search, │
69
69
  │ reports, aggregations, analytics │
70
70
  │ - Delegates JS generation to Anthropic │
@@ -421,9 +421,9 @@ replacement file.
421
421
  Both the backend seams (document classes, lifecycle hooks, converters, plugin
422
422
  loading) and the frontend seams (doctype/route/nav/component registries,
423
423
  branding, configurable API base, Tailwind preset) are implemented. The backend
424
- also builds a clean pip wheel and the frontend a `@lambda-development/erp-core` npm library
425
- — see [`docs/packaging-distribution-plan.md`](docs/packaging-distribution-plan.md)
426
- for the publish path.
424
+ ships as a pip package and the frontend as the
425
+ `@lambda-development/erp-core` npm package. See
426
+ [`docs/releasing.md`](docs/releasing.md) for the release process.
427
427
 
428
428
  ---
429
429
 
@@ -612,7 +612,7 @@ DEFAULTS = {
612
612
  "allow_public_signup": "0",
613
613
  # When "1", the programmatic chat API (POST /api/v1/chat, Bearer API keys) is
614
614
  # active. Default off — an admin turns it on and issues keys. See
615
- # docs/chat-api-plan.md.
615
+ # docs/chat-api.md.
616
616
  "chat_api_enabled": "0",
617
617
  # When "1", the same Bearer API keys authenticate the regular REST API
618
618
  # (/api/documents, /api/masters, /api/reports, …) in addition to the session
@@ -467,7 +467,7 @@ TOOLS = [
467
467
  "type": "object",
468
468
  "properties": {
469
469
  "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
470
- "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\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, 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": {}},
470
+ "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": {}},
471
471
  "order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
472
472
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
473
473
  "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
@@ -478,6 +478,20 @@ TOOLS = [
478
478
  },
479
479
  },
480
480
  },
481
+ {
482
+ "type": "function",
483
+ "function": {
484
+ "name": "get_document_fields",
485
+ "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 the default fields used by free-text search.",
486
+ "parameters": {
487
+ "type": "object",
488
+ "properties": {
489
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
490
+ },
491
+ "required": ["doctype"],
492
+ },
493
+ },
494
+ },
481
495
  {
482
496
  "type": "function",
483
497
  "function": {
@@ -627,7 +641,7 @@ TOOLS = [
627
641
  "type": "function",
628
642
  "function": {
629
643
  "name": "search_masters",
630
- "description": "Search master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). Case-insensitive, with fuzzy fallback for misspellings. By default matches across standard text fields (name, display name, address/city/zip); large free-text columns like description/templates are skipped unless named in `fields`. PREFER passing `fields` whenever you know which attribute you're matching on (e.g. a city, an email, a tax id) — it's faster, more precise, and avoids false hits from other columns. Returns ALL matching records by default (no cap); pass `limit` only to bound a large list.",
644
+ "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.",
631
645
  "parameters": {
632
646
  "type": "object",
633
647
  "properties": {
@@ -638,7 +652,20 @@ TOOLS = [
638
652
  "items": {"type": "string"},
639
653
  "description": "Recommended: the column(s) to search, e.g. [\"city\"] or [\"customer_name\"]. Narrowing here is faster and avoids matching unrelated columns. Omit only when you genuinely don't know which field holds the value, to search all standard text fields.",
640
654
  },
655
+ "filters": {
656
+ "type": "object",
657
+ "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.",
658
+ "default": {},
659
+ },
660
+ "order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to name."},
661
+ "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
641
662
  "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."},
663
+ "offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
664
+ "result_fields": {
665
+ "type": "array",
666
+ "items": {"type": "string"},
667
+ "description": "Optional result projection. Returns only these real columns plus name. This is separate from `fields`, which selects columns searched by `query`.",
668
+ },
642
669
  "include_disabled": {"type": "boolean", "description": "Default false (active records only). Set true to ALSO return disabled/archived records — needed to find a record you must inspect, re-enable, or reference, or to answer 'what's disabled'. Retry with this if a lookup for a record you know exists comes back empty."},
643
670
  },
644
671
  "required": ["master_type"],
@@ -649,7 +676,7 @@ TOOLS = [
649
676
  "type": "function",
650
677
  "function": {
651
678
  "name": "get_master_fields",
652
- "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` to search_masters, and before building a `data` payload for create_master/update_master when you're not certain a field exists or where a value belongs (e.g. a contact person, a tax id, a payment term). It lets you target real fields instead of guessing — or wrongly concluding a value can't be stored. Returns: `fields` (all columns), `default_search_fields` (what search_masters searches when `fields` is omitted), and `bulk_text_fields` (large text columns searched only when named in `fields`).",
679
+ "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` or `filters` to search_masters, and before building a create/update payload. Returns all fields, text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
653
680
  "parameters": {
654
681
  "type": "object",
655
682
  "properties": {
@@ -1326,18 +1353,6 @@ def _handle_list_documents(args):
1326
1353
  # within the tool-result budget. Use get_document to drill into one doc.
1327
1354
  doctype = args["doctype"]
1328
1355
  filters = args.get("filters") or {}
1329
- # Chat callers routinely pass `search` but omit `search_fields`; with no fields
1330
- # the free-text search silently no-ops and returns the newest N by creation
1331
- # (2026-08-13: tag lookups on leads "found nothing"). Default the columns to the
1332
- # doctype's registered chat fields so document search matches a record the way
1333
- # search_masters already does for masters. The frontend never hits this — its
1334
- # UI config always sends search_fields.
1335
- if filters.get("search") and not filters.get("search_fields"):
1336
- registered = (services.CHAT_DOCTYPES.get(doctype) or {}).get("fields") or []
1337
- valid = set(services.document_columns(doctype) or [])
1338
- default_fields = [f for f in registered if f in valid]
1339
- if default_fields:
1340
- filters = {**filters, "search_fields": default_fields}
1341
1356
  err = _validate_filter_columns(doctype, filters)
1342
1357
  if err:
1343
1358
  return {"error": err}
@@ -1369,6 +1384,23 @@ def _handle_list_documents(args):
1369
1384
  return rows
1370
1385
 
1371
1386
 
1387
+ def _handle_get_document_fields(args):
1388
+ doctype = args["doctype"]
1389
+ table = services.SLUG_TO_DOCTYPE.get(doctype)
1390
+ if not table:
1391
+ return {"error": f"Unknown document type: {doctype}"}
1392
+ db = get_db()
1393
+ cls = services.DOCUMENT_CLASSES.get(table)
1394
+ return {
1395
+ "doctype": doctype,
1396
+ "fields": sorted(db._get_table_columns(table)),
1397
+ "text_fields": sorted(db._get_text_columns(table)),
1398
+ "default_search_fields": services.document_search_columns(db, doctype),
1399
+ "child_tables": sorted((cls.CHILD_TABLES or {}).keys()) if cls else [],
1400
+ "link_fields": dict(getattr(cls, "LINK_FIELDS", None) or {}) if cls else {},
1401
+ }
1402
+
1403
+
1372
1404
  def _handle_get_document(args):
1373
1405
  return services.load_document(args["doctype"], args["name"])
1374
1406
 
@@ -1407,22 +1439,6 @@ def _handle_convert_document(args):
1407
1439
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1408
1440
 
1409
1441
 
1410
- # Columns that are technically text but are noise for free-text master search.
1411
- _MASTER_SEARCH_SKIP_COLUMNS = {
1412
- "naming_series",
1413
- "owner",
1414
- "modified_by",
1415
- "creation",
1416
- "modified",
1417
- "created_at",
1418
- "updated_at",
1419
- }
1420
-
1421
- # Large free-text columns excluded from the DEFAULT search: scanning/fuzzing a
1422
- # big blob (e.g. an item description or an HTML template) is costly and rarely
1423
- # how you identify a record. Still searchable on demand via the `fields` arg.
1424
- _MASTER_BULK_TEXT_COLUMNS = {"description", "notes", "remarks", "terms", "comments"}
1425
-
1426
1442
  # Minimum difflib similarity for a fuzzy (misspelled) match to be returned.
1427
1443
  _MASTER_FUZZY_THRESHOLD = 0.6
1428
1444
  # Bounds for the fuzzy scorer so a single large value can't blow up cost:
@@ -1431,18 +1447,9 @@ _FUZZY_MAX_VALUE_LEN = 200
1431
1447
  _FUZZY_MAX_TOKENS = 16
1432
1448
 
1433
1449
 
1434
- def _is_bulk_text_column(col):
1435
- return (col in _MASTER_BULK_TEXT_COLUMNS
1436
- or col.endswith("_template") or col.endswith("_html"))
1437
-
1438
-
1439
1450
  def _master_search_columns(db, doctype):
1440
- """Text columns worth matching a query against by default. Discovered from
1441
- the live schema (new text fields become searchable automatically), minus
1442
- audit noise and large free-text/template columns."""
1443
- cols = db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP_COLUMNS
1444
- # Deterministic order keeps generated SQL and fuzzy scoring stable.
1445
- return sorted(c for c in cols if not _is_bulk_text_column(c))
1451
+ """Compatibility wrapper for the shared REST/MCP field policy."""
1452
+ return services.master_search_columns(db, doctype)
1446
1453
 
1447
1454
 
1448
1455
  def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
@@ -1485,10 +1492,11 @@ def _handle_get_master_fields(args):
1485
1492
  return {"error": f"Unknown master type: {master_type}"}
1486
1493
  doctype, _ = entry
1487
1494
  default_search = _master_search_columns(db, doctype)
1488
- bulk = sorted(c for c in db._get_text_columns(doctype) if _is_bulk_text_column(c))
1495
+ bulk = services.master_bulk_text_columns(db, doctype)
1489
1496
  return {
1490
1497
  "master_type": master_type,
1491
1498
  "fields": sorted(db._get_table_columns(doctype)),
1499
+ "text_fields": sorted(db._get_text_columns(doctype)),
1492
1500
  # What search_masters searches when `fields` is omitted.
1493
1501
  "default_search_fields": default_search,
1494
1502
  # Large text fields searched ONLY when named in search_masters `fields`.
@@ -1517,23 +1525,20 @@ def _handle_search_masters(args):
1517
1525
  return {"error": f"Unknown master type: {master_type}"}
1518
1526
 
1519
1527
  doctype, _name_field = entry
1520
- has_disabled = "disabled" in db._get_table_columns(doctype)
1528
+ columns = set(db._get_table_columns(doctype))
1529
+ has_disabled = "disabled" in columns
1521
1530
  # Active-only by default (retired/archived records are hidden — you shouldn't
1522
1531
  # book to them or wade through them). Set include_disabled to reach a disabled
1523
1532
  # record you need to inspect, re-enable, or reference. Only meaningful when the
1524
1533
  # table has a `disabled` column.
1525
1534
  filter_disabled = has_disabled and not bool(args.get("include_disabled"))
1526
1535
 
1527
- if not query:
1528
- filters = {"disabled": 0} if filter_disabled else None
1529
- return db.get_all(doctype, filters=filters, fields=["*"], limit=limit)
1530
-
1531
1536
  # Optional `fields` narrows the search to specific columns — cheaper, more
1532
1537
  # precise, and the only way to reach bulk columns (description, templates)
1533
1538
  # that the default search skips. Unknown names are ignored.
1534
1539
  requested = args.get("fields") or []
1535
1540
  if requested:
1536
- valid = set(db._get_table_columns(doctype))
1541
+ valid = columns
1537
1542
  # The identity alias (e.g. "item_code") is what the model calls the code
1538
1543
  # everywhere else — create_master, document lines, the asset/reservation
1539
1544
  # prompt — so it naturally passes fields=["item_code"]. But the real
@@ -1556,22 +1561,36 @@ def _handle_search_masters(args):
1556
1561
  else:
1557
1562
  search_cols = _master_search_columns(db, doctype)
1558
1563
 
1559
- active_prefix = "disabled = 0 AND " if filter_disabled else ""
1560
-
1561
- # Case-insensitive substring match. lower() on both sides is portable (bare
1562
- # LIKE is case-insensitive on SQLite but case-sensitive on Postgres, which
1563
- # silently broke prod search); CAST lets targeted non-text columns match too.
1564
- where = " OR ".join(f'lower(CAST("{col}" AS TEXT)) LIKE ?' for col in search_cols)
1565
- pattern = f"%{query.lower()}%"
1566
- limit_sql = f" LIMIT {limit}" if limit else ""
1567
- rows = db.sql(
1568
- f'SELECT * FROM "{doctype}" WHERE {active_prefix}({where}){limit_sql}',
1569
- [pattern] * len(search_cols),
1570
- )
1571
- if rows:
1572
- return [dict(r) for r in rows]
1573
-
1574
- # Nothing matched literally — try fuzzy matching to catch misspellings.
1564
+ filters = args.get("filters") or {}
1565
+ result_fields = args.get("result_fields") or None
1566
+ offset = args.get("offset", 0) or 0
1567
+ order_by = args.get("order_by")
1568
+ order = args.get("order", "asc")
1569
+ try:
1570
+ result = services.list_master_records(
1571
+ master_type,
1572
+ filters=filters,
1573
+ search=query or None,
1574
+ search_fields=search_cols if query else None,
1575
+ include_disabled=bool(args.get("include_disabled")),
1576
+ order_by=order_by,
1577
+ order=order,
1578
+ limit=limit,
1579
+ offset=offset,
1580
+ fields=result_fields,
1581
+ with_total=False,
1582
+ )
1583
+ except (TypeError, ValueError) as exc:
1584
+ return {"error": str(exc)}
1585
+ rows = [dict(row) for row in result["rows"]]
1586
+ if rows or not query:
1587
+ return rows
1588
+
1589
+ # Preserve the established fuzzy fallback for a simple lookup. A
1590
+ # deterministic field-filter query must return exactly its literal result;
1591
+ # relaxing only the free-text part could otherwise select the wrong record.
1592
+ if filters or offset or order_by or result_fields:
1593
+ return []
1575
1594
  return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
1576
1595
 
1577
1596
 
@@ -1932,6 +1951,7 @@ def _handle_apply_company_setup(args):
1932
1951
 
1933
1952
  TOOL_HANDLERS = {
1934
1953
  "list_documents": _handle_list_documents,
1954
+ "get_document_fields": _handle_get_document_fields,
1935
1955
  "get_document": _handle_get_document,
1936
1956
  "create_document": _handle_create_document,
1937
1957
  "update_document": _handle_update_document,
@@ -2405,10 +2425,10 @@ Every master record has a primary key (the `name` column) and a human-readable d
2405
2425
  When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, etc. in a document or child-table row, you MUST use the **primary key**, never the display name. `"item_code": "Project Management"` is ALWAYS wrong — it's a name, not a code.
2406
2426
 
2407
2427
  If the user refers to something by its human name ("bill them 8 hours of project mgmt", "add Redstone to the quote"), resolve the key first:
2408
- - `search_masters(master_type="item", q="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
2428
+ - `search_masters(master_type="item", query="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
2409
2429
  - Same for customers, suppliers, warehouses, etc. — `search_masters` is **case-insensitive** and falls back to **fuzzy matching for misspellings**, so a typo'd name ("Meynex") still resolves. Trust its results instead of concluding "not found" after one narrow try.
2410
2430
  - **Prefer narrowing with `fields`** whenever you know the attribute: search a customer by city with `fields=["city"]`, by email with `fields=["contact_email"]`, etc. It's faster and avoids false matches from unrelated columns. Omitting `fields` searches all standard text fields (a good fallback when you're unsure where the value lives), but large free-text columns (e.g. item `description`) are only searched when you name them explicitly in `fields`.
2411
- - **Don't guess column names** — call `get_master_fields(master_type=...)` first to see the real columns (and which are searched by default), then pass the exact names to `search_masters` `fields`. This also tells you which large text fields (like `description`) you must name explicitly to search.
2431
+ - **Don't guess column names** — call `get_master_fields(master_type=...)` first to see the real columns, which support `contains`, and which are searched by default. Use `query` + `fields` for a fuzzy lookup; use `filters` for exact, combined field conditions. For documents, use `get_document_fields` before constructing unfamiliar `list_documents` filters.
2412
2432
  - **When unsure where a value belongs, discover the schema — don't guess or give up.** Before a `create_master`/`update_master` where you're not certain a field exists or which column fits (the user gives a contact person, a VAT/tax id, a payment term, an IBAN, …), call `get_master_fields(master_type=...)` and map the value onto the real column. Never tell the user you can't store something without checking the fields first — the master usually has a column for it (e.g. a customer's contact person goes in `contact_person`/`contact_phone`/`contact_email`, not the company-level `phone`/`email`).
2413
2433
 
2414
2434
  When you list masters back to the user (items on an invoice, customers on a report), include the key in parentheses so follow-ups are unambiguous. Example: "Project Management (SVC-005) — 16 Hour".
@@ -0,0 +1,25 @@
1
+ """Shared value suggestions for the generic master/document list filters."""
2
+
3
+
4
+ def distinct_list_values(db, table: str, field: str, query: str = "", limit: int = 200) -> list:
5
+ """Return distinct, non-empty values for one validated table column.
6
+
7
+ ``field`` is checked against the live schema before it is interpolated.
8
+ Prefix matching keeps autocomplete useful without loading a whole
9
+ high-cardinality column into the browser.
10
+ """
11
+ if field not in db._get_table_columns(table):
12
+ raise KeyError(field)
13
+
14
+ where = [f'"{field}" IS NOT NULL', f'CAST("{field}" AS TEXT) <> \'\'']
15
+ params: list = []
16
+ if query:
17
+ where.append(f'LOWER(CAST("{field}" AS TEXT)) LIKE LOWER(?)')
18
+ params.append(f"{query}%")
19
+
20
+ rows = db.sql(
21
+ f'SELECT DISTINCT "{field}" AS v FROM "{table}" '
22
+ f'WHERE {" AND ".join(where)} ORDER BY "{field}" LIMIT {int(limit)}',
23
+ params,
24
+ )
25
+ return [row["v"] for row in rows]
@@ -4,8 +4,9 @@ Exposes the Reservation availability engine (lambda_erp/assets/reservation.py)
4
4
  over REST so the booking form can check "is this free?" before saving and the
5
5
  fleet calendar can render free vs booked. Read-only — posts nothing.
6
6
 
7
- See docs/RENTAL_UI_PLAN.md (Phase 1). The overlap rule itself lives in one
8
- place (reservation.overlapping_reservations); this router only surfaces it.
7
+ The overlap rule itself lives in one place
8
+ (`reservation.overlapping_reservations`); this router only surfaces it. See
9
+ `docs/adr-0002-asset-and-reservation.md` for the reservation semantics.
9
10
  """
10
11
 
11
12
  from fastapi import APIRouter, Depends, Query, HTTPException
@@ -6,7 +6,7 @@ default). Lets an external orchestrator (Lambda's own lambda-web infra, then the
6
6
  iPhone app) hold a conversation with an ERP instance the way a connector script
7
7
  talks to Dynamics NAV.
8
8
 
9
- Statefulness (see docs/chat-api-plan.md):
9
+ Statefulness (see docs/chat-api.md):
10
10
  - no `session_id` -> stateless reasoning: the agent answers using only the
11
11
  current message; the turn is still persisted to a rolling audit session for
12
12
  visibility, but prior turns are NOT replayed. This suits a caller (the
@@ -14,9 +14,13 @@ from api.services import (
14
14
  count_documents,
15
15
  document_columns,
16
16
  adjacent_documents,
17
+ parse_list_filter,
18
+ SLUG_TO_DOCTYPE,
17
19
  )
18
20
  from api.pdf import generate_pdf
19
21
  from api.auth import require_role
22
+ from api.list_values import distinct_list_values
23
+ from lambda_erp.database import get_db
20
24
 
21
25
  router = APIRouter(prefix="/documents", tags=["documents"])
22
26
 
@@ -24,7 +28,8 @@ _viewer = Depends(require_role("viewer"))
24
28
  _manager = Depends(require_role("manager"))
25
29
 
26
30
  # Query params the list endpoint interprets itself — everything else is treated
27
- # as an ad-hoc column=value filter (validated against the doctype's columns).
31
+ # as an ad-hoc field filter (validated against the doctype's columns). Plain
32
+ # keys are exact; ``field__contains`` is available for real text columns.
28
33
  _LIST_RESERVED = {
29
34
  "status", "party", "from_date", "to_date", "docstatus",
30
35
  "include_discarded", "limit", "offset", "order_by", "order",
@@ -64,16 +69,23 @@ def list_docs(
64
69
  if to_date:
65
70
  filters["to_date"] = to_date
66
71
 
67
- # Ad-hoc equality filters: any remaining query param that names a real column
68
- # of this doctype (e.g. /documents/activity?lead_id=LEAD-3316). Validate
69
- # against the live columns so an unknown field is a 400, never interpolated.
72
+ # Ad-hoc filters: any remaining query param must name a real column, with an
73
+ # optional __contains suffix for schema-confirmed text columns.
70
74
  columns = document_columns(doctype_slug)
75
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
71
76
  for key, value in request.query_params.items():
72
77
  if key in _LIST_RESERVED:
73
78
  continue
74
- if key not in columns:
79
+ try:
80
+ field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
81
+ except KeyError:
75
82
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
76
- filters[key] = value
83
+ except TypeError:
84
+ raise HTTPException(
85
+ status_code=400,
86
+ detail=f"Contains filter is only supported for text fields: {key}",
87
+ )
88
+ filters[field] = parsed_value
77
89
 
78
90
  # Which column from_date/to_date filter on. The frontend passes its declared
79
91
  # dateField so plugin doctypes get working date filters without a server-side
@@ -115,7 +127,13 @@ def list_docs(
115
127
  include_discarded=include_discarded, order_by=order_by, order=order,
116
128
  fields=projection)
117
129
  total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
118
- return {"rows": rows, "total": total, "limit": limit, "offset": offset}
130
+ return {
131
+ "rows": rows,
132
+ "total": total,
133
+ "limit": limit,
134
+ "offset": offset,
135
+ "text_fields": sorted(get_db()._get_text_columns(doctype)),
136
+ }
119
137
 
120
138
 
121
139
  @router.get("/{doctype_slug}/{name}/adjacent")
@@ -151,12 +169,20 @@ def adjacent_doc(
151
169
  if to_date:
152
170
  filters["to_date"] = to_date
153
171
  columns = document_columns(doctype_slug)
172
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
154
173
  for key, value in request.query_params.items():
155
174
  if key in _LIST_RESERVED:
156
175
  continue
157
- if key not in columns:
176
+ try:
177
+ field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
178
+ except KeyError:
158
179
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
159
- filters[key] = value
180
+ except TypeError:
181
+ raise HTTPException(
182
+ status_code=400,
183
+ detail=f"Contains filter is only supported for text fields: {key}",
184
+ )
185
+ filters[field] = parsed_value
160
186
  if date_field:
161
187
  filters["date_field"] = date_field
162
188
  if search:
@@ -183,6 +209,27 @@ def search_docs(doctype_slug: str, q: str = "", limit: int = Query(default=10, l
183
209
  return [{"name": d["name"]} for d in docs]
184
210
 
185
211
 
212
+ @router.get("/{doctype_slug}/filter-values")
213
+ def document_filter_values(
214
+ doctype_slug: str,
215
+ field: str,
216
+ q: str = "",
217
+ limit: int = Query(default=200, ge=1, le=200),
218
+ _user: dict = _viewer,
219
+ ):
220
+ """Distinct values for the generic list's field autocomplete."""
221
+ from api.services import SLUG_TO_DOCTYPE
222
+
223
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
224
+ if not doctype:
225
+ raise HTTPException(status_code=404, detail=f"Unknown document type: {doctype_slug}")
226
+ try:
227
+ values = distinct_list_values(get_db(), doctype, field, q, limit)
228
+ except KeyError:
229
+ raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
230
+ return {"values": values}
231
+
232
+
186
233
  @router.get("/{doctype_slug}/{name}/pdf")
187
234
  def get_pdf(doctype_slug: str, name: str, _user: dict = _viewer):
188
235
  pdf_bytes = generate_pdf(doctype_slug, name)