lambda-erp 0.8.18__tar.gz → 0.8.20__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.18 → lambda_erp-0.8.20}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/chat.py +1 -1
  3. lambda_erp-0.8.20/api/list_values.py +25 -0
  4. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/documents.py +56 -9
  5. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/masters.py +30 -13
  6. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/reports.py +3 -3
  7. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/services.py +44 -0
  8. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/src/api/client.ts +13 -5
  9. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/journal_entry.py +36 -23
  10. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/pyproject.toml +1 -1
  11. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/.gitignore +0 -0
  12. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/LICENSE +0 -0
  13. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/README.md +0 -0
  14. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/__init__.py +0 -0
  15. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/attachments.py +0 -0
  16. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/auth.py +0 -0
  17. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/bootstrap.py +0 -0
  18. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/demo_limits.py +0 -0
  19. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/deps.py +0 -0
  20. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/errors.py +0 -0
  21. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/main.py +0 -0
  22. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/oauth.py +0 -0
  23. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/pdf.py +0 -0
  24. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/providers.py +0 -0
  25. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/remarks_md.py +0 -0
  26. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/__init__.py +0 -0
  27. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/accounting.py +0 -0
  28. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/actions.py +0 -0
  29. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/admin.py +0 -0
  30. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/analytics.py +0 -0
  31. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/availability.py +0 -0
  32. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/bank_reconciliation.py +0 -0
  33. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/chat_api.py +0 -0
  34. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/mcp.py +0 -0
  35. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/proposals.py +0 -0
  36. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/setup.py +0 -0
  37. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/templates/document.html +0 -0
  38. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/templates/proposal.html +0 -0
  39. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/docs/agents/README.md +0 -0
  40. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/LICENSE +0 -0
  41. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/README.md +0 -0
  42. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/__init__.py +0 -0
  43. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/__init__.py +0 -0
  44. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/bank_transaction.py +0 -0
  45. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/budget.py +0 -0
  46. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  47. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/general_ledger.py +0 -0
  48. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/payment_entry.py +0 -0
  49. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/pos_invoice.py +0 -0
  50. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/purchase_invoice.py +0 -0
  51. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/revaluation.py +0 -0
  52. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/sales_invoice.py +0 -0
  53. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/__init__.py +0 -0
  54. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/engine.py +0 -0
  55. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/pack.py +0 -0
  56. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  57. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  58. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  59. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  60. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  61. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  62. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/profiles.py +0 -0
  63. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/spine.py +0 -0
  64. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/subscription.py +0 -0
  65. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/__init__.py +0 -0
  66. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/asset.py +0 -0
  67. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/reservation.py +0 -0
  68. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/buying/__init__.py +0 -0
  69. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/buying/purchase_order.py +0 -0
  70. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/__init__.py +0 -0
  71. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/currency.py +0 -0
  72. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/defaults.py +0 -0
  73. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/pricing_rule.py +0 -0
  74. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  75. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/database.py +0 -0
  76. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/exceptions.py +0 -0
  77. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/hooks.py +0 -0
  78. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/model.py +0 -0
  79. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/__init__.py +0 -0
  80. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/proposal.py +0 -0
  81. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/quotation.py +0 -0
  82. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/sales_order.py +0 -0
  83. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/simulation.py +0 -0
  84. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/__init__.py +0 -0
  85. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/delivery_note.py +0 -0
  86. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/purchase_receipt.py +0 -0
  87. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/stock_entry.py +0 -0
  88. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/stock_ledger.py +0 -0
  89. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/utils.py +0 -0
  90. {lambda_erp-0.8.18 → lambda_erp-0.8.20}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.18
3
+ Version: 0.8.20
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -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},
@@ -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]
@@ -14,9 +14,13 @@ from api.services import (
14
14
  count_documents,
15
15
  document_columns,
16
16
  adjacent_documents,
17
+ parse_list_filter,
18
+ SLUG_TO_DOCTYPE,
17
19
  )
18
20
  from api.pdf import generate_pdf
19
21
  from api.auth import require_role
22
+ from api.list_values import distinct_list_values
23
+ from lambda_erp.database import get_db
20
24
 
21
25
  router = APIRouter(prefix="/documents", tags=["documents"])
22
26
 
@@ -24,7 +28,8 @@ _viewer = Depends(require_role("viewer"))
24
28
  _manager = Depends(require_role("manager"))
25
29
 
26
30
  # Query params the list endpoint interprets itself — everything else is treated
27
- # as an ad-hoc column=value filter (validated against the doctype's columns).
31
+ # as an ad-hoc field filter (validated against the doctype's columns). Plain
32
+ # keys are exact; ``field__contains`` is available for real text columns.
28
33
  _LIST_RESERVED = {
29
34
  "status", "party", "from_date", "to_date", "docstatus",
30
35
  "include_discarded", "limit", "offset", "order_by", "order",
@@ -64,16 +69,23 @@ def list_docs(
64
69
  if to_date:
65
70
  filters["to_date"] = to_date
66
71
 
67
- # Ad-hoc equality filters: any remaining query param that names a real column
68
- # of this doctype (e.g. /documents/activity?lead_id=LEAD-3316). Validate
69
- # against the live columns so an unknown field is a 400, never interpolated.
72
+ # Ad-hoc filters: any remaining query param must name a real column, with an
73
+ # optional __contains suffix for schema-confirmed text columns.
70
74
  columns = document_columns(doctype_slug)
75
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
71
76
  for key, value in request.query_params.items():
72
77
  if key in _LIST_RESERVED:
73
78
  continue
74
- if key not in columns:
79
+ try:
80
+ field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
81
+ except KeyError:
75
82
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
76
- filters[key] = value
83
+ except TypeError:
84
+ raise HTTPException(
85
+ status_code=400,
86
+ detail=f"Contains filter is only supported for text fields: {key}",
87
+ )
88
+ filters[field] = parsed_value
77
89
 
78
90
  # Which column from_date/to_date filter on. The frontend passes its declared
79
91
  # dateField so plugin doctypes get working date filters without a server-side
@@ -115,7 +127,13 @@ def list_docs(
115
127
  include_discarded=include_discarded, order_by=order_by, order=order,
116
128
  fields=projection)
117
129
  total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
118
- return {"rows": rows, "total": total, "limit": limit, "offset": offset}
130
+ return {
131
+ "rows": rows,
132
+ "total": total,
133
+ "limit": limit,
134
+ "offset": offset,
135
+ "text_fields": sorted(get_db()._get_text_columns(doctype)),
136
+ }
119
137
 
120
138
 
121
139
  @router.get("/{doctype_slug}/{name}/adjacent")
@@ -151,12 +169,20 @@ def adjacent_doc(
151
169
  if to_date:
152
170
  filters["to_date"] = to_date
153
171
  columns = document_columns(doctype_slug)
172
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
154
173
  for key, value in request.query_params.items():
155
174
  if key in _LIST_RESERVED:
156
175
  continue
157
- if key not in columns:
176
+ try:
177
+ field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
178
+ except KeyError:
158
179
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
159
- filters[key] = value
180
+ except TypeError:
181
+ raise HTTPException(
182
+ status_code=400,
183
+ detail=f"Contains filter is only supported for text fields: {key}",
184
+ )
185
+ filters[field] = parsed_value
160
186
  if date_field:
161
187
  filters["date_field"] = date_field
162
188
  if search:
@@ -183,6 +209,27 @@ def search_docs(doctype_slug: str, q: str = "", limit: int = Query(default=10, l
183
209
  return [{"name": d["name"]} for d in docs]
184
210
 
185
211
 
212
+ @router.get("/{doctype_slug}/filter-values")
213
+ def document_filter_values(
214
+ doctype_slug: str,
215
+ field: str,
216
+ q: str = "",
217
+ limit: int = Query(default=200, ge=1, le=200),
218
+ _user: dict = _viewer,
219
+ ):
220
+ """Distinct values for the generic list's field autocomplete."""
221
+ from api.services import SLUG_TO_DOCTYPE
222
+
223
+ doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
224
+ if not doctype:
225
+ raise HTTPException(status_code=404, detail=f"Unknown document type: {doctype_slug}")
226
+ try:
227
+ values = distinct_list_values(get_db(), doctype, field, q, limit)
228
+ except KeyError:
229
+ raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
230
+ return {"values": values}
231
+
232
+
186
233
  @router.get("/{doctype_slug}/{name}/pdf")
187
234
  def get_pdf(doctype_slug: str, name: str, _user: dict = _viewer):
188
235
  pdf_bytes = generate_pdf(doctype_slug, name)
@@ -13,9 +13,11 @@ 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
- _search_clause, _where_from_filters, master_search_columns, count_query_cached,
16
+ _filter_atom, _search_clause, _where_from_filters, count_query_cached,
17
+ master_search_columns, parse_list_filter,
17
18
  )
18
19
  from api.auth import require_role, require_non_public_manager
20
+ from api.list_values import distinct_list_values
19
21
 
20
22
  router = APIRouter(prefix="/masters", tags=["masters"])
21
23
 
@@ -300,7 +302,9 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
300
302
 
301
303
 
302
304
  # Query params list_masters consumes itself — anything else is treated as an
303
- # ad-hoc equality field filter (validated against the master's real columns).
305
+ # ad-hoc field filter (validated against the master's real columns). Plain keys
306
+ # are exact; text columns additionally support the explicit ``__contains``
307
+ # suffix used by Smart Search.
304
308
  _MASTER_LIST_RESERVED = {
305
309
  "limit", "offset", "include_disabled", "search", "search_fields", "fields",
306
310
  "order_by", "order",
@@ -328,10 +332,18 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
328
332
  for key, value in request.query_params.items():
329
333
  if key in _MASTER_LIST_RESERVED:
330
334
  continue
331
- if key not in columns:
335
+ try:
336
+ field, parsed_value = parse_list_filter(db, doctype, key, value)
337
+ except KeyError:
332
338
  raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
333
- where_parts.append(f'"{key}" = ?')
334
- params.append(value)
339
+ except TypeError:
340
+ raise HTTPException(
341
+ status_code=400,
342
+ detail=f"Contains filter is only supported for text fields: {key}",
343
+ )
344
+ clause, clause_params = _filter_atom(field, parsed_value)
345
+ where_parts.append(clause)
346
+ params.extend(clause_params)
335
347
 
336
348
  if search:
337
349
  requested = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
@@ -374,6 +386,8 @@ def _master_order_sql(column: str, direction: str, *, reverse: bool = False) ->
374
386
  def master_filter_values(
375
387
  master_type: str,
376
388
  field: str,
389
+ q: str = "",
390
+ limit: int = Query(default=200, ge=1, le=200),
377
391
  _user: dict = _viewer,
378
392
  ):
379
393
  """Distinct non-empty values of one column — populates a master-list filter
@@ -382,14 +396,11 @@ def master_filter_values(
382
396
  if not doctype:
383
397
  raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
384
398
  db = get_db()
385
- if field not in db._get_table_columns(doctype):
399
+ try:
400
+ values = distinct_list_values(db, doctype, field, q, limit)
401
+ except KeyError:
386
402
  raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
387
- rows = db.sql(
388
- f'SELECT DISTINCT "{field}" AS v FROM "{doctype}" '
389
- f'WHERE "{field}" IS NOT NULL AND "{field}" <> \'\' '
390
- f'ORDER BY "{field}" LIMIT 200'
391
- )
392
- return {"values": [r["v"] for r in rows]}
403
+ return {"values": values}
393
404
 
394
405
 
395
406
  @router.get("/{master_type}")
@@ -436,7 +447,13 @@ def list_masters(
436
447
  query += f" OFFSET {int(offset)}"
437
448
  rows = db.sql(query, params)
438
449
 
439
- return {"rows": rows, "total": total, "limit": limit, "offset": offset}
450
+ return {
451
+ "rows": rows,
452
+ "total": total,
453
+ "limit": limit,
454
+ "offset": offset,
455
+ "text_fields": sorted(db._get_text_columns(doctype)),
456
+ }
440
457
 
441
458
 
442
459
  @router.get("/{master_type}/search")
@@ -508,14 +508,14 @@ def _profit_and_loss(db, company=None, from_date=None, to_date=None):
508
508
  "account": acc["name"],
509
509
  "account_name": acc["account_name"],
510
510
  "root_type": acc["root_type"],
511
- "amount": abs(balance),
511
+ "amount": balance,
512
512
  }
513
513
  if acc["root_type"] == "Income":
514
514
  income_rows.append(row)
515
- total_income += abs(balance)
515
+ total_income += balance
516
516
  else:
517
517
  expense_rows.append(row)
518
- total_expense += abs(balance)
518
+ total_expense += balance
519
519
 
520
520
  return {
521
521
  "income": income_rows,
@@ -709,6 +709,7 @@ _FILTER_OPS = {
709
709
  "=": "=", "==": "=", "!=": "!=", "<>": "!=",
710
710
  ">": ">", "<": "<", ">=": ">=", "<=": "<=",
711
711
  "like": "LIKE", "not like": "NOT LIKE",
712
+ "contains": "CONTAINS",
712
713
  "is null": "IS NULL", "is not null": "IS NOT NULL",
713
714
  }
714
715
  _NULLARY_OPS = {"IS NULL", "IS NOT NULL"} # take no bound value
@@ -744,6 +745,13 @@ def _filter_atom(col: str, value):
744
745
  op = _resolve_op(value[0])
745
746
  if op in _NULLARY_OPS:
746
747
  return f'"{col}" {op}', [] # value ignored for IS [NOT] NULL
748
+ if op == "CONTAINS":
749
+ # Literal, case-insensitive substring search. Escape LIKE's two
750
+ # wildcard characters so a user-entered '%' or '_' keeps its
751
+ # ordinary meaning. The REST layer only permits this operator
752
+ # for schema-confirmed text columns.
753
+ escaped = str(value[1]).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
754
+ return f'LOWER("{col}") LIKE LOWER(?) ESCAPE \'\\\'', [f"%{escaped}%"]
747
755
  return f'"{col}" {op} ?', [value[1]]
748
756
  raise ValueError(
749
757
  f"Malformed filter for {col!r}: expected a scalar, [op], or [op, value]"
@@ -751,6 +759,39 @@ def _filter_atom(col: str, value):
751
759
  return f'"{col}" = ?', [value]
752
760
 
753
761
 
762
+ def parse_list_filter(db, doctype: str, raw_key: str, value):
763
+ """Parse one REST list-filter query parameter.
764
+
765
+ Plain ``field=value`` parameters retain their historical equality
766
+ semantics. ``field__contains=value`` becomes the internal whitelisted
767
+ ``contains`` operator, but only when the live schema says ``field`` is a
768
+ text column. Unknown fields raise ``KeyError``; applying ``contains`` to a
769
+ non-text field raises ``TypeError``. Callers translate both into HTTP 400.
770
+ """
771
+ suffix = "__contains"
772
+ field = raw_key[:-len(suffix)] if raw_key.endswith(suffix) else raw_key
773
+ if not field or field not in db._get_table_columns(doctype):
774
+ raise KeyError(field or raw_key)
775
+ if raw_key.endswith(suffix):
776
+ if field not in db._get_text_columns(doctype):
777
+ raise TypeError(field)
778
+ return field, ("contains", value)
779
+ return field, value
780
+
781
+
782
+ def _validate_typed_filters(db, doctype: str, filters: dict) -> None:
783
+ """Keep type-sensitive operators honest for REST, chat, and direct calls."""
784
+ text_columns = db._get_text_columns(doctype)
785
+ for field, value in filters.items():
786
+ if (
787
+ isinstance(value, (list, tuple))
788
+ and len(value) == 2
789
+ and str(value[0]).strip().lower() == "contains"
790
+ and field not in text_columns
791
+ ):
792
+ raise ValueError(f"Contains filter is only supported for text fields: {field}")
793
+
794
+
754
795
  def _where_from_filters(db_filters: dict):
755
796
  """(where_parts, params) for a dict of already-parsed equality/operator
756
797
  filters, via the whitelisted _filter_atom builder."""
@@ -806,6 +847,7 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
806
847
  db_filters[key] = value
807
848
 
808
849
  _exclude_discarded(db, doctype, db_filters, include_discarded)
850
+ _validate_typed_filters(db, doctype, db_filters)
809
851
 
810
852
  # Date range filtering via the doctype's primary date field
811
853
  date_field = _resolve_date_field(db, doctype, date_field_override)
@@ -961,6 +1003,7 @@ def _count_documents_uncached(doctype_slug: str, filters: dict = None, include_d
961
1003
  db_filters[key] = value
962
1004
 
963
1005
  _exclude_discarded(db, doctype, db_filters, include_discarded)
1006
+ _validate_typed_filters(db, doctype, db_filters)
964
1007
 
965
1008
  date_field = _resolve_date_field(db, doctype, date_field_override)
966
1009
  where_parts = []
@@ -1010,6 +1053,7 @@ def _filter_where(db, doctype: str, doctype_slug: str, filters: dict, include_di
1010
1053
  else:
1011
1054
  db_filters[key] = value
1012
1055
  _exclude_discarded(db, doctype, db_filters, include_discarded)
1056
+ _validate_typed_filters(db, doctype, db_filters)
1013
1057
 
1014
1058
  date_field = _resolve_date_field(db, doctype, date_field_override)
1015
1059
  where_parts, params = [], []
@@ -167,7 +167,7 @@ export const api = {
167
167
 
168
168
  // Documents
169
169
  listDocuments: (doctype: string, params?: Record<string, string | number | undefined>) =>
170
- request<{ rows: any[]; total: number; limit: number; offset: number }>(
170
+ request<{ rows: any[]; total: number; limit: number; offset: number; text_fields: string[] }>(
171
171
  `/documents/${doctype}${qs(params)}`,
172
172
  ),
173
173
 
@@ -243,16 +243,24 @@ export const api = {
243
243
 
244
244
  // Masters
245
245
  listMasters: (type: string, params?: Record<string, string | number | undefined>) =>
246
- request<{ rows: any[]; total: number; limit: number; offset: number }>(
246
+ request<{ rows: any[]; total: number; limit: number; offset: number; text_fields: string[] }>(
247
247
  `/masters/${type}${qs(params)}`,
248
248
  ),
249
249
 
250
250
  searchMasters: (type: string, q: string) =>
251
251
  request<any[]>(`/masters/${type}/search?q=${encodeURIComponent(q)}`),
252
252
 
253
- // Distinct values of one master column — populates a list filter dropdown.
254
- masterFilterValues: (type: string, field: string) =>
255
- request<{ values: any[] }>(`/masters/${type}/filter-values?field=${encodeURIComponent(field)}`),
253
+ // Distinct values of one list column — used by dropdown filters and the
254
+ // shared field-aware search autocomplete.
255
+ masterFilterValues: (type: string, field: string, q = "", limit = 200) =>
256
+ request<{ values: any[] }>(
257
+ `/masters/${type}/filter-values${qs({ field, q: q || undefined, limit })}`,
258
+ ),
259
+
260
+ documentFilterValues: (doctype: string, field: string, q = "", limit = 200) =>
261
+ request<{ values: any[] }>(
262
+ `/documents/${doctype}/filter-values${qs({ field, q: q || undefined, limit })}`,
263
+ ),
256
264
 
257
265
  searchDocuments: (doctype: string, q: string) =>
258
266
  request<any[]>(`/documents/${doctype}/search?q=${encodeURIComponent(q)}`),
@@ -76,26 +76,27 @@ class JournalEntry(Document):
76
76
  self._set_totals()
77
77
 
78
78
  def _normalize_amounts(self):
79
- """Keep each row's base debit/credit and its *_in_account_currency twin in
80
- sync. lambda-erp has no per-line exchange rate (base == account currency),
81
- so back-fill whichever side is missing. Without this, a row created with
82
- the amount ONLY in debit_in_account_currency (as the chat's create path
83
- did) leaves base debit/credit at 0 it passes the 0==0 balance check,
84
- submits "successfully", and posts NOTHING (GL is built from the base
85
- fields, and _get_gl_entries skips 0/0 rows). Idempotent."""
79
+ """Back-fill only missing base/account-currency amounts.
80
+
81
+ ``None`` or an absent field means the caller supplied only the matching
82
+ amount in the other currency. An explicit zero is meaningful: period-end
83
+ valuation entries move the base carrying value without changing the
84
+ foreign-currency quantity, so their *_in_account_currency amount must
85
+ remain zero.
86
+ """
86
87
  for row in self.get("accounts") or []:
87
- debit = flt(row.get("debit"))
88
- credit = flt(row.get("credit"))
89
- dr_ac = flt(row.get("debit_in_account_currency"))
90
- cr_ac = flt(row.get("credit_in_account_currency"))
91
- if not debit and dr_ac:
92
- row["debit"] = dr_ac
93
- if not credit and cr_ac:
94
- row["credit"] = cr_ac
95
- if not dr_ac and debit:
96
- row["debit_in_account_currency"] = debit
97
- if not cr_ac and credit:
98
- row["credit_in_account_currency"] = credit
88
+ debit = row.get("debit")
89
+ credit = row.get("credit")
90
+ dr_ac = row.get("debit_in_account_currency")
91
+ cr_ac = row.get("credit_in_account_currency")
92
+ if debit is None and dr_ac is not None:
93
+ row["debit"] = flt(dr_ac)
94
+ if credit is None and cr_ac is not None:
95
+ row["credit"] = flt(cr_ac)
96
+ if dr_ac is None and debit is not None:
97
+ row["debit_in_account_currency"] = flt(debit)
98
+ if cr_ac is None and credit is not None:
99
+ row["credit_in_account_currency"] = flt(credit)
99
100
 
100
101
  def _validate_debit_credit(self):
101
102
  """Ensure total debits == total credits.
@@ -235,21 +236,33 @@ class JournalEntry(Document):
235
236
  journal entries map 1:1 from account rows to GL entries.
236
237
  """
237
238
  gl_entries = []
239
+ db = get_db()
240
+ base_currency = db.get_value("Company", self.company, "default_currency") or "USD"
238
241
 
239
242
  for row in self.get("accounts"):
240
243
  if not (flt(row.get("debit")) or flt(row.get("credit"))):
241
244
  continue
242
245
 
246
+ debit = flt(row.get("debit"), 2)
247
+ credit = flt(row.get("credit"), 2)
248
+ dr_ac = row.get("debit_in_account_currency")
249
+ cr_ac = row.get("credit_in_account_currency")
250
+ account_currency = (
251
+ db.get_value("Account", row.get("account"), "account_currency")
252
+ or base_currency
253
+ )
254
+
243
255
  gl_entries.append(
244
256
  _dict(
245
257
  account=row.get("account"),
246
258
  party_type=row.get("party_type"),
247
259
  party=row.get("party"),
248
260
  cost_center=row.get("cost_center"),
249
- debit=flt(row.get("debit"), 2),
250
- credit=flt(row.get("credit"), 2),
251
- debit_in_account_currency=flt(row.get("debit_in_account_currency") or row.get("debit"), 2),
252
- credit_in_account_currency=flt(row.get("credit_in_account_currency") or row.get("credit"), 2),
261
+ debit=debit,
262
+ credit=credit,
263
+ debit_in_account_currency=flt(debit if dr_ac is None else dr_ac, 2),
264
+ credit_in_account_currency=flt(credit if cr_ac is None else cr_ac, 2),
265
+ account_currency=account_currency,
253
266
  against_voucher_type=row.get("reference_doctype") or row.get("reference_type"),
254
267
  against_voucher=row.get("reference_name"),
255
268
  voucher_type=self.DOCTYPE,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.18"
3
+ version = "0.8.20"
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
File without changes
File without changes