lambda-erp 0.8.6__tar.gz → 0.8.8__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.6 → lambda_erp-0.8.8}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/documents.py +8 -7
  3. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/services.py +16 -14
  4. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/database.py +39 -1
  5. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/pyproject.toml +1 -1
  6. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/.gitignore +0 -0
  7. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/LICENSE +0 -0
  8. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/README.md +0 -0
  9. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/__init__.py +0 -0
  10. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/attachments.py +0 -0
  11. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/auth.py +0 -0
  12. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/chat.py +0 -0
  14. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/deps.py +0 -0
  16. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/errors.py +0 -0
  17. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/main.py +0 -0
  18. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/oauth.py +0 -0
  19. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/pdf.py +0 -0
  20. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/providers.py +0 -0
  21. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/availability.py +0 -0
  27. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/chat_api.py +0 -0
  29. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/masters.py +0 -0
  30. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/mcp.py +0 -0
  31. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/proposals.py +0 -0
  32. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/reports.py +0 -0
  33. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/routers/setup.py +0 -0
  34. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/templates/document.html +0 -0
  35. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/frontend/README.md +0 -0
  39. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/frontend/src/api/client.ts +0 -0
  40. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/__init__.py +0 -0
  41. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/__init__.py +0 -0
  42. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/bank_transaction.py +0 -0
  43. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/budget.py +0 -0
  44. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  45. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/general_ledger.py +0 -0
  46. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/journal_entry.py +0 -0
  47. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/payment_entry.py +0 -0
  48. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/pos_invoice.py +0 -0
  49. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/purchase_invoice.py +0 -0
  50. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/revaluation.py +0 -0
  51. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/sales_invoice.py +0 -0
  52. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/__init__.py +0 -0
  53. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/engine.py +0 -0
  54. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/pack.py +0 -0
  55. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  56. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  57. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  58. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  59. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  60. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  61. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/profiles.py +0 -0
  62. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/setup/spine.py +0 -0
  63. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/accounting/subscription.py +0 -0
  64. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/assets/__init__.py +0 -0
  65. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/assets/asset.py +0 -0
  66. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/assets/reservation.py +0 -0
  67. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/buying/__init__.py +0 -0
  68. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/buying/purchase_order.py +0 -0
  69. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/controllers/__init__.py +0 -0
  70. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/controllers/currency.py +0 -0
  71. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/controllers/defaults.py +0 -0
  72. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/controllers/pricing_rule.py +0 -0
  73. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  74. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/exceptions.py +0 -0
  75. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/hooks.py +0 -0
  76. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/model.py +0 -0
  77. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/selling/__init__.py +0 -0
  78. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/selling/proposal.py +0 -0
  79. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/selling/quotation.py +0 -0
  80. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/selling/sales_order.py +0 -0
  81. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/simulation.py +0 -0
  82. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/stock/__init__.py +0 -0
  83. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/stock/delivery_note.py +0 -0
  84. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/stock/purchase_receipt.py +0 -0
  85. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/stock/stock_entry.py +0 -0
  86. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/stock/stock_ledger.py +0 -0
  87. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/lambda_erp/utils.py +0 -0
  88. {lambda_erp-0.8.6 → lambda_erp-0.8.8}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.6
3
+ Version: 0.8.8
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -100,15 +100,16 @@ def list_docs(
100
100
  if order.lower() not in ("asc", "desc"):
101
101
  raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
102
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).
103
+ # Column projection: the frontend sends the columns its list view renders, so
104
+ # a wide doctype doesn't ship every column for a few-column grid. This is
105
+ # ADVISORY unknown columns are silently dropped, never a 400: the frontend
106
+ # appends display-only fields (currency, party_type) that not every doctype
107
+ # has, and a projection can only ever narrow the response (name is kept
108
+ # downstream), so an unknown field is harmless. (Contrast order_by/search_
109
+ # fields, which affect query correctness and stay strict.)
106
110
  projection = None
107
111
  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
+ projection = [f.strip() for f in fields.split(",") if f.strip() and f.strip() in columns]
112
113
 
113
114
  rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset,
114
115
  include_discarded=include_discarded, order_by=order_by, order=order,
@@ -3,7 +3,7 @@
3
3
  import time
4
4
 
5
5
  from lambda_erp.utils import _dict, now
6
- from lambda_erp.database import get_db
6
+ from lambda_erp.database import get_db, get_write_generation
7
7
 
8
8
  from lambda_erp.selling.quotation import (
9
9
  Quotation, make_sales_order,
@@ -736,13 +736,13 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
736
736
  return rows
737
737
 
738
738
 
739
- # Exact list counts are cached briefly per (doctype, filters). The count is the
740
- # one O(N) part of a list load — a full scan even with the creation index, since
741
- # the discard/search predicates aren't covered — and paging within a view repeats
742
- # the identical count. A short TTL keeps the page-jump total EXACT (unlike a
743
- # reltuples estimate, which would make the "of N" bound approximate) while
744
- # collapsing repeat counts to a dict lookup. Stale for at most _COUNT_CACHE_TTL
745
- # seconds after a write, which is fine for a total-count display.
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
746
  _COUNT_CACHE: dict = {}
747
747
  _COUNT_CACHE_TTL = 60.0
748
748
  _COUNT_CACHE_MAX = 512
@@ -754,22 +754,24 @@ def _count_cache_key(doctype_slug, filters, include_discarded):
754
754
 
755
755
 
756
756
  def invalidate_count_cache() -> None:
757
- """Clear the list-count cache (call after a bulk import/delete if an
758
- immediately-fresh total matters more than the ~60s TTL)."""
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
759
  _COUNT_CACHE.clear()
760
760
 
761
761
 
762
762
  def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
763
- """Exact count of documents matching the filters, cached ~60s per filter-set."""
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."""
764
765
  key = _count_cache_key(doctype_slug, filters, include_discarded)
766
+ gen = get_write_generation()
765
767
  now_mono = time.monotonic()
766
768
  hit = _COUNT_CACHE.get(key)
767
- if hit is not None and now_mono - hit[0] < _COUNT_CACHE_TTL:
768
- return hit[1]
769
+ if hit is not None and hit[1] == gen and now_mono - hit[0] < _COUNT_CACHE_TTL:
770
+ return hit[2]
769
771
  result = _count_documents_uncached(doctype_slug, filters, include_discarded)
770
772
  if len(_COUNT_CACHE) >= _COUNT_CACHE_MAX:
771
773
  _COUNT_CACHE.clear()
772
- _COUNT_CACHE[key] = (now_mono, result)
774
+ _COUNT_CACHE[key] = (now_mono, gen, result)
773
775
  return result
774
776
 
775
777
 
@@ -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")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.6"
3
+ version = "0.8.8"
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