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.
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/PKG-INFO +1 -1
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/documents.py +18 -6
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/services.py +42 -1
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/database.py +42 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/pyproject.toml +1 -1
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/.gitignore +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/LICENSE +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/README.md +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/attachments.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/auth.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/chat.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/deps.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/errors.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/main.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/oauth.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/pdf.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/providers.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/masters.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/templates/document.html +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/README.md +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.4 → lambda_erp-0.8.6}/terraform/README.md +0 -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
|
-
|
|
91
|
-
bad = [f for f in
|
|
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
|
|
95
|
-
filters["search_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):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|