lambda-erp 0.8.18__tar.gz → 0.8.20__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.18 → lambda_erp-0.8.20}/PKG-INFO +1 -1
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/chat.py +1 -1
- lambda_erp-0.8.20/api/list_values.py +25 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/documents.py +56 -9
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/masters.py +30 -13
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/reports.py +3 -3
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/services.py +44 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/src/api/client.ts +13 -5
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/journal_entry.py +36 -23
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/pyproject.toml +1 -1
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/.gitignore +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/LICENSE +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/README.md +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/attachments.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/auth.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/deps.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/errors.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/main.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/oauth.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/pdf.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/providers.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/templates/document.html +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/frontend/README.md +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.18 → lambda_erp-0.8.20}/terraform/README.md +0 -0
|
@@ -467,7 +467,7 @@ TOOLS = [
|
|
|
467
467
|
"type": "object",
|
|
468
468
|
"properties": {
|
|
469
469
|
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
|
|
470
|
-
"filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, like, not like, is null, is not null. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
|
|
470
|
+
"filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, contains, like, not like, is null, is not null. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
|
|
471
471
|
"order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
|
|
472
472
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
|
|
473
473
|
"limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared value suggestions for the generic master/document list filters."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def distinct_list_values(db, table: str, field: str, query: str = "", limit: int = 200) -> list:
|
|
5
|
+
"""Return distinct, non-empty values for one validated table column.
|
|
6
|
+
|
|
7
|
+
``field`` is checked against the live schema before it is interpolated.
|
|
8
|
+
Prefix matching keeps autocomplete useful without loading a whole
|
|
9
|
+
high-cardinality column into the browser.
|
|
10
|
+
"""
|
|
11
|
+
if field not in db._get_table_columns(table):
|
|
12
|
+
raise KeyError(field)
|
|
13
|
+
|
|
14
|
+
where = [f'"{field}" IS NOT NULL', f'CAST("{field}" AS TEXT) <> \'\'']
|
|
15
|
+
params: list = []
|
|
16
|
+
if query:
|
|
17
|
+
where.append(f'LOWER(CAST("{field}" AS TEXT)) LIKE LOWER(?)')
|
|
18
|
+
params.append(f"{query}%")
|
|
19
|
+
|
|
20
|
+
rows = db.sql(
|
|
21
|
+
f'SELECT DISTINCT "{field}" AS v FROM "{table}" '
|
|
22
|
+
f'WHERE {" AND ".join(where)} ORDER BY "{field}" LIMIT {int(limit)}',
|
|
23
|
+
params,
|
|
24
|
+
)
|
|
25
|
+
return [row["v"] for row in rows]
|
|
@@ -14,9 +14,13 @@ from api.services import (
|
|
|
14
14
|
count_documents,
|
|
15
15
|
document_columns,
|
|
16
16
|
adjacent_documents,
|
|
17
|
+
parse_list_filter,
|
|
18
|
+
SLUG_TO_DOCTYPE,
|
|
17
19
|
)
|
|
18
20
|
from api.pdf import generate_pdf
|
|
19
21
|
from api.auth import require_role
|
|
22
|
+
from api.list_values import distinct_list_values
|
|
23
|
+
from lambda_erp.database import get_db
|
|
20
24
|
|
|
21
25
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
|
22
26
|
|
|
@@ -24,7 +28,8 @@ _viewer = Depends(require_role("viewer"))
|
|
|
24
28
|
_manager = Depends(require_role("manager"))
|
|
25
29
|
|
|
26
30
|
# Query params the list endpoint interprets itself — everything else is treated
|
|
27
|
-
# as an ad-hoc
|
|
31
|
+
# as an ad-hoc field filter (validated against the doctype's columns). Plain
|
|
32
|
+
# keys are exact; ``field__contains`` is available for real text columns.
|
|
28
33
|
_LIST_RESERVED = {
|
|
29
34
|
"status", "party", "from_date", "to_date", "docstatus",
|
|
30
35
|
"include_discarded", "limit", "offset", "order_by", "order",
|
|
@@ -64,16 +69,23 @@ def list_docs(
|
|
|
64
69
|
if to_date:
|
|
65
70
|
filters["to_date"] = to_date
|
|
66
71
|
|
|
67
|
-
# Ad-hoc
|
|
68
|
-
#
|
|
69
|
-
# against the live columns so an unknown field is a 400, never interpolated.
|
|
72
|
+
# Ad-hoc filters: any remaining query param must name a real column, with an
|
|
73
|
+
# optional __contains suffix for schema-confirmed text columns.
|
|
70
74
|
columns = document_columns(doctype_slug)
|
|
75
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
71
76
|
for key, value in request.query_params.items():
|
|
72
77
|
if key in _LIST_RESERVED:
|
|
73
78
|
continue
|
|
74
|
-
|
|
79
|
+
try:
|
|
80
|
+
field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
|
|
81
|
+
except KeyError:
|
|
75
82
|
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
76
|
-
|
|
83
|
+
except TypeError:
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status_code=400,
|
|
86
|
+
detail=f"Contains filter is only supported for text fields: {key}",
|
|
87
|
+
)
|
|
88
|
+
filters[field] = parsed_value
|
|
77
89
|
|
|
78
90
|
# Which column from_date/to_date filter on. The frontend passes its declared
|
|
79
91
|
# dateField so plugin doctypes get working date filters without a server-side
|
|
@@ -115,7 +127,13 @@ def list_docs(
|
|
|
115
127
|
include_discarded=include_discarded, order_by=order_by, order=order,
|
|
116
128
|
fields=projection)
|
|
117
129
|
total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
|
|
118
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
"rows": rows,
|
|
132
|
+
"total": total,
|
|
133
|
+
"limit": limit,
|
|
134
|
+
"offset": offset,
|
|
135
|
+
"text_fields": sorted(get_db()._get_text_columns(doctype)),
|
|
136
|
+
}
|
|
119
137
|
|
|
120
138
|
|
|
121
139
|
@router.get("/{doctype_slug}/{name}/adjacent")
|
|
@@ -151,12 +169,20 @@ def adjacent_doc(
|
|
|
151
169
|
if to_date:
|
|
152
170
|
filters["to_date"] = to_date
|
|
153
171
|
columns = document_columns(doctype_slug)
|
|
172
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
154
173
|
for key, value in request.query_params.items():
|
|
155
174
|
if key in _LIST_RESERVED:
|
|
156
175
|
continue
|
|
157
|
-
|
|
176
|
+
try:
|
|
177
|
+
field, parsed_value = parse_list_filter(get_db(), doctype, key, value)
|
|
178
|
+
except KeyError:
|
|
158
179
|
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
159
|
-
|
|
180
|
+
except TypeError:
|
|
181
|
+
raise HTTPException(
|
|
182
|
+
status_code=400,
|
|
183
|
+
detail=f"Contains filter is only supported for text fields: {key}",
|
|
184
|
+
)
|
|
185
|
+
filters[field] = parsed_value
|
|
160
186
|
if date_field:
|
|
161
187
|
filters["date_field"] = date_field
|
|
162
188
|
if search:
|
|
@@ -183,6 +209,27 @@ def search_docs(doctype_slug: str, q: str = "", limit: int = Query(default=10, l
|
|
|
183
209
|
return [{"name": d["name"]} for d in docs]
|
|
184
210
|
|
|
185
211
|
|
|
212
|
+
@router.get("/{doctype_slug}/filter-values")
|
|
213
|
+
def document_filter_values(
|
|
214
|
+
doctype_slug: str,
|
|
215
|
+
field: str,
|
|
216
|
+
q: str = "",
|
|
217
|
+
limit: int = Query(default=200, ge=1, le=200),
|
|
218
|
+
_user: dict = _viewer,
|
|
219
|
+
):
|
|
220
|
+
"""Distinct values for the generic list's field autocomplete."""
|
|
221
|
+
from api.services import SLUG_TO_DOCTYPE
|
|
222
|
+
|
|
223
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
224
|
+
if not doctype:
|
|
225
|
+
raise HTTPException(status_code=404, detail=f"Unknown document type: {doctype_slug}")
|
|
226
|
+
try:
|
|
227
|
+
values = distinct_list_values(get_db(), doctype, field, q, limit)
|
|
228
|
+
except KeyError:
|
|
229
|
+
raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
|
|
230
|
+
return {"values": values}
|
|
231
|
+
|
|
232
|
+
|
|
186
233
|
@router.get("/{doctype_slug}/{name}/pdf")
|
|
187
234
|
def get_pdf(doctype_slug: str, name: str, _user: dict = _viewer):
|
|
188
235
|
pdf_bytes = generate_pdf(doctype_slug, name)
|
|
@@ -13,9 +13,11 @@ from api.services import (
|
|
|
13
13
|
MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_NAME_DIGITS, MASTER_RANDOM_NAME_TYPES,
|
|
14
14
|
MASTER_IDENTITY_ALIAS,
|
|
15
15
|
MASTER_REFERENCE_CHECKS,
|
|
16
|
-
_search_clause, _where_from_filters,
|
|
16
|
+
_filter_atom, _search_clause, _where_from_filters, count_query_cached,
|
|
17
|
+
master_search_columns, parse_list_filter,
|
|
17
18
|
)
|
|
18
19
|
from api.auth import require_role, require_non_public_manager
|
|
20
|
+
from api.list_values import distinct_list_values
|
|
19
21
|
|
|
20
22
|
router = APIRouter(prefix="/masters", tags=["masters"])
|
|
21
23
|
|
|
@@ -300,7 +302,9 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
300
302
|
|
|
301
303
|
|
|
302
304
|
# Query params list_masters consumes itself — anything else is treated as an
|
|
303
|
-
# ad-hoc
|
|
305
|
+
# ad-hoc field filter (validated against the master's real columns). Plain keys
|
|
306
|
+
# are exact; text columns additionally support the explicit ``__contains``
|
|
307
|
+
# suffix used by Smart Search.
|
|
304
308
|
_MASTER_LIST_RESERVED = {
|
|
305
309
|
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
306
310
|
"order_by", "order",
|
|
@@ -328,10 +332,18 @@ def _master_list_where(db, doctype: str, master_type: str, request: Request,
|
|
|
328
332
|
for key, value in request.query_params.items():
|
|
329
333
|
if key in _MASTER_LIST_RESERVED:
|
|
330
334
|
continue
|
|
331
|
-
|
|
335
|
+
try:
|
|
336
|
+
field, parsed_value = parse_list_filter(db, doctype, key, value)
|
|
337
|
+
except KeyError:
|
|
332
338
|
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
except TypeError:
|
|
340
|
+
raise HTTPException(
|
|
341
|
+
status_code=400,
|
|
342
|
+
detail=f"Contains filter is only supported for text fields: {key}",
|
|
343
|
+
)
|
|
344
|
+
clause, clause_params = _filter_atom(field, parsed_value)
|
|
345
|
+
where_parts.append(clause)
|
|
346
|
+
params.extend(clause_params)
|
|
335
347
|
|
|
336
348
|
if search:
|
|
337
349
|
requested = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
|
|
@@ -374,6 +386,8 @@ def _master_order_sql(column: str, direction: str, *, reverse: bool = False) ->
|
|
|
374
386
|
def master_filter_values(
|
|
375
387
|
master_type: str,
|
|
376
388
|
field: str,
|
|
389
|
+
q: str = "",
|
|
390
|
+
limit: int = Query(default=200, ge=1, le=200),
|
|
377
391
|
_user: dict = _viewer,
|
|
378
392
|
):
|
|
379
393
|
"""Distinct non-empty values of one column — populates a master-list filter
|
|
@@ -382,14 +396,11 @@ def master_filter_values(
|
|
|
382
396
|
if not doctype:
|
|
383
397
|
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
384
398
|
db = get_db()
|
|
385
|
-
|
|
399
|
+
try:
|
|
400
|
+
values = distinct_list_values(db, doctype, field, q, limit)
|
|
401
|
+
except KeyError:
|
|
386
402
|
raise HTTPException(status_code=400, detail=f"Unknown field: {field}")
|
|
387
|
-
|
|
388
|
-
f'SELECT DISTINCT "{field}" AS v FROM "{doctype}" '
|
|
389
|
-
f'WHERE "{field}" IS NOT NULL AND "{field}" <> \'\' '
|
|
390
|
-
f'ORDER BY "{field}" LIMIT 200'
|
|
391
|
-
)
|
|
392
|
-
return {"values": [r["v"] for r in rows]}
|
|
403
|
+
return {"values": values}
|
|
393
404
|
|
|
394
405
|
|
|
395
406
|
@router.get("/{master_type}")
|
|
@@ -436,7 +447,13 @@ def list_masters(
|
|
|
436
447
|
query += f" OFFSET {int(offset)}"
|
|
437
448
|
rows = db.sql(query, params)
|
|
438
449
|
|
|
439
|
-
return {
|
|
450
|
+
return {
|
|
451
|
+
"rows": rows,
|
|
452
|
+
"total": total,
|
|
453
|
+
"limit": limit,
|
|
454
|
+
"offset": offset,
|
|
455
|
+
"text_fields": sorted(db._get_text_columns(doctype)),
|
|
456
|
+
}
|
|
440
457
|
|
|
441
458
|
|
|
442
459
|
@router.get("/{master_type}/search")
|
|
@@ -508,14 +508,14 @@ def _profit_and_loss(db, company=None, from_date=None, to_date=None):
|
|
|
508
508
|
"account": acc["name"],
|
|
509
509
|
"account_name": acc["account_name"],
|
|
510
510
|
"root_type": acc["root_type"],
|
|
511
|
-
"amount":
|
|
511
|
+
"amount": balance,
|
|
512
512
|
}
|
|
513
513
|
if acc["root_type"] == "Income":
|
|
514
514
|
income_rows.append(row)
|
|
515
|
-
total_income +=
|
|
515
|
+
total_income += balance
|
|
516
516
|
else:
|
|
517
517
|
expense_rows.append(row)
|
|
518
|
-
total_expense +=
|
|
518
|
+
total_expense += balance
|
|
519
519
|
|
|
520
520
|
return {
|
|
521
521
|
"income": income_rows,
|
|
@@ -709,6 +709,7 @@ _FILTER_OPS = {
|
|
|
709
709
|
"=": "=", "==": "=", "!=": "!=", "<>": "!=",
|
|
710
710
|
">": ">", "<": "<", ">=": ">=", "<=": "<=",
|
|
711
711
|
"like": "LIKE", "not like": "NOT LIKE",
|
|
712
|
+
"contains": "CONTAINS",
|
|
712
713
|
"is null": "IS NULL", "is not null": "IS NOT NULL",
|
|
713
714
|
}
|
|
714
715
|
_NULLARY_OPS = {"IS NULL", "IS NOT NULL"} # take no bound value
|
|
@@ -744,6 +745,13 @@ def _filter_atom(col: str, value):
|
|
|
744
745
|
op = _resolve_op(value[0])
|
|
745
746
|
if op in _NULLARY_OPS:
|
|
746
747
|
return f'"{col}" {op}', [] # value ignored for IS [NOT] NULL
|
|
748
|
+
if op == "CONTAINS":
|
|
749
|
+
# Literal, case-insensitive substring search. Escape LIKE's two
|
|
750
|
+
# wildcard characters so a user-entered '%' or '_' keeps its
|
|
751
|
+
# ordinary meaning. The REST layer only permits this operator
|
|
752
|
+
# for schema-confirmed text columns.
|
|
753
|
+
escaped = str(value[1]).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
754
|
+
return f'LOWER("{col}") LIKE LOWER(?) ESCAPE \'\\\'', [f"%{escaped}%"]
|
|
747
755
|
return f'"{col}" {op} ?', [value[1]]
|
|
748
756
|
raise ValueError(
|
|
749
757
|
f"Malformed filter for {col!r}: expected a scalar, [op], or [op, value]"
|
|
@@ -751,6 +759,39 @@ def _filter_atom(col: str, value):
|
|
|
751
759
|
return f'"{col}" = ?', [value]
|
|
752
760
|
|
|
753
761
|
|
|
762
|
+
def parse_list_filter(db, doctype: str, raw_key: str, value):
|
|
763
|
+
"""Parse one REST list-filter query parameter.
|
|
764
|
+
|
|
765
|
+
Plain ``field=value`` parameters retain their historical equality
|
|
766
|
+
semantics. ``field__contains=value`` becomes the internal whitelisted
|
|
767
|
+
``contains`` operator, but only when the live schema says ``field`` is a
|
|
768
|
+
text column. Unknown fields raise ``KeyError``; applying ``contains`` to a
|
|
769
|
+
non-text field raises ``TypeError``. Callers translate both into HTTP 400.
|
|
770
|
+
"""
|
|
771
|
+
suffix = "__contains"
|
|
772
|
+
field = raw_key[:-len(suffix)] if raw_key.endswith(suffix) else raw_key
|
|
773
|
+
if not field or field not in db._get_table_columns(doctype):
|
|
774
|
+
raise KeyError(field or raw_key)
|
|
775
|
+
if raw_key.endswith(suffix):
|
|
776
|
+
if field not in db._get_text_columns(doctype):
|
|
777
|
+
raise TypeError(field)
|
|
778
|
+
return field, ("contains", value)
|
|
779
|
+
return field, value
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _validate_typed_filters(db, doctype: str, filters: dict) -> None:
|
|
783
|
+
"""Keep type-sensitive operators honest for REST, chat, and direct calls."""
|
|
784
|
+
text_columns = db._get_text_columns(doctype)
|
|
785
|
+
for field, value in filters.items():
|
|
786
|
+
if (
|
|
787
|
+
isinstance(value, (list, tuple))
|
|
788
|
+
and len(value) == 2
|
|
789
|
+
and str(value[0]).strip().lower() == "contains"
|
|
790
|
+
and field not in text_columns
|
|
791
|
+
):
|
|
792
|
+
raise ValueError(f"Contains filter is only supported for text fields: {field}")
|
|
793
|
+
|
|
794
|
+
|
|
754
795
|
def _where_from_filters(db_filters: dict):
|
|
755
796
|
"""(where_parts, params) for a dict of already-parsed equality/operator
|
|
756
797
|
filters, via the whitelisted _filter_atom builder."""
|
|
@@ -806,6 +847,7 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
|
|
|
806
847
|
db_filters[key] = value
|
|
807
848
|
|
|
808
849
|
_exclude_discarded(db, doctype, db_filters, include_discarded)
|
|
850
|
+
_validate_typed_filters(db, doctype, db_filters)
|
|
809
851
|
|
|
810
852
|
# Date range filtering via the doctype's primary date field
|
|
811
853
|
date_field = _resolve_date_field(db, doctype, date_field_override)
|
|
@@ -961,6 +1003,7 @@ def _count_documents_uncached(doctype_slug: str, filters: dict = None, include_d
|
|
|
961
1003
|
db_filters[key] = value
|
|
962
1004
|
|
|
963
1005
|
_exclude_discarded(db, doctype, db_filters, include_discarded)
|
|
1006
|
+
_validate_typed_filters(db, doctype, db_filters)
|
|
964
1007
|
|
|
965
1008
|
date_field = _resolve_date_field(db, doctype, date_field_override)
|
|
966
1009
|
where_parts = []
|
|
@@ -1010,6 +1053,7 @@ def _filter_where(db, doctype: str, doctype_slug: str, filters: dict, include_di
|
|
|
1010
1053
|
else:
|
|
1011
1054
|
db_filters[key] = value
|
|
1012
1055
|
_exclude_discarded(db, doctype, db_filters, include_discarded)
|
|
1056
|
+
_validate_typed_filters(db, doctype, db_filters)
|
|
1013
1057
|
|
|
1014
1058
|
date_field = _resolve_date_field(db, doctype, date_field_override)
|
|
1015
1059
|
where_parts, params = [], []
|
|
@@ -167,7 +167,7 @@ export const api = {
|
|
|
167
167
|
|
|
168
168
|
// Documents
|
|
169
169
|
listDocuments: (doctype: string, params?: Record<string, string | number | undefined>) =>
|
|
170
|
-
request<{ rows: any[]; total: number; limit: number; offset: number }>(
|
|
170
|
+
request<{ rows: any[]; total: number; limit: number; offset: number; text_fields: string[] }>(
|
|
171
171
|
`/documents/${doctype}${qs(params)}`,
|
|
172
172
|
),
|
|
173
173
|
|
|
@@ -243,16 +243,24 @@ export const api = {
|
|
|
243
243
|
|
|
244
244
|
// Masters
|
|
245
245
|
listMasters: (type: string, params?: Record<string, string | number | undefined>) =>
|
|
246
|
-
request<{ rows: any[]; total: number; limit: number; offset: number }>(
|
|
246
|
+
request<{ rows: any[]; total: number; limit: number; offset: number; text_fields: string[] }>(
|
|
247
247
|
`/masters/${type}${qs(params)}`,
|
|
248
248
|
),
|
|
249
249
|
|
|
250
250
|
searchMasters: (type: string, q: string) =>
|
|
251
251
|
request<any[]>(`/masters/${type}/search?q=${encodeURIComponent(q)}`),
|
|
252
252
|
|
|
253
|
-
// Distinct values of one
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// Distinct values of one list column — used by dropdown filters and the
|
|
254
|
+
// shared field-aware search autocomplete.
|
|
255
|
+
masterFilterValues: (type: string, field: string, q = "", limit = 200) =>
|
|
256
|
+
request<{ values: any[] }>(
|
|
257
|
+
`/masters/${type}/filter-values${qs({ field, q: q || undefined, limit })}`,
|
|
258
|
+
),
|
|
259
|
+
|
|
260
|
+
documentFilterValues: (doctype: string, field: string, q = "", limit = 200) =>
|
|
261
|
+
request<{ values: any[] }>(
|
|
262
|
+
`/documents/${doctype}/filter-values${qs({ field, q: q || undefined, limit })}`,
|
|
263
|
+
),
|
|
256
264
|
|
|
257
265
|
searchDocuments: (doctype: string, q: string) =>
|
|
258
266
|
request<any[]>(`/documents/${doctype}/search?q=${encodeURIComponent(q)}`),
|
|
@@ -76,26 +76,27 @@ class JournalEntry(Document):
|
|
|
76
76
|
self._set_totals()
|
|
77
77
|
|
|
78
78
|
def _normalize_amounts(self):
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
the
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
"""Back-fill only missing base/account-currency amounts.
|
|
80
|
+
|
|
81
|
+
``None`` or an absent field means the caller supplied only the matching
|
|
82
|
+
amount in the other currency. An explicit zero is meaningful: period-end
|
|
83
|
+
valuation entries move the base carrying value without changing the
|
|
84
|
+
foreign-currency quantity, so their *_in_account_currency amount must
|
|
85
|
+
remain zero.
|
|
86
|
+
"""
|
|
86
87
|
for row in self.get("accounts") or []:
|
|
87
|
-
debit =
|
|
88
|
-
credit =
|
|
89
|
-
dr_ac =
|
|
90
|
-
cr_ac =
|
|
91
|
-
if
|
|
92
|
-
row["debit"] = dr_ac
|
|
93
|
-
if
|
|
94
|
-
row["credit"] = cr_ac
|
|
95
|
-
if
|
|
96
|
-
row["debit_in_account_currency"] = debit
|
|
97
|
-
if
|
|
98
|
-
row["credit_in_account_currency"] = credit
|
|
88
|
+
debit = row.get("debit")
|
|
89
|
+
credit = row.get("credit")
|
|
90
|
+
dr_ac = row.get("debit_in_account_currency")
|
|
91
|
+
cr_ac = row.get("credit_in_account_currency")
|
|
92
|
+
if debit is None and dr_ac is not None:
|
|
93
|
+
row["debit"] = flt(dr_ac)
|
|
94
|
+
if credit is None and cr_ac is not None:
|
|
95
|
+
row["credit"] = flt(cr_ac)
|
|
96
|
+
if dr_ac is None and debit is not None:
|
|
97
|
+
row["debit_in_account_currency"] = flt(debit)
|
|
98
|
+
if cr_ac is None and credit is not None:
|
|
99
|
+
row["credit_in_account_currency"] = flt(credit)
|
|
99
100
|
|
|
100
101
|
def _validate_debit_credit(self):
|
|
101
102
|
"""Ensure total debits == total credits.
|
|
@@ -235,21 +236,33 @@ class JournalEntry(Document):
|
|
|
235
236
|
journal entries map 1:1 from account rows to GL entries.
|
|
236
237
|
"""
|
|
237
238
|
gl_entries = []
|
|
239
|
+
db = get_db()
|
|
240
|
+
base_currency = db.get_value("Company", self.company, "default_currency") or "USD"
|
|
238
241
|
|
|
239
242
|
for row in self.get("accounts"):
|
|
240
243
|
if not (flt(row.get("debit")) or flt(row.get("credit"))):
|
|
241
244
|
continue
|
|
242
245
|
|
|
246
|
+
debit = flt(row.get("debit"), 2)
|
|
247
|
+
credit = flt(row.get("credit"), 2)
|
|
248
|
+
dr_ac = row.get("debit_in_account_currency")
|
|
249
|
+
cr_ac = row.get("credit_in_account_currency")
|
|
250
|
+
account_currency = (
|
|
251
|
+
db.get_value("Account", row.get("account"), "account_currency")
|
|
252
|
+
or base_currency
|
|
253
|
+
)
|
|
254
|
+
|
|
243
255
|
gl_entries.append(
|
|
244
256
|
_dict(
|
|
245
257
|
account=row.get("account"),
|
|
246
258
|
party_type=row.get("party_type"),
|
|
247
259
|
party=row.get("party"),
|
|
248
260
|
cost_center=row.get("cost_center"),
|
|
249
|
-
debit=
|
|
250
|
-
credit=
|
|
251
|
-
debit_in_account_currency=flt(
|
|
252
|
-
credit_in_account_currency=flt(
|
|
261
|
+
debit=debit,
|
|
262
|
+
credit=credit,
|
|
263
|
+
debit_in_account_currency=flt(debit if dr_ac is None else dr_ac, 2),
|
|
264
|
+
credit_in_account_currency=flt(credit if cr_ac is None else cr_ac, 2),
|
|
265
|
+
account_currency=account_currency,
|
|
253
266
|
against_voucher_type=row.get("reference_doctype") or row.get("reference_type"),
|
|
254
267
|
against_voucher=row.get("reference_name"),
|
|
255
268
|
voucher_type=self.DOCTYPE,
|
|
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
|