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.
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/PKG-INFO +5 -5
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/README.md +4 -4
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/auth.py +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/chat.py +86 -66
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/availability.py +3 -2
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/chat_api.py +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/masters.py +41 -35
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/services.py +162 -11
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/src/api/client.ts +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/revaluation.py +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/database.py +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/pyproject.toml +1 -1
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/.gitignore +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/LICENSE +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/attachments.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/deps.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/errors.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/list_values.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/main.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/oauth.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/pdf.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/providers.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/templates/document.html +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/frontend/README.md +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.20 → lambda_erp-0.8.21}/lambda_erp/utils.py +0 -0
- {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.
|
|
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
|
|
@@ -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".
|
|
@@ -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
|
|
@@ -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,
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
240
|
-
text
|
|
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
|
|
956
|
-
return
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|