lambda-erp 0.8.5__tar.gz → 0.8.7__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.5 → lambda_erp-0.8.7}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/documents.py +18 -6
  3. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/services.py +45 -2
  4. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/database.py +81 -1
  5. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/pyproject.toml +1 -1
  6. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/.gitignore +0 -0
  7. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/LICENSE +0 -0
  8. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/README.md +0 -0
  9. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/__init__.py +0 -0
  10. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/attachments.py +0 -0
  11. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/auth.py +0 -0
  12. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/chat.py +0 -0
  14. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/deps.py +0 -0
  16. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/errors.py +0 -0
  17. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/main.py +0 -0
  18. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/oauth.py +0 -0
  19. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/pdf.py +0 -0
  20. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/providers.py +0 -0
  21. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/availability.py +0 -0
  27. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/chat_api.py +0 -0
  29. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/masters.py +0 -0
  30. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/mcp.py +0 -0
  31. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/proposals.py +0 -0
  32. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/reports.py +0 -0
  33. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/routers/setup.py +0 -0
  34. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/templates/document.html +0 -0
  35. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/frontend/README.md +0 -0
  39. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/frontend/src/api/client.ts +0 -0
  40. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/__init__.py +0 -0
  41. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/__init__.py +0 -0
  42. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/bank_transaction.py +0 -0
  43. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/budget.py +0 -0
  44. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  45. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/general_ledger.py +0 -0
  46. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/journal_entry.py +0 -0
  47. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/payment_entry.py +0 -0
  48. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/pos_invoice.py +0 -0
  49. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/purchase_invoice.py +0 -0
  50. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/revaluation.py +0 -0
  51. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/sales_invoice.py +0 -0
  52. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/__init__.py +0 -0
  53. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/engine.py +0 -0
  54. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/pack.py +0 -0
  55. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  56. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  57. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  58. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  59. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  60. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  61. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/profiles.py +0 -0
  62. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/setup/spine.py +0 -0
  63. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/accounting/subscription.py +0 -0
  64. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/assets/__init__.py +0 -0
  65. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/assets/asset.py +0 -0
  66. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/assets/reservation.py +0 -0
  67. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/buying/__init__.py +0 -0
  68. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/buying/purchase_order.py +0 -0
  69. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/controllers/__init__.py +0 -0
  70. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/controllers/currency.py +0 -0
  71. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/controllers/defaults.py +0 -0
  72. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/controllers/pricing_rule.py +0 -0
  73. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  74. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/exceptions.py +0 -0
  75. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/hooks.py +0 -0
  76. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/model.py +0 -0
  77. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/selling/__init__.py +0 -0
  78. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/selling/proposal.py +0 -0
  79. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/selling/quotation.py +0 -0
  80. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/selling/sales_order.py +0 -0
  81. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/simulation.py +0 -0
  82. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/stock/__init__.py +0 -0
  83. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/stock/delivery_note.py +0 -0
  84. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/stock/purchase_receipt.py +0 -0
  85. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/stock/stock_entry.py +0 -0
  86. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/stock/stock_ledger.py +0 -0
  87. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/lambda_erp/utils.py +0 -0
  88. {lambda_erp-0.8.5 → lambda_erp-0.8.7}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.5
3
+ Version: 0.8.7
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -28,7 +28,7 @@ _manager = Depends(require_role("manager"))
28
28
  _LIST_RESERVED = {
29
29
  "status", "party", "from_date", "to_date", "docstatus",
30
30
  "include_discarded", "limit", "offset", "order_by", "order",
31
- "date_field", "search", "search_fields",
31
+ "date_field", "search", "search_fields", "fields",
32
32
  }
33
33
 
34
34
 
@@ -47,6 +47,7 @@ def list_docs(
47
47
  date_field: str | None = None,
48
48
  search: str | None = None,
49
49
  search_fields: str | None = None,
50
+ fields: str | None = None,
50
51
  limit: int = Query(default=50, le=500),
51
52
  offset: int = Query(default=0, ge=0),
52
53
  _user: dict = _viewer,
@@ -87,20 +88,31 @@ def list_docs(
87
88
  # real columns, mirroring the arbitrary-filter / order_by contract.
88
89
  if search:
89
90
  filters["search"] = search
90
- fields = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
91
- bad = [f for f in fields if f not in columns]
91
+ sf = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
92
+ bad = [f for f in sf if f not in columns]
92
93
  if bad:
93
94
  raise HTTPException(status_code=400, detail=f"Unknown search field(s): {', '.join(bad)}")
94
- if fields:
95
- filters["search_fields"] = fields
95
+ if sf:
96
+ filters["search_fields"] = sf
96
97
 
97
98
  if order_by is not None and order_by not in columns:
98
99
  raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
99
100
  if order.lower() not in ("asc", "desc"):
100
101
  raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
101
102
 
103
+ # Column projection: the frontend sends the columns its list view actually
104
+ # renders, so a wide doctype doesn't ship every column for a few-column grid.
105
+ # Validated against real columns (name is always kept, added downstream).
106
+ projection = None
107
+ if fields:
108
+ projection = [f.strip() for f in fields.split(",") if f.strip()]
109
+ bad = [f for f in projection if f not in columns]
110
+ if bad:
111
+ raise HTTPException(status_code=400, detail=f"Unknown field(s): {', '.join(bad)}")
112
+
102
113
  rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset,
103
- include_discarded=include_discarded, order_by=order_by, order=order)
114
+ include_discarded=include_discarded, order_by=order_by, order=order,
115
+ fields=projection)
104
116
  total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
105
117
  return {"rows": rows, "total": total, "limit": limit, "offset": offset}
106
118
 
@@ -1,7 +1,9 @@
1
1
  """Bridge between FastAPI request data and lambda_erp Document classes."""
2
2
 
3
+ import time
4
+
3
5
  from lambda_erp.utils import _dict, now
4
- from lambda_erp.database import get_db
6
+ from lambda_erp.database import get_db, get_write_generation
5
7
 
6
8
  from lambda_erp.selling.quotation import (
7
9
  Quotation, make_sales_order,
@@ -717,8 +719,10 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
717
719
  else:
718
720
  rows = db.get_all(
719
721
  doctype,
722
+ # Project to the requested columns at the DB (not just *-then-trim) so a
723
+ # wide table doesn't ship every column for a few-column list view.
724
+ fields=(sorted(_projection_columns(doctype_slug, fields)) if fields else ["*"]),
720
725
  filters=db_filters if db_filters else None,
721
- fields=["*"],
722
726
  order_by=order_clause,
723
727
  limit=limit,
724
728
  )
@@ -732,7 +736,46 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
732
736
  return rows
733
737
 
734
738
 
739
+ # Exact list counts are cached per (doctype, filters). The count is the one O(N)
740
+ # part of a list load — a full scan even with the creation index, since the
741
+ # discard/search predicates aren't covered — and paging within a view repeats the
742
+ # identical count. The cache stays EXACT (unlike a reltuples estimate, which would
743
+ # make the page-jump "of N" bound approximate): every entry is tagged with the DB
744
+ # write-generation, so any in-process write invalidates it immediately; the TTL is
745
+ # only a backstop for cross-replica writes this process didn't see.
746
+ _COUNT_CACHE: dict = {}
747
+ _COUNT_CACHE_TTL = 60.0
748
+ _COUNT_CACHE_MAX = 512
749
+
750
+
751
+ def _count_cache_key(doctype_slug, filters, include_discarded):
752
+ items = tuple(sorted((k, str(v)) for k, v in (filters or {}).items()))
753
+ return (doctype_slug, items, bool(include_discarded))
754
+
755
+
756
+ def invalidate_count_cache() -> None:
757
+ """Clear the list-count cache (writes already invalidate via the write-
758
+ generation; call this only to force-drop cross-replica staleness early)."""
759
+ _COUNT_CACHE.clear()
760
+
761
+
735
762
  def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
763
+ """Exact count of documents matching the filters, cached per filter-set until
764
+ the next write (write-generation) or the TTL, whichever comes first."""
765
+ key = _count_cache_key(doctype_slug, filters, include_discarded)
766
+ gen = get_write_generation()
767
+ now_mono = time.monotonic()
768
+ hit = _COUNT_CACHE.get(key)
769
+ if hit is not None and hit[1] == gen and now_mono - hit[0] < _COUNT_CACHE_TTL:
770
+ return hit[2]
771
+ result = _count_documents_uncached(doctype_slug, filters, include_discarded)
772
+ if len(_COUNT_CACHE) >= _COUNT_CACHE_MAX:
773
+ _COUNT_CACHE.clear()
774
+ _COUNT_CACHE[key] = (now_mono, gen, result)
775
+ return result
776
+
777
+
778
+ def _count_documents_uncached(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
736
779
  """Count documents matching the filters (ignores limit/offset)."""
737
780
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
738
781
  if not doctype:
@@ -96,6 +96,28 @@ def _pg_row_factory(cursor):
96
96
  return make
97
97
 
98
98
 
99
+ _write_generation = 0
100
+
101
+
102
+ def _stmt_is_read(sql: str) -> bool:
103
+ s = sql.lstrip().lower()
104
+ return s.startswith("select") and "for update" not in s and "for share" not in s
105
+
106
+
107
+ def _note_write(sql: str) -> None:
108
+ """Bump the process write-generation on any non-SELECT statement. The list
109
+ count cache keys on it, so an in-process write invalidates cached counts at
110
+ once (cross-process staleness stays bounded by the cache TTL)."""
111
+ global _write_generation
112
+ if not _stmt_is_read(sql):
113
+ _write_generation += 1
114
+
115
+
116
+ def get_write_generation() -> int:
117
+ """Monotonic per-process write counter — see _note_write."""
118
+ return _write_generation
119
+
120
+
99
121
  class _PgConn:
100
122
  """Adapts a psycopg (v3) connection to the sqlite3 connection API the code
101
123
  uses. autocommit is off so the existing `_in_transaction` + single commit()
@@ -150,6 +172,7 @@ class _PgConn:
150
172
  self._safe_rollback()
151
173
  raise
152
174
  self._maybe_release(sql)
175
+ _note_write(sql)
153
176
  return cur
154
177
 
155
178
  def executemany(self, sql, seq_params):
@@ -161,6 +184,7 @@ class _PgConn:
161
184
  self._safe_rollback()
162
185
  raise
163
186
  self._dirty = True
187
+ _note_write(sql)
164
188
  return cur
165
189
 
166
190
  def _safe_rollback(self):
@@ -185,6 +209,20 @@ class _PgConn:
185
209
  return self._raw.cursor(*args, **kwargs)
186
210
 
187
211
 
212
+ class _SqliteConn(sqlite3.Connection):
213
+ """SQLite connection that bumps the write-generation like _PgConn does, so the
214
+ list count cache invalidates on writes under both backends (SQLite is a raw
215
+ connection where Postgres is wrapped)."""
216
+
217
+ def execute(self, sql, *args, **kwargs):
218
+ _note_write(sql)
219
+ return super().execute(sql, *args, **kwargs)
220
+
221
+ def executemany(self, sql, *args, **kwargs):
222
+ _note_write(sql)
223
+ return super().executemany(sql, *args, **kwargs)
224
+
225
+
188
226
  class _NullLock:
189
227
  """No-op context manager used when SQLite-level locking is sufficient."""
190
228
 
@@ -240,7 +278,7 @@ class Database:
240
278
  return conn
241
279
 
242
280
  def _open_sqlite_conn(self):
243
- conn = sqlite3.connect(self.db_path, check_same_thread=False)
281
+ conn = sqlite3.connect(self.db_path, check_same_thread=False, factory=_SqliteConn)
244
282
  conn.row_factory = sqlite3.Row
245
283
  conn.execute(f"PRAGMA journal_mode={_journal_mode()}")
246
284
  conn.execute("PRAGMA foreign_keys=ON")
@@ -1457,6 +1495,7 @@ class Database:
1457
1495
  self.conn.execute(self._ddl(stmt))
1458
1496
  self.conn.commit()
1459
1497
  self._migrate()
1498
+ self._ensure_list_indexes()
1460
1499
 
1461
1500
  def _ddl(self, stmt: str) -> str:
1462
1501
  """Translate SQLite DDL to the active dialect. No-op for SQLite.
@@ -1605,6 +1644,47 @@ class Database:
1605
1644
  # covers the happy path; migrations are only needed for drift.
1606
1645
  self.conn.rollback()
1607
1646
 
1647
+ def _all_user_tables(self) -> list:
1648
+ """Base tables in the active DB, minus internal bookkeeping."""
1649
+ if self.dialect == "postgres":
1650
+ rows = self.conn.execute(
1651
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
1652
+ ).fetchall()
1653
+ else:
1654
+ rows = self.conn.execute(
1655
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
1656
+ ).fetchall()
1657
+ return [
1658
+ r[0] for r in rows
1659
+ if not str(r[0]).startswith("_") and not str(r[0]).startswith("sqlite_")
1660
+ ]
1661
+
1662
+ def _ensure_list_indexes(self) -> None:
1663
+ """Index `creation` on every table that has it — the generic list-speed fix.
1664
+
1665
+ Every document/master list defaults to `ORDER BY creation DESC`, but tables
1666
+ ship with only a `name` primary key. So once a table grows large, each list
1667
+ load does a full sequential scan + top-N sort: the Leads page at 89K rows
1668
+ measured ~340ms; ~0.3ms with this index. Reconciled here (idempotent,
1669
+ CREATE INDEX IF NOT EXISTS) so it covers core, plugin AND future doctypes,
1670
+ and back-fills existing tables on the next deploy. `creation` is a
1671
+ fixed-width ISO string, so a plain btree sorts chronologically and the
1672
+ planner scans it backward for DESC. Best-effort per table — a failure on one
1673
+ (e.g. two replicas racing to build it at boot) never blocks startup.
1674
+ """
1675
+ for table in self._all_user_tables():
1676
+ try:
1677
+ if "creation" not in self._get_table_columns(table):
1678
+ continue
1679
+ except Exception:
1680
+ continue
1681
+ idx = f"ix_{table.replace(' ', '_')}_creation"
1682
+ try:
1683
+ self.conn.execute(f'CREATE INDEX IF NOT EXISTS "{idx}" ON "{table}" ("creation")')
1684
+ self.conn.commit()
1685
+ except Exception:
1686
+ self.conn.rollback()
1687
+
1608
1688
  # --- Core query interface (mirrors framework.db) ---
1609
1689
 
1610
1690
  def sql(self, query, values=None, as_dict=True):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.5"
3
+ version = "0.8.7"
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