lambda-erp 0.8.20__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.20 → lambda_erp-0.8.21}/PKG-INFO +5 -5
  2. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/README.md +4 -4
  3. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/auth.py +1 -1
  4. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/chat.py +86 -66
  5. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/availability.py +3 -2
  6. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/chat_api.py +1 -1
  7. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/masters.py +41 -35
  8. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/services.py +162 -11
  9. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/src/api/client.ts +1 -1
  10. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/revaluation.py +1 -1
  11. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/database.py +1 -1
  12. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/pyproject.toml +1 -1
  13. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/.gitignore +0 -0
  14. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/LICENSE +0 -0
  15. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/__init__.py +0 -0
  16. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/attachments.py +0 -0
  17. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/bootstrap.py +0 -0
  18. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/demo_limits.py +0 -0
  19. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/deps.py +0 -0
  20. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/errors.py +0 -0
  21. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/list_values.py +0 -0
  22. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/main.py +0 -0
  23. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/oauth.py +0 -0
  24. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/pdf.py +0 -0
  25. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/providers.py +0 -0
  26. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/remarks_md.py +0 -0
  27. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/__init__.py +0 -0
  28. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/accounting.py +0 -0
  29. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/actions.py +0 -0
  30. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/admin.py +0 -0
  31. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/analytics.py +0 -0
  32. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/bank_reconciliation.py +0 -0
  33. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/documents.py +0 -0
  34. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/mcp.py +0 -0
  35. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/proposals.py +0 -0
  36. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/reports.py +0 -0
  37. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/setup.py +0 -0
  38. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/templates/document.html +0 -0
  39. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/templates/proposal.html +0 -0
  40. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/docs/agents/README.md +0 -0
  41. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/LICENSE +0 -0
  42. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/README.md +0 -0
  43. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/__init__.py +0 -0
  44. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/__init__.py +0 -0
  45. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/bank_transaction.py +0 -0
  46. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/budget.py +0 -0
  47. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  48. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/general_ledger.py +0 -0
  49. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/journal_entry.py +0 -0
  50. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/payment_entry.py +0 -0
  51. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/pos_invoice.py +0 -0
  52. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
  53. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/sales_invoice.py +0 -0
  54. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/__init__.py +0 -0
  55. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/engine.py +0 -0
  56. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/pack.py +0 -0
  57. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  58. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  59. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  60. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  61. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  62. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  63. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/profiles.py +0 -0
  64. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/spine.py +0 -0
  65. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/subscription.py +0 -0
  66. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/__init__.py +0 -0
  67. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/asset.py +0 -0
  68. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/reservation.py +0 -0
  69. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/buying/__init__.py +0 -0
  70. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/buying/purchase_order.py +0 -0
  71. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/__init__.py +0 -0
  72. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/currency.py +0 -0
  73. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/defaults.py +0 -0
  74. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/pricing_rule.py +0 -0
  75. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  76. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/exceptions.py +0 -0
  77. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/hooks.py +0 -0
  78. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/model.py +0 -0
  79. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/__init__.py +0 -0
  80. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/proposal.py +0 -0
  81. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/quotation.py +0 -0
  82. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/sales_order.py +0 -0
  83. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/simulation.py +0 -0
  84. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/__init__.py +0 -0
  85. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/delivery_note.py +0 -0
  86. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/purchase_receipt.py +0 -0
  87. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/stock_entry.py +0 -0
  88. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/stock_ledger.py +0 -0
  89. {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/utils.py +0 -0
  90. {lambda_erp-0.8.20 → 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.20
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
@@ -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".
@@ -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
@@ -13,8 +13,8 @@ from api.services import (
13
13
  MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_NAME_DIGITS, MASTER_RANDOM_NAME_TYPES,
14
14
  MASTER_IDENTITY_ALIAS,
15
15
  MASTER_REFERENCE_CHECKS,
16
- _filter_atom, _search_clause, _where_from_filters, count_query_cached,
17
- master_search_columns, parse_list_filter,
16
+ _filter_atom, _search_clause, _where_from_filters,
17
+ list_master_records, master_search_columns, parse_list_filter,
18
18
  )
19
19
  from api.auth import require_role, require_non_public_manager
20
20
  from api.list_values import distinct_list_values
@@ -359,6 +359,25 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
359
359
  return where_parts, params, columns
360
360
 
361
361
 
362
+ def _master_request_filters(db, doctype: str, request: Request) -> dict:
363
+ """Parse the REST query-string field filters into the shared internal form."""
364
+ filters = {}
365
+ for key, value in request.query_params.items():
366
+ if key in _MASTER_LIST_RESERVED:
367
+ continue
368
+ try:
369
+ field, parsed_value = parse_list_filter(db, doctype, key, value)
370
+ except KeyError:
371
+ raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
372
+ except TypeError:
373
+ raise HTTPException(
374
+ status_code=400,
375
+ detail=f"Contains filter is only supported for text fields: {key}",
376
+ )
377
+ filters[field] = parsed_value
378
+ return filters
379
+
380
+
362
381
  def _master_order(columns: set, order_by: str | None, order: str) -> tuple[str, str]:
363
382
  if order_by is not None and order_by not in columns:
364
383
  raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
@@ -421,39 +440,26 @@ def list_masters(
421
440
  if not doctype:
422
441
  return {"detail": f"Unknown master type: {master_type}"}
423
442
  db = get_db()
424
- where_parts, params, columns = _master_list_where(
425
- db, doctype, master_type, request, include_disabled, search, search_fields,
426
- )
427
- sort_column, sort_direction = _master_order(columns, order_by, order)
428
-
429
- where = (" WHERE " + " AND ".join(where_parts)) if where_parts else ""
430
- total = count_query_cached(f'SELECT COUNT(*) as c FROM "{doctype}"{where}', params)
431
-
432
- requested_fields = [f.strip() for f in (fields or "").split(",") if f.strip()]
433
- if requested_fields:
434
- unknown = [f for f in requested_fields if f not in columns]
435
- if unknown:
436
- raise HTTPException(status_code=400, detail=f"Unknown list fields: {', '.join(unknown)}")
437
- if "name" not in requested_fields:
438
- requested_fields.insert(0, "name")
439
- projection = ", ".join(f'"{f}"' for f in requested_fields)
440
- else:
441
- projection = "*"
442
- query = (
443
- f'SELECT {projection} FROM "{doctype}"{where} '
444
- f'ORDER BY {_master_order_sql(sort_column, sort_direction)} LIMIT {int(limit)}'
445
- )
446
- if offset:
447
- query += f" OFFSET {int(offset)}"
448
- rows = db.sql(query, params)
449
-
450
- return {
451
- "rows": rows,
452
- "total": total,
453
- "limit": limit,
454
- "offset": offset,
455
- "text_fields": sorted(db._get_text_columns(doctype)),
456
- }
443
+ field_filters = _master_request_filters(db, doctype, request)
444
+ requested_search_fields = [
445
+ field.strip() for field in (search_fields or "").split(",") if field.strip()
446
+ ]
447
+ requested_fields = [field.strip() for field in (fields or "").split(",") if field.strip()]
448
+ try:
449
+ return list_master_records(
450
+ master_type,
451
+ filters=field_filters,
452
+ search=search,
453
+ search_fields=requested_search_fields or None,
454
+ include_disabled=include_disabled,
455
+ order_by=order_by,
456
+ order=order,
457
+ limit=limit,
458
+ offset=offset,
459
+ fields=requested_fields or None,
460
+ )
461
+ except ValueError as exc:
462
+ raise HTTPException(status_code=400, detail=str(exc))
457
463
 
458
464
 
459
465
  @router.get("/{master_type}/search")
@@ -236,8 +236,9 @@ def register_master(slug: str, table: str, name_field: str, *,
236
236
  (`/api/masters/{slug}`) and the chat tools (search_masters,
237
237
  get_master_fields, create/update/delete_master — their schemas and the
238
238
  system prompt are built from the live registry per request). Fields are
239
- never declared: they're introspected from the table at call time, so every
240
- text column of `table` is immediately searchable.
239
+ never declared: they're introspected from the table at call time. Ordinary
240
+ text columns are searched by default; large blobs remain available when a
241
+ caller names them explicitly.
241
242
 
242
243
  `name_field` is the human display column (e.g. "company_name").
243
244
  `name_prefix` enables auto-generated ids (prefix "LEAD" -> LEAD-001) when a
@@ -634,6 +635,47 @@ def _resolve_date_field(db, doctype: str, override: str | None) -> str | None:
634
635
 
635
636
  _SEARCH_EXPANSIONS: dict[str, list] = {} # slug -> [fn(query: str, db) -> Iterable[str]]
636
637
 
638
+ # Audit/plumbing columns and large blobs are poor defaults for a free-text
639
+ # lookup. Callers can still target every real column explicitly.
640
+ _SEARCH_SKIP_COLUMNS = {
641
+ "creation", "modified", "created_at", "updated_at", "owner",
642
+ "modified_by", "idx", "parent", "parenttype", "parentfield",
643
+ }
644
+ _BULK_TEXT_COLUMNS = {"description", "notes", "remarks", "terms", "comments"}
645
+
646
+
647
+ def is_bulk_text_column(column: str) -> bool:
648
+ return (
649
+ column in _BULK_TEXT_COLUMNS
650
+ or column.endswith("_template")
651
+ or column.endswith("_html")
652
+ )
653
+
654
+
655
+ def _useful_text_columns(db, doctype: str) -> list[str]:
656
+ return sorted(
657
+ column for column in db._get_text_columns(doctype) - _SEARCH_SKIP_COLUMNS
658
+ if not is_bulk_text_column(column)
659
+ )
660
+
661
+
662
+ def document_search_columns(db, doctype_slug: str) -> list[str]:
663
+ """Default free-text columns for a document REST/MCP list query.
664
+
665
+ A registered chat doctype may deliberately provide a smaller field set.
666
+ Otherwise use schema-discovered text columns, excluding audit noise and
667
+ large blobs. This prevents ``search`` without ``search_fields`` from
668
+ silently becoming an unfiltered list.
669
+ """
670
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
671
+ if not doctype:
672
+ return []
673
+ columns = db._get_table_columns(doctype)
674
+ declared = (CHAT_DOCTYPES.get(doctype_slug) or {}).get("fields") or []
675
+ if declared:
676
+ return sorted({field for field in declared if field in columns})
677
+ return _useful_text_columns(db, doctype)
678
+
637
679
 
638
680
  def register_search_expansion(doctype_slug: str, fn) -> None:
639
681
  """Register a related-table search for a doctype's list. `fn(query, db)`
@@ -652,6 +694,8 @@ def _search_clause(db, doctype: str, doctype_slug: str, search, search_fields):
652
694
  tracks the searched list exactly."""
653
695
  if not search:
654
696
  return None, []
697
+ if not search_fields:
698
+ search_fields = document_search_columns(db, doctype_slug)
655
699
  cols = db._get_table_columns(doctype)
656
700
  like = f"%{search}%"
657
701
  parts, params = [], []
@@ -942,18 +986,125 @@ def count_query_cached(query: str, params=None) -> int:
942
986
  return total
943
987
 
944
988
 
945
- # Audit / plumbing columns never worth matching a free-text list search against.
946
- _MASTER_SEARCH_SKIP = {
947
- "name", "creation", "modified", "owner", "modified_by", "disabled",
948
- "idx", "parent", "parenttype", "parentfield", "docstatus",
949
- }
950
-
951
-
952
989
  def master_search_columns(db, doctype: str) -> list:
953
990
  """Text columns a free-text master-list search matches by default — schema-
954
991
  discovered (a new text field becomes searchable automatically), minus audit
955
- noise. Mirrors the chat search_masters default so both search the same set."""
956
- return sorted(db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP)
992
+ noise and large blobs. Shared by REST and chat/MCP."""
993
+ return _useful_text_columns(db, doctype)
994
+
995
+
996
+ def master_bulk_text_columns(db, doctype: str) -> list[str]:
997
+ """Large text fields available for explicit search, but skipped by default."""
998
+ return sorted(
999
+ column for column in db._get_text_columns(doctype)
1000
+ if is_bulk_text_column(column)
1001
+ )
1002
+
1003
+
1004
+ def _master_sort_sql(column: str, direction: str) -> str:
1005
+ sql = f'"{column}" {direction.upper()}'
1006
+ if column != "name":
1007
+ sql += f" NULLS LAST, name {direction.upper()}"
1008
+ return sql
1009
+
1010
+
1011
+ def list_master_records(
1012
+ master_type: str,
1013
+ *,
1014
+ filters: dict | None = None,
1015
+ search: str | None = None,
1016
+ search_fields: list[str] | None = None,
1017
+ include_disabled: bool = False,
1018
+ order_by: str | None = None,
1019
+ order: str = "asc",
1020
+ limit: int | None = 50,
1021
+ offset: int = 0,
1022
+ fields: list[str] | None = None,
1023
+ with_total: bool = True,
1024
+ ) -> dict:
1025
+ """Deterministic master-list query shared by REST and chat/MCP.
1026
+
1027
+ Scalars are exact filters; the whitelisted operator forms accepted by
1028
+ ``_filter_atom`` provide contains/comparison/NULL filtering. Free text is a
1029
+ separate OR-group and is ANDed with those field filters.
1030
+ """
1031
+ entry = MASTER_TABLES.get(master_type)
1032
+ if not entry:
1033
+ raise ValueError(f"Unknown master type: {master_type}")
1034
+ doctype, _ = entry
1035
+ db = get_db()
1036
+ columns = db._get_table_columns(doctype)
1037
+ text_fields = sorted(db._get_text_columns(doctype))
1038
+
1039
+ db_filters = {
1040
+ key: value for key, value in (filters or {}).items()
1041
+ if value is not None and value != ""
1042
+ }
1043
+ unknown_filters = [key for key in db_filters if key not in columns]
1044
+ if unknown_filters:
1045
+ raise ValueError(
1046
+ f"Unknown filter column(s) for {master_type}: {', '.join(unknown_filters)}"
1047
+ )
1048
+ _validate_typed_filters(db, doctype, db_filters)
1049
+
1050
+ where_parts, params = _where_from_filters(db_filters)
1051
+ if not include_disabled and "disabled" in columns:
1052
+ where_parts.insert(0, '"disabled" = ?')
1053
+ params.insert(0, 0)
1054
+
1055
+ requested_search_fields = list(search_fields or [])
1056
+ unknown_search = [field for field in requested_search_fields if field not in columns]
1057
+ if unknown_search:
1058
+ raise ValueError(f"Unknown search field(s): {', '.join(unknown_search)}")
1059
+ effective_search_fields = requested_search_fields or master_search_columns(db, doctype)
1060
+ search_where, search_params = _search_clause(
1061
+ db, doctype, master_type, search, effective_search_fields,
1062
+ )
1063
+ if search_where:
1064
+ where_parts.append(search_where)
1065
+ params.extend(search_params)
1066
+
1067
+ sort_column = order_by or "name"
1068
+ if sort_column not in columns:
1069
+ raise ValueError(f"Unknown order_by column for {master_type}: {sort_column}")
1070
+ direction = str(order).lower()
1071
+ if direction not in ("asc", "desc"):
1072
+ raise ValueError("order must be 'asc' or 'desc'")
1073
+
1074
+ requested_fields = list(fields or [])
1075
+ unknown_fields = [field for field in requested_fields if field not in columns]
1076
+ if unknown_fields:
1077
+ raise ValueError(f"Unknown result field(s): {', '.join(unknown_fields)}")
1078
+ if requested_fields:
1079
+ if "name" not in requested_fields:
1080
+ requested_fields.insert(0, "name")
1081
+ projection = ", ".join(f'"{field}"' for field in requested_fields)
1082
+ else:
1083
+ projection = "*"
1084
+
1085
+ where = " WHERE " + " AND ".join(where_parts) if where_parts else ""
1086
+ total = (
1087
+ count_query_cached(f'SELECT COUNT(*) AS c FROM "{doctype}"{where}', params)
1088
+ if with_total else None
1089
+ )
1090
+ query = (
1091
+ f'SELECT {projection} FROM "{doctype}"{where} '
1092
+ f'ORDER BY {_master_sort_sql(sort_column, direction)}'
1093
+ )
1094
+ if limit is not None:
1095
+ query += f" LIMIT {max(1, int(limit))}"
1096
+ elif offset and db.dialect == "sqlite":
1097
+ query += " LIMIT -1"
1098
+ if offset:
1099
+ query += f" OFFSET {max(0, int(offset))}"
1100
+
1101
+ return {
1102
+ "rows": db.sql(query, params),
1103
+ "total": total,
1104
+ "limit": limit,
1105
+ "offset": max(0, int(offset)),
1106
+ "text_fields": text_fields,
1107
+ }
957
1108
 
958
1109
 
959
1110
  def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
@@ -324,7 +324,7 @@ export const api = {
324
324
  stockBalance: (params?: Record<string, string>) =>
325
325
  request<{ rows: any[] }>(`/reports/stock-balance${qs(params)}`),
326
326
 
327
- // --- Rentals: availability + fleet calendar (docs/RENTAL_UI_PLAN.md) ---
327
+ // --- Rentals: availability + fleet calendar ---
328
328
  availability: (params: { item_code: string; from: string; to: string; warehouse?: string; exclude?: string }) =>
329
329
  request<AvailabilityResult>(`/availability${qs(params)}`),
330
330
 
@@ -5,7 +5,7 @@ historical rate(s) they were booked at. At period end their base value has
5
5
  drifted from today's rate; `run_period_revaluation` restates them to the
6
6
  closing rate and books the difference as **unrealized** FX gain/loss.
7
7
 
8
- Principles (see docs/multicurrency-phase-4c.md):
8
+ Principles (see docs/foreign-currency-revaluation.md):
9
9
  - Never edit posted entries — revaluation posts new GL, and an auto-reversal
10
10
  dated the next day backs it out so it doesn't double-count once the balance
11
11
  settles (and realized FX is recognized then).
@@ -1403,7 +1403,7 @@ class Database:
1403
1403
  # (sha256); `key_prefix` is a display-only fragment. `role` bounds the
1404
1404
  # agent's tool access; `session_owner` is the user_id the key's chat
1405
1405
  # sessions are owned under (isolated from human users). See
1406
- # docs/chat-api-plan.md.
1406
+ # docs/chat-api.md.
1407
1407
  # Per-user API keys (v2): a key BELONGS to a User and can never act
1408
1408
  # above its owner — `role` is only a CAP; the effective role is
1409
1409
  # min(cap, owner's current role) resolved live at auth time, so a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.20"
3
+ version = "0.8.21"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes