lambda-erp 0.8.15__tar.gz → 0.8.17__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.15 → lambda_erp-0.8.17}/PKG-INFO +1 -9
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/README.md +0 -8
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/masters.py +147 -54
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/services.py +11 -2
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/src/api/client.ts +3 -3
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/pyproject.toml +1 -1
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/.gitignore +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/LICENSE +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/attachments.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/auth.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/chat.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/deps.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/errors.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/main.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/oauth.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/pdf.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/providers.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/templates/document.html +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/README.md +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.15 → lambda_erp-0.8.17}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.17
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -462,7 +462,6 @@ This is early. The project needs:
|
|
|
462
462
|
- Country compliance packs (tax rules, invoice formats, mandatory fields)
|
|
463
463
|
- Industry templates (services, retail, light manufacturing, SaaS)
|
|
464
464
|
- More preset reports
|
|
465
|
-
- A Postgres storage adapter (the current SQLite layer is fine for local evaluation but will need to be swapped for real multi-user write loads)
|
|
466
465
|
- Better observability around token spend per turn
|
|
467
466
|
- Native messenger integration, for WhatsApp, Telegram, etc.
|
|
468
467
|
|
|
@@ -482,13 +481,6 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
482
481
|
`vX.Y.Z` and published in lockstep to PyPI (`lambda-erp`) and npm
|
|
483
482
|
(`@lambda-development/erp-core`).
|
|
484
483
|
|
|
485
|
-
---
|
|
486
|
-
|
|
487
|
-
## Status
|
|
488
|
-
|
|
489
|
-
Version 0, and it already implements the vision: invoicing, inventory, accounting, and reporting — all driven by chat. Good for demos, internal tools, and real day-to-day work. It's under active development and improving fast.
|
|
490
|
-
|
|
491
|
-
If you try it, we'd love to know what broke.
|
|
492
484
|
|
|
493
485
|
---
|
|
494
486
|
|
|
@@ -434,7 +434,6 @@ This is early. The project needs:
|
|
|
434
434
|
- Country compliance packs (tax rules, invoice formats, mandatory fields)
|
|
435
435
|
- Industry templates (services, retail, light manufacturing, SaaS)
|
|
436
436
|
- More preset reports
|
|
437
|
-
- A Postgres storage adapter (the current SQLite layer is fine for local evaluation but will need to be swapped for real multi-user write loads)
|
|
438
437
|
- Better observability around token spend per turn
|
|
439
438
|
- Native messenger integration, for WhatsApp, Telegram, etc.
|
|
440
439
|
|
|
@@ -454,13 +453,6 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
454
453
|
`vX.Y.Z` and published in lockstep to PyPI (`lambda-erp`) and npm
|
|
455
454
|
(`@lambda-development/erp-core`).
|
|
456
455
|
|
|
457
|
-
---
|
|
458
|
-
|
|
459
|
-
## Status
|
|
460
|
-
|
|
461
|
-
Version 0, and it already implements the vision: invoicing, inventory, accounting, and reporting — all driven by chat. Good for demos, internal tools, and real day-to-day work. It's under active development and improving fast.
|
|
462
|
-
|
|
463
|
-
If you try it, we'd love to know what broke.
|
|
464
456
|
|
|
465
457
|
---
|
|
466
458
|
|
|
@@ -10,7 +10,8 @@ from lambda_erp.utils import _dict, now
|
|
|
10
10
|
# them; imported here (not moved) so existing `from api.routers.masters import
|
|
11
11
|
# MASTER_IDENTITY_ALIAS`-style imports keep working.
|
|
12
12
|
from api.services import (
|
|
13
|
-
MASTER_TABLES, MASTER_NAME_PREFIXES,
|
|
13
|
+
MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_NAME_DIGITS, MASTER_RANDOM_NAME_TYPES,
|
|
14
|
+
MASTER_IDENTITY_ALIAS,
|
|
14
15
|
MASTER_REFERENCE_CHECKS,
|
|
15
16
|
_search_clause, _where_from_filters, master_search_columns, count_query_cached,
|
|
16
17
|
)
|
|
@@ -161,7 +162,7 @@ def _find_reference(master_type: str, name: str) -> str | None:
|
|
|
161
162
|
return None
|
|
162
163
|
|
|
163
164
|
|
|
164
|
-
def _generate_master_name(db, doctype: str, prefix: str) -> str:
|
|
165
|
+
def _generate_master_name(db, doctype: str, prefix: str, digits: int = 3) -> str:
|
|
165
166
|
# Take the highest numeric suffix among strict "PREFIX-<digits>" names.
|
|
166
167
|
# Names with a non-numeric tail (e.g. a custom "ITEM-COST-TEST") are ignored
|
|
167
168
|
# rather than parsed — string-sorting them to the top used to reset the
|
|
@@ -179,9 +180,9 @@ def _generate_master_name(db, doctype: str, prefix: str) -> str:
|
|
|
179
180
|
|
|
180
181
|
# Skip any number already taken by a non-standard name so we never collide.
|
|
181
182
|
number = max_number + 1
|
|
182
|
-
while db.exists(doctype, f"{prefix}-{number:
|
|
183
|
+
while db.exists(doctype, f"{prefix}-{number:0{digits}d}"):
|
|
183
184
|
number += 1
|
|
184
|
-
return f"{prefix}-{number:
|
|
185
|
+
return f"{prefix}-{number:0{digits}d}"
|
|
185
186
|
|
|
186
187
|
|
|
187
188
|
def _generate_random_master_name(db, doctype: str, prefix: str) -> str:
|
|
@@ -230,7 +231,9 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
230
231
|
if master_type in MASTER_RANDOM_NAME_TYPES:
|
|
231
232
|
doc["name"] = _generate_random_master_name(db, doctype, prefix)
|
|
232
233
|
else:
|
|
233
|
-
doc["name"] = _generate_master_name(
|
|
234
|
+
doc["name"] = _generate_master_name(
|
|
235
|
+
db, doctype, prefix, MASTER_NAME_DIGITS.get(master_type, 3)
|
|
236
|
+
)
|
|
234
237
|
elif master_type == "company" and doc.get("company_name"):
|
|
235
238
|
# A company's id is conventionally its name (mirrors /setup/company).
|
|
236
239
|
doc["name"] = doc["company_name"]
|
|
@@ -300,9 +303,73 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
300
303
|
# ad-hoc equality field filter (validated against the master's real columns).
|
|
301
304
|
_MASTER_LIST_RESERVED = {
|
|
302
305
|
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
306
|
+
"order_by", "order",
|
|
303
307
|
}
|
|
304
308
|
|
|
305
309
|
|
|
310
|
+
def _master_list_where(db, doctype: str, master_type: str, request: Request,
|
|
311
|
+
include_disabled: bool, search: str | None,
|
|
312
|
+
search_fields: str | None) -> tuple[list, list, set]:
|
|
313
|
+
"""Build the validated WHERE clause shared by master list and adjacent.
|
|
314
|
+
|
|
315
|
+
Keeping this in one place is what makes detail prev/next follow the exact
|
|
316
|
+
search and field filters of the list the user opened the record from.
|
|
317
|
+
"""
|
|
318
|
+
columns = db._get_table_columns(doctype)
|
|
319
|
+
where_parts: list = []
|
|
320
|
+
params: list = []
|
|
321
|
+
|
|
322
|
+
active = None if include_disabled else _with_active_filter(db, doctype)
|
|
323
|
+
if active:
|
|
324
|
+
wp, ps = _where_from_filters(active)
|
|
325
|
+
where_parts += wp
|
|
326
|
+
params += ps
|
|
327
|
+
|
|
328
|
+
for key, value in request.query_params.items():
|
|
329
|
+
if key in _MASTER_LIST_RESERVED:
|
|
330
|
+
continue
|
|
331
|
+
if key not in columns:
|
|
332
|
+
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
333
|
+
where_parts.append(f'"{key}" = ?')
|
|
334
|
+
params.append(value)
|
|
335
|
+
|
|
336
|
+
if search:
|
|
337
|
+
requested = [f.strip() for f in (search_fields or "").split(",") if f.strip()]
|
|
338
|
+
unknown = [f for f in requested if f not in columns]
|
|
339
|
+
if unknown:
|
|
340
|
+
raise HTTPException(status_code=400, detail=f"Unknown search field(s): {', '.join(unknown)}")
|
|
341
|
+
sf = requested or master_search_columns(db, doctype)
|
|
342
|
+
clause, sp = _search_clause(db, doctype, master_type, search, sf)
|
|
343
|
+
if clause:
|
|
344
|
+
where_parts.append(clause)
|
|
345
|
+
params += sp
|
|
346
|
+
|
|
347
|
+
return where_parts, params, columns
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _master_order(columns: set, order_by: str | None, order: str) -> tuple[str, str]:
|
|
351
|
+
if order_by is not None and order_by not in columns:
|
|
352
|
+
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
|
|
353
|
+
direction = order.lower()
|
|
354
|
+
if direction not in ("asc", "desc"):
|
|
355
|
+
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
|
|
356
|
+
# Reference masters retain their historical default: stable ID order.
|
|
357
|
+
return order_by or "name", direction if order_by else "asc"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _master_order_sql(column: str, direction: str, *, reverse: bool = False) -> str:
|
|
361
|
+
"""Deterministic master ordering with NULL values consistently at the end."""
|
|
362
|
+
effective = direction
|
|
363
|
+
nulls = "LAST"
|
|
364
|
+
if reverse:
|
|
365
|
+
effective = "desc" if direction == "asc" else "asc"
|
|
366
|
+
nulls = "FIRST"
|
|
367
|
+
sql = f'"{column}" {effective.upper()}'
|
|
368
|
+
if column != "name":
|
|
369
|
+
sql += f" NULLS {nulls}, name {effective.upper()}"
|
|
370
|
+
return sql
|
|
371
|
+
|
|
372
|
+
|
|
306
373
|
@router.get("/{master_type}/filter-values")
|
|
307
374
|
def master_filter_values(
|
|
308
375
|
master_type: str,
|
|
@@ -335,52 +402,22 @@ def list_masters(
|
|
|
335
402
|
search: str | None = None,
|
|
336
403
|
search_fields: str | None = None,
|
|
337
404
|
fields: str | None = None,
|
|
405
|
+
order_by: str | None = None,
|
|
406
|
+
order: str = "asc",
|
|
338
407
|
_user: dict = _viewer,
|
|
339
408
|
):
|
|
340
409
|
doctype, _ = _get_table(master_type)
|
|
341
410
|
if not doctype:
|
|
342
411
|
return {"detail": f"Unknown master type: {master_type}"}
|
|
343
412
|
db = get_db()
|
|
344
|
-
columns =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
# Active (non-disabled) filter, unless the caller opts in to disabled rows.
|
|
350
|
-
active = None if include_disabled else _with_active_filter(db, doctype)
|
|
351
|
-
if active:
|
|
352
|
-
wp, ps = _where_from_filters(active)
|
|
353
|
-
where_parts += wp
|
|
354
|
-
params += ps
|
|
355
|
-
|
|
356
|
-
# Ad-hoc equality field filters: any remaining query param naming a real
|
|
357
|
-
# column (e.g. /masters/customer?customer_group=Commercial). Unknown field
|
|
358
|
-
# -> 400 (a filter on a missing column is a client bug, never interpolated).
|
|
359
|
-
for key, value in request.query_params.items():
|
|
360
|
-
if key in _MASTER_LIST_RESERVED:
|
|
361
|
-
continue
|
|
362
|
-
if key not in columns:
|
|
363
|
-
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
364
|
-
where_parts.append(f'"{key}" = ?')
|
|
365
|
-
params.append(value)
|
|
366
|
-
|
|
367
|
-
# Free-text search — defaults to the master's text columns when the caller
|
|
368
|
-
# doesn't name search_fields (so a plain ?search=... just works).
|
|
369
|
-
if search:
|
|
370
|
-
sf = [f.strip() for f in (search_fields or "").split(",")
|
|
371
|
-
if f.strip() and f.strip() in columns]
|
|
372
|
-
if not sf:
|
|
373
|
-
sf = master_search_columns(db, doctype)
|
|
374
|
-
clause, sp = _search_clause(db, doctype, master_type, search, sf)
|
|
375
|
-
if clause:
|
|
376
|
-
where_parts.append(clause)
|
|
377
|
-
params += sp
|
|
413
|
+
where_parts, params, columns = _master_list_where(
|
|
414
|
+
db, doctype, master_type, request, include_disabled, search, search_fields,
|
|
415
|
+
)
|
|
416
|
+
sort_column, sort_direction = _master_order(columns, order_by, order)
|
|
378
417
|
|
|
379
418
|
where = (" WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
|
380
419
|
total = count_query_cached(f'SELECT COUNT(*) as c FROM "{doctype}"{where}', params)
|
|
381
420
|
|
|
382
|
-
# Deterministic order by the `name` PK (already indexed) — required for
|
|
383
|
-
# OFFSET pagination and the /adjacent prev/next contract.
|
|
384
421
|
requested_fields = [f.strip() for f in (fields or "").split(",") if f.strip()]
|
|
385
422
|
if requested_fields:
|
|
386
423
|
unknown = [f for f in requested_fields if f not in columns]
|
|
@@ -391,7 +428,10 @@ def list_masters(
|
|
|
391
428
|
projection = ", ".join(f'"{f}"' for f in requested_fields)
|
|
392
429
|
else:
|
|
393
430
|
projection = "*"
|
|
394
|
-
query =
|
|
431
|
+
query = (
|
|
432
|
+
f'SELECT {projection} FROM "{doctype}"{where} '
|
|
433
|
+
f'ORDER BY {_master_order_sql(sort_column, sort_direction)} LIMIT {int(limit)}'
|
|
434
|
+
)
|
|
395
435
|
if offset:
|
|
396
436
|
query += f" OFFSET {int(offset)}"
|
|
397
437
|
rows = db.sql(query, params)
|
|
@@ -421,26 +461,79 @@ def search_masters(master_type: str, q: str = "", _user: dict = _viewer):
|
|
|
421
461
|
|
|
422
462
|
|
|
423
463
|
@router.get("/{master_type}/{name}/adjacent")
|
|
424
|
-
def adjacent_master(
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
464
|
+
def adjacent_master(
|
|
465
|
+
master_type: str,
|
|
466
|
+
name: str,
|
|
467
|
+
request: Request,
|
|
468
|
+
include_disabled: bool = True,
|
|
469
|
+
search: str | None = None,
|
|
470
|
+
search_fields: str | None = None,
|
|
471
|
+
order_by: str | None = None,
|
|
472
|
+
order: str = "asc",
|
|
473
|
+
_user: dict = _viewer,
|
|
474
|
+
):
|
|
475
|
+
"""Records immediately before/after ``name`` in the current master list.
|
|
476
|
+
|
|
477
|
+
Search, equality filters, disabled visibility, and ordering are shared with
|
|
478
|
+
``list_masters``. Keyset predicates keep navigation proportional to one
|
|
479
|
+
index lookup rather than numbering the entire result set.
|
|
480
|
+
"""
|
|
430
481
|
doctype, _ = _get_table(master_type)
|
|
431
482
|
if not doctype:
|
|
432
483
|
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
433
484
|
db = get_db()
|
|
434
|
-
|
|
435
|
-
|
|
485
|
+
where_parts, params, columns = _master_list_where(
|
|
486
|
+
db, doctype, master_type, request, include_disabled, search, search_fields,
|
|
487
|
+
)
|
|
488
|
+
sort_column, sort_direction = _master_order(columns, order_by, order)
|
|
489
|
+
base_where = (" AND ".join(where_parts)) if where_parts else "1 = 1"
|
|
490
|
+
current_rows = db.sql(
|
|
491
|
+
f'SELECT "{sort_column}" AS sort_value FROM "{doctype}" '
|
|
492
|
+
f"WHERE {base_where} AND name = ? LIMIT 1",
|
|
493
|
+
[*params, name],
|
|
494
|
+
)
|
|
495
|
+
if not current_rows:
|
|
496
|
+
return {"prev": None, "next": None}
|
|
497
|
+
current_value = current_rows[0]["sort_value"]
|
|
498
|
+
|
|
499
|
+
def neighbor(toward: str):
|
|
500
|
+
after = toward == "next"
|
|
501
|
+
same_cmp = ">" if sort_direction == "asc" else "<"
|
|
502
|
+
primary_cmp = same_cmp
|
|
503
|
+
if not after:
|
|
504
|
+
same_cmp = "<" if same_cmp == ">" else ">"
|
|
505
|
+
primary_cmp = "<" if primary_cmp == ">" else ">"
|
|
506
|
+
|
|
507
|
+
if sort_column == "name":
|
|
508
|
+
keyset = f'name {same_cmp} ?'
|
|
509
|
+
key_params = [name]
|
|
510
|
+
elif current_value is None:
|
|
511
|
+
if after:
|
|
512
|
+
keyset = f'"{sort_column}" IS NULL AND name {same_cmp} ?'
|
|
513
|
+
else:
|
|
514
|
+
keyset = (
|
|
515
|
+
f'("{sort_column}" IS NOT NULL OR '
|
|
516
|
+
f'("{sort_column}" IS NULL AND name {same_cmp} ?))'
|
|
517
|
+
)
|
|
518
|
+
key_params = [name]
|
|
519
|
+
else:
|
|
520
|
+
keyset = (
|
|
521
|
+
f'("{sort_column}" {primary_cmp} ? OR '
|
|
522
|
+
f'("{sort_column}" = ? AND name {same_cmp} ?)'
|
|
523
|
+
)
|
|
524
|
+
key_params = [current_value, current_value, name]
|
|
525
|
+
if after:
|
|
526
|
+
keyset += f' OR "{sort_column}" IS NULL'
|
|
527
|
+
keyset += ")"
|
|
436
528
|
|
|
437
|
-
def neighbor(cmp: str, direction: str):
|
|
438
529
|
rows = db.sql(
|
|
439
|
-
f'SELECT name FROM "{doctype}" WHERE {
|
|
440
|
-
f
|
|
530
|
+
f'SELECT name FROM "{doctype}" WHERE {base_where} AND {keyset} '
|
|
531
|
+
f'ORDER BY {_master_order_sql(sort_column, sort_direction, reverse=not after)} LIMIT 1',
|
|
532
|
+
[*params, *key_params],
|
|
533
|
+
)
|
|
441
534
|
return rows[0]["name"] if rows else None
|
|
442
535
|
|
|
443
|
-
return {"prev": neighbor("
|
|
536
|
+
return {"prev": neighbor("prev"), "next": neighbor("next")}
|
|
444
537
|
|
|
445
538
|
|
|
446
539
|
@router.get("/{master_type}/{name}")
|
|
@@ -90,6 +90,10 @@ MASTER_NAME_PREFIXES = {
|
|
|
90
90
|
"warehouse": "WH",
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
# Minimum numeric suffix width for sequential plugin master ids. Core masters
|
|
94
|
+
# retain the historical three-digit form; plugins can opt into e.g. FIDX-0001.
|
|
95
|
+
MASTER_NAME_DIGITS: dict[str, int] = {}
|
|
96
|
+
|
|
93
97
|
# High-volume plugin masters can opt out of sequential ids. Sequential naming
|
|
94
98
|
# must inspect existing suffixes after a process restart; opaque random ids keep
|
|
95
99
|
# inserts constant-time for index-scale reference tables.
|
|
@@ -218,6 +222,7 @@ def document_columns(doctype_slug: str) -> set:
|
|
|
218
222
|
|
|
219
223
|
def register_master(slug: str, table: str, name_field: str, *,
|
|
220
224
|
name_prefix: str | None = None,
|
|
225
|
+
name_digits: int = 3,
|
|
221
226
|
random_name: bool = False,
|
|
222
227
|
identity_alias: str | None = None,
|
|
223
228
|
description: str | None = None,
|
|
@@ -236,12 +241,16 @@ def register_master(slug: str, table: str, name_field: str, *,
|
|
|
236
241
|
|
|
237
242
|
`name_field` is the human display column (e.g. "company_name").
|
|
238
243
|
`name_prefix` enables auto-generated ids (prefix "LEAD" -> LEAD-001) when a
|
|
239
|
-
record is created without an explicit `name`. `
|
|
240
|
-
|
|
244
|
+
record is created without an explicit `name`. `name_digits` controls the
|
|
245
|
+
minimum sequential suffix width. `identity_alias` lets callers address the
|
|
246
|
+
`name` PK under a friendlier key, like item's `item_code`.
|
|
241
247
|
"""
|
|
242
248
|
MASTER_TABLES[slug] = (table, name_field)
|
|
243
249
|
if name_prefix:
|
|
250
|
+
if name_digits < 1:
|
|
251
|
+
raise ValueError("name_digits must be at least 1")
|
|
244
252
|
MASTER_NAME_PREFIXES[slug] = name_prefix
|
|
253
|
+
MASTER_NAME_DIGITS[slug] = name_digits
|
|
245
254
|
if random_name:
|
|
246
255
|
if not name_prefix:
|
|
247
256
|
raise ValueError("random_name requires name_prefix")
|
|
@@ -180,10 +180,10 @@ export const api = {
|
|
|
180
180
|
`/documents/${doctype}/${encodeURIComponent(name)}/adjacent${qs(params)}`,
|
|
181
181
|
),
|
|
182
182
|
|
|
183
|
-
// Prev/next master record around `name` in the
|
|
184
|
-
adjacentMaster: (masterType: string, name: string) =>
|
|
183
|
+
// Prev/next master record around `name` in the same order+filters as its list.
|
|
184
|
+
adjacentMaster: (masterType: string, name: string, params?: Record<string, string | number | undefined>) =>
|
|
185
185
|
request<{ prev: string | null; next: string | null }>(
|
|
186
|
-
`/masters/${masterType}/${encodeURIComponent(name)}/adjacent`,
|
|
186
|
+
`/masters/${masterType}/${encodeURIComponent(name)}/adjacent${qs(params)}`,
|
|
187
187
|
),
|
|
188
188
|
|
|
189
189
|
createDocument: (doctype: string, data: any) =>
|
|
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
|