lambda-erp 0.8.8__tar.gz → 0.8.9__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 (88) hide show
  1. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/masters.py +78 -29
  3. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/services.py +32 -0
  4. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/src/api/client.ts +4 -0
  5. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/pyproject.toml +1 -1
  6. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/.gitignore +0 -0
  7. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/LICENSE +0 -0
  8. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/README.md +0 -0
  9. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/__init__.py +0 -0
  10. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/attachments.py +0 -0
  11. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/auth.py +0 -0
  12. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/chat.py +0 -0
  14. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/deps.py +0 -0
  16. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/errors.py +0 -0
  17. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/main.py +0 -0
  18. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/oauth.py +0 -0
  19. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/pdf.py +0 -0
  20. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/providers.py +0 -0
  21. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/availability.py +0 -0
  27. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/chat_api.py +0 -0
  29. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/documents.py +0 -0
  30. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/mcp.py +0 -0
  31. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/proposals.py +0 -0
  32. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/reports.py +0 -0
  33. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/setup.py +0 -0
  34. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/templates/document.html +0 -0
  35. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/README.md +0 -0
  39. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/__init__.py +0 -0
  40. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/__init__.py +0 -0
  41. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/bank_transaction.py +0 -0
  42. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/budget.py +0 -0
  43. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  44. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/general_ledger.py +0 -0
  45. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/journal_entry.py +0 -0
  46. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/payment_entry.py +0 -0
  47. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/pos_invoice.py +0 -0
  48. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/purchase_invoice.py +0 -0
  49. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/revaluation.py +0 -0
  50. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/sales_invoice.py +0 -0
  51. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/__init__.py +0 -0
  52. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/engine.py +0 -0
  53. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/pack.py +0 -0
  54. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  55. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  56. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  57. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  58. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  59. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  60. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/profiles.py +0 -0
  61. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/spine.py +0 -0
  62. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/subscription.py +0 -0
  63. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/__init__.py +0 -0
  64. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/asset.py +0 -0
  65. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/reservation.py +0 -0
  66. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/buying/__init__.py +0 -0
  67. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/buying/purchase_order.py +0 -0
  68. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/__init__.py +0 -0
  69. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/currency.py +0 -0
  70. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/defaults.py +0 -0
  71. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/pricing_rule.py +0 -0
  72. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  73. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/database.py +0 -0
  74. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/exceptions.py +0 -0
  75. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/hooks.py +0 -0
  76. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/model.py +0 -0
  77. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/__init__.py +0 -0
  78. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/proposal.py +0 -0
  79. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/quotation.py +0 -0
  80. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/sales_order.py +0 -0
  81. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/simulation.py +0 -0
  82. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/__init__.py +0 -0
  83. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/delivery_note.py +0 -0
  84. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/purchase_receipt.py +0 -0
  85. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/stock_entry.py +0 -0
  86. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/stock_ledger.py +0 -0
  87. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/utils.py +0 -0
  88. {lambda_erp-0.8.8 → lambda_erp-0.8.9}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.8
3
+ Version: 0.8.9
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -2,13 +2,16 @@
2
2
 
3
3
  import re
4
4
 
5
- from fastapi import APIRouter, Depends, HTTPException, Query
5
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
6
6
  from lambda_erp.database import get_db
7
7
  from lambda_erp.utils import _dict
8
8
  # The per-type registries live in api.services so `register_master` can extend
9
9
  # them; imported here (not moved) so existing `from api.routers.masters import
10
10
  # MASTER_IDENTITY_ALIAS`-style imports keep working.
11
- from api.services import MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS
11
+ from api.services import (
12
+ MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS,
13
+ _search_clause, _where_from_filters, master_search_columns, count_query_cached,
14
+ )
12
15
  from api.auth import require_role, require_non_public_manager
13
16
 
14
17
  router = APIRouter(prefix="/masters", tags=["masters"])
@@ -267,45 +270,91 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
267
270
  return _echo_identity_alias(master_type, rows[0])
268
271
 
269
272
 
273
+ # Query params list_masters consumes itself — anything else is treated as an
274
+ # ad-hoc equality field filter (validated against the master's real columns).
275
+ _MASTER_LIST_RESERVED = {
276
+ "limit", "offset", "include_disabled", "search", "search_fields",
277
+ }
278
+
279
+
280
+ @router.get("/{master_type}/filter-values")
281
+ def master_filter_values(
282
+ master_type: str,
283
+ field: str,
284
+ _user: dict = _viewer,
285
+ ):
286
+ """Distinct non-empty values of one column — populates a master-list filter
287
+ dropdown. Capped; the field is validated against real columns."""
288
+ doctype, _ = _get_table(master_type)
289
+ if not doctype:
290
+ raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
291
+ db = get_db()
292
+ if field not in db._get_table_columns(doctype):
293
+ raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
294
+ rows = db.sql(
295
+ f'SELECT DISTINCT "{field}" AS v FROM "{doctype}" '
296
+ f'WHERE "{field}" IS NOT NULL AND "{field}" <> \'\' '
297
+ f'ORDER BY "{field}" LIMIT 200'
298
+ )
299
+ return {"values": [r["v"] for r in rows]}
300
+
301
+
270
302
  @router.get("/{master_type}")
271
303
  def list_masters(
272
304
  master_type: str,
305
+ request: Request,
273
306
  limit: int = Query(default=50, le=1000),
274
307
  offset: int = Query(default=0, ge=0),
275
308
  include_disabled: bool = False,
309
+ search: str | None = None,
310
+ search_fields: str | None = None,
276
311
  _user: dict = _viewer,
277
312
  ):
278
313
  doctype, _ = _get_table(master_type)
279
314
  if not doctype:
280
315
  return {"detail": f"Unknown master type: {master_type}"}
281
316
  db = get_db()
282
- filters = None if include_disabled else _with_active_filter(db, doctype)
283
-
284
- # Build WHERE clause and params
285
- where_parts = []
286
- params = []
287
- if filters:
288
- for k, v in filters.items():
289
- if isinstance(v, (list, tuple)) and len(v) == 2:
290
- op, val = v
291
- where_parts.append(f'"{k}" {op} ?')
292
- params.append(val)
293
- else:
294
- where_parts.append(f'"{k}" = ?')
295
- params.append(v)
296
-
297
- count_query = f'SELECT COUNT(*) as c FROM "{doctype}"'
298
- if where_parts:
299
- count_query += " WHERE " + " AND ".join(where_parts)
300
- total_rows = db.sql(count_query, params)
301
- total = int(total_rows[0]["c"]) if total_rows else 0
302
-
303
- query = f'SELECT * FROM "{doctype}"'
304
- if where_parts:
305
- query += " WHERE " + " AND ".join(where_parts)
306
- # Deterministic order: without it Postgres returns pages in arbitrary order,
307
- # which breaks OFFSET pagination and the /adjacent prev/next contract.
308
- query += f" ORDER BY name LIMIT {int(limit)}"
317
+ columns = db._get_table_columns(doctype)
318
+
319
+ where_parts: list = []
320
+ params: list = []
321
+
322
+ # Active (non-disabled) filter, unless the caller opts in to disabled rows.
323
+ active = None if include_disabled else _with_active_filter(db, doctype)
324
+ if active:
325
+ wp, ps = _where_from_filters(active)
326
+ where_parts += wp
327
+ params += ps
328
+
329
+ # Ad-hoc equality field filters: any remaining query param naming a real
330
+ # column (e.g. /masters/customer?customer_group=Commercial). Unknown field
331
+ # -> 400 (a filter on a missing column is a client bug, never interpolated).
332
+ for key, value in request.query_params.items():
333
+ if key in _MASTER_LIST_RESERVED:
334
+ continue
335
+ if key not in columns:
336
+ raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
337
+ where_parts.append(f'"{key}" = ?')
338
+ params.append(value)
339
+
340
+ # Free-text search defaults to the master's text columns when the caller
341
+ # doesn't name search_fields (so a plain ?search=... just works).
342
+ if search:
343
+ sf = [f.strip() for f in (search_fields or "").split(",")
344
+ if f.strip() and f.strip() in columns]
345
+ if not sf:
346
+ sf = master_search_columns(db, doctype)
347
+ clause, sp = _search_clause(db, doctype, master_type, search, sf)
348
+ if clause:
349
+ where_parts.append(clause)
350
+ params += sp
351
+
352
+ where = (" WHERE " + " AND ".join(where_parts)) if where_parts else ""
353
+ total = count_query_cached(f'SELECT COUNT(*) as c FROM "{doctype}"{where}', params)
354
+
355
+ # Deterministic order by the `name` PK (already indexed) — required for
356
+ # OFFSET pagination and the /adjacent prev/next contract.
357
+ query = f'SELECT * FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
309
358
  if offset:
310
359
  query += f" OFFSET {int(offset)}"
311
360
  rows = db.sql(query, params)
@@ -759,6 +759,38 @@ def invalidate_count_cache() -> None:
759
759
  _COUNT_CACHE.clear()
760
760
 
761
761
 
762
+ def count_query_cached(query: str, params=None) -> int:
763
+ """Result of a single-column COUNT(*) query, cached per (query, params) with
764
+ the same policy as count_documents — until the next write (write-generation)
765
+ or the TTL. Lets the masters list share the exact-but-cached count."""
766
+ key = ("q", query, tuple(str(p) for p in (params or [])))
767
+ gen = get_write_generation()
768
+ now_mono = time.monotonic()
769
+ hit = _COUNT_CACHE.get(key)
770
+ if hit is not None and hit[1] == gen and now_mono - hit[0] < _COUNT_CACHE_TTL:
771
+ return hit[2]
772
+ rows = get_db().sql(query, params or [])
773
+ total = int(next(iter(rows[0].values()))) if rows else 0
774
+ if len(_COUNT_CACHE) >= _COUNT_CACHE_MAX:
775
+ _COUNT_CACHE.clear()
776
+ _COUNT_CACHE[key] = (now_mono, gen, total)
777
+ return total
778
+
779
+
780
+ # Audit / plumbing columns never worth matching a free-text list search against.
781
+ _MASTER_SEARCH_SKIP = {
782
+ "name", "creation", "modified", "owner", "modified_by", "disabled",
783
+ "idx", "parent", "parenttype", "parentfield", "docstatus",
784
+ }
785
+
786
+
787
+ def master_search_columns(db, doctype: str) -> list:
788
+ """Text columns a free-text master-list search matches by default — schema-
789
+ discovered (a new text field becomes searchable automatically), minus audit
790
+ noise. Mirrors the chat search_masters default so both search the same set."""
791
+ return sorted(db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP)
792
+
793
+
762
794
  def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
763
795
  """Exact count of documents matching the filters, cached per filter-set until
764
796
  the next write (write-generation) or the TTL, whichever comes first."""
@@ -250,6 +250,10 @@ export const api = {
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)}`),
256
+
253
257
  searchDocuments: (doctype: string, q: string) =>
254
258
  request<any[]>(`/documents/${doctype}/search?q=${encodeURIComponent(q)}`),
255
259
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.8"
3
+ version = "0.8.9"
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
File without changes
File without changes
File without changes
File without changes
File without changes