lambda-erp 0.8.4__tar.gz → 0.8.6__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.4 → lambda_erp-0.8.6}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/documents.py +18 -6
  3. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/services.py +42 -1
  4. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/database.py +42 -0
  5. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/pyproject.toml +1 -1
  6. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/.gitignore +0 -0
  7. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/LICENSE +0 -0
  8. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/README.md +0 -0
  9. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/__init__.py +0 -0
  10. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/attachments.py +0 -0
  11. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/auth.py +0 -0
  12. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/chat.py +0 -0
  14. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/deps.py +0 -0
  16. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/errors.py +0 -0
  17. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/main.py +0 -0
  18. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/oauth.py +0 -0
  19. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/pdf.py +0 -0
  20. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/providers.py +0 -0
  21. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/availability.py +0 -0
  27. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/chat_api.py +0 -0
  29. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/masters.py +0 -0
  30. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/mcp.py +0 -0
  31. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/proposals.py +0 -0
  32. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/reports.py +0 -0
  33. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/setup.py +0 -0
  34. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/templates/document.html +0 -0
  35. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/README.md +0 -0
  39. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/src/api/client.ts +0 -0
  40. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/__init__.py +0 -0
  41. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/__init__.py +0 -0
  42. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/bank_transaction.py +0 -0
  43. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/budget.py +0 -0
  44. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  45. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/general_ledger.py +0 -0
  46. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/journal_entry.py +0 -0
  47. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/payment_entry.py +0 -0
  48. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/pos_invoice.py +0 -0
  49. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/purchase_invoice.py +0 -0
  50. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/revaluation.py +0 -0
  51. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/sales_invoice.py +0 -0
  52. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/__init__.py +0 -0
  53. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/engine.py +0 -0
  54. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/pack.py +0 -0
  55. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  56. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  57. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  58. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  59. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  60. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  61. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/profiles.py +0 -0
  62. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/spine.py +0 -0
  63. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/subscription.py +0 -0
  64. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/__init__.py +0 -0
  65. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/asset.py +0 -0
  66. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/reservation.py +0 -0
  67. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/buying/__init__.py +0 -0
  68. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/buying/purchase_order.py +0 -0
  69. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/__init__.py +0 -0
  70. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/currency.py +0 -0
  71. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/defaults.py +0 -0
  72. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/pricing_rule.py +0 -0
  73. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  74. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/exceptions.py +0 -0
  75. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/hooks.py +0 -0
  76. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/model.py +0 -0
  77. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/__init__.py +0 -0
  78. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/proposal.py +0 -0
  79. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/quotation.py +0 -0
  80. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/sales_order.py +0 -0
  81. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/simulation.py +0 -0
  82. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/__init__.py +0 -0
  83. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/delivery_note.py +0 -0
  84. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/purchase_receipt.py +0 -0
  85. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/stock_entry.py +0 -0
  86. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/stock_ledger.py +0 -0
  87. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/utils.py +0 -0
  88. {lambda_erp-0.8.4 → lambda_erp-0.8.6}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.4
3
+ Version: 0.8.6
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,5 +1,7 @@
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
6
  from lambda_erp.database import get_db
5
7
 
@@ -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,44 @@ 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 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.
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 (call after a bulk import/delete if an
758
+ immediately-fresh total matters more than the ~60s TTL)."""
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 ~60s per filter-set."""
764
+ key = _count_cache_key(doctype_slug, filters, include_discarded)
765
+ now_mono = time.monotonic()
766
+ hit = _COUNT_CACHE.get(key)
767
+ if hit is not None and now_mono - hit[0] < _COUNT_CACHE_TTL:
768
+ return hit[1]
769
+ result = _count_documents_uncached(doctype_slug, filters, include_discarded)
770
+ if len(_COUNT_CACHE) >= _COUNT_CACHE_MAX:
771
+ _COUNT_CACHE.clear()
772
+ _COUNT_CACHE[key] = (now_mono, result)
773
+ return result
774
+
775
+
776
+ def _count_documents_uncached(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
736
777
  """Count documents matching the filters (ignores limit/offset)."""
737
778
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
738
779
  if not doctype:
@@ -1457,6 +1457,7 @@ class Database:
1457
1457
  self.conn.execute(self._ddl(stmt))
1458
1458
  self.conn.commit()
1459
1459
  self._migrate()
1460
+ self._ensure_list_indexes()
1460
1461
 
1461
1462
  def _ddl(self, stmt: str) -> str:
1462
1463
  """Translate SQLite DDL to the active dialect. No-op for SQLite.
@@ -1605,6 +1606,47 @@ class Database:
1605
1606
  # covers the happy path; migrations are only needed for drift.
1606
1607
  self.conn.rollback()
1607
1608
 
1609
+ def _all_user_tables(self) -> list:
1610
+ """Base tables in the active DB, minus internal bookkeeping."""
1611
+ if self.dialect == "postgres":
1612
+ rows = self.conn.execute(
1613
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
1614
+ ).fetchall()
1615
+ else:
1616
+ rows = self.conn.execute(
1617
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
1618
+ ).fetchall()
1619
+ return [
1620
+ r[0] for r in rows
1621
+ if not str(r[0]).startswith("_") and not str(r[0]).startswith("sqlite_")
1622
+ ]
1623
+
1624
+ def _ensure_list_indexes(self) -> None:
1625
+ """Index `creation` on every table that has it — the generic list-speed fix.
1626
+
1627
+ Every document/master list defaults to `ORDER BY creation DESC`, but tables
1628
+ ship with only a `name` primary key. So once a table grows large, each list
1629
+ load does a full sequential scan + top-N sort: the Leads page at 89K rows
1630
+ measured ~340ms; ~0.3ms with this index. Reconciled here (idempotent,
1631
+ CREATE INDEX IF NOT EXISTS) so it covers core, plugin AND future doctypes,
1632
+ and back-fills existing tables on the next deploy. `creation` is a
1633
+ fixed-width ISO string, so a plain btree sorts chronologically and the
1634
+ planner scans it backward for DESC. Best-effort per table — a failure on one
1635
+ (e.g. two replicas racing to build it at boot) never blocks startup.
1636
+ """
1637
+ for table in self._all_user_tables():
1638
+ try:
1639
+ if "creation" not in self._get_table_columns(table):
1640
+ continue
1641
+ except Exception:
1642
+ continue
1643
+ idx = f"ix_{table.replace(' ', '_')}_creation"
1644
+ try:
1645
+ self.conn.execute(f'CREATE INDEX IF NOT EXISTS "{idx}" ON "{table}" ("creation")')
1646
+ self.conn.commit()
1647
+ except Exception:
1648
+ self.conn.rollback()
1649
+
1608
1650
  # --- Core query interface (mirrors framework.db) ---
1609
1651
 
1610
1652
  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.4"
3
+ version = "0.8.6"
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