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.
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/PKG-INFO +5 -5
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/README.md +4 -4
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/auth.py +1 -1
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/chat.py +87 -67
- lambda_erp-0.8.21/api/list_values.py +25 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/availability.py +3 -2
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/chat_api.py +1 -1
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/documents.py +56 -9
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/masters.py +62 -39
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/services.py +206 -11
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/src/api/client.ts +14 -6
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/revaluation.py +1 -1
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/database.py +1 -1
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/pyproject.toml +1 -1
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/.gitignore +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/LICENSE +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/attachments.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/deps.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/errors.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/main.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/oauth.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/pdf.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/providers.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/templates/document.html +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/frontend/README.md +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.19 → lambda_erp-0.8.21}/lambda_erp/utils.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
for the
|
|
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.
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
for the
|
|
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
|
|
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).
|
|
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
|
|
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
|
-
"""
|
|
1441
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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",
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
68
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|