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.
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/PKG-INFO +1 -1
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/masters.py +78 -29
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/services.py +32 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/src/api/client.ts +4 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/pyproject.toml +1 -1
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/.gitignore +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/LICENSE +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/README.md +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/attachments.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/auth.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/chat.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/deps.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/errors.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/main.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/oauth.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/pdf.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/providers.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/templates/document.html +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/frontend/README.md +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.8 → lambda_erp-0.8.9}/terraform/README.md +0 -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
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
|
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
|