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.
Files changed (89) hide show
  1. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/PKG-INFO +1 -9
  2. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/README.md +0 -8
  3. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/masters.py +147 -54
  4. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/services.py +11 -2
  5. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/src/api/client.ts +3 -3
  6. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/pyproject.toml +1 -1
  7. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/.gitignore +0 -0
  8. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/LICENSE +0 -0
  9. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/__init__.py +0 -0
  10. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/attachments.py +0 -0
  11. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/auth.py +0 -0
  12. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/chat.py +0 -0
  14. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/deps.py +0 -0
  16. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/errors.py +0 -0
  17. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/main.py +0 -0
  18. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/oauth.py +0 -0
  19. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/pdf.py +0 -0
  20. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/providers.py +0 -0
  21. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/actions.py +0 -0
  25. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/admin.py +0 -0
  26. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/analytics.py +0 -0
  27. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/availability.py +0 -0
  28. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/bank_reconciliation.py +0 -0
  29. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/chat_api.py +0 -0
  30. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/documents.py +0 -0
  31. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/mcp.py +0 -0
  32. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/proposals.py +0 -0
  33. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/reports.py +0 -0
  34. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/routers/setup.py +0 -0
  35. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/templates/document.html +0 -0
  36. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/api/templates/proposal.html +0 -0
  37. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/docs/agents/README.md +0 -0
  38. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/LICENSE +0 -0
  39. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/frontend/README.md +0 -0
  40. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/__init__.py +0 -0
  41. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/__init__.py +0 -0
  42. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/bank_transaction.py +0 -0
  43. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/budget.py +0 -0
  44. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  45. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/general_ledger.py +0 -0
  46. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/journal_entry.py +0 -0
  47. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/payment_entry.py +0 -0
  48. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/pos_invoice.py +0 -0
  49. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/purchase_invoice.py +0 -0
  50. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/revaluation.py +0 -0
  51. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/sales_invoice.py +0 -0
  52. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/__init__.py +0 -0
  53. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/engine.py +0 -0
  54. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/pack.py +0 -0
  55. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  56. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  57. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  58. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  59. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  60. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  61. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/profiles.py +0 -0
  62. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/setup/spine.py +0 -0
  63. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/accounting/subscription.py +0 -0
  64. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/__init__.py +0 -0
  65. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/asset.py +0 -0
  66. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/assets/reservation.py +0 -0
  67. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/buying/__init__.py +0 -0
  68. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/buying/purchase_order.py +0 -0
  69. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/__init__.py +0 -0
  70. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/currency.py +0 -0
  71. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/defaults.py +0 -0
  72. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/pricing_rule.py +0 -0
  73. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  74. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/database.py +0 -0
  75. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/exceptions.py +0 -0
  76. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/hooks.py +0 -0
  77. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/model.py +0 -0
  78. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/__init__.py +0 -0
  79. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/proposal.py +0 -0
  80. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/quotation.py +0 -0
  81. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/selling/sales_order.py +0 -0
  82. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/simulation.py +0 -0
  83. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/__init__.py +0 -0
  84. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/delivery_note.py +0 -0
  85. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/purchase_receipt.py +0 -0
  86. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/stock_entry.py +0 -0
  87. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/stock/stock_ledger.py +0 -0
  88. {lambda_erp-0.8.15 → lambda_erp-0.8.17}/lambda_erp/utils.py +0 -0
  89. {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.15
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, MASTER_RANDOM_NAME_TYPES, MASTER_IDENTITY_ALIAS,
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:03d}"):
183
+ while db.exists(doctype, f"{prefix}-{number:0{digits}d}"):
183
184
  number += 1
184
- return f"{prefix}-{number:03d}"
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(db, doctype, prefix)
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 = db._get_table_columns(doctype)
345
-
346
- where_parts: list = []
347
- params: list = []
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 = f'SELECT {projection} FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
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(master_type: str, name: str, include_disabled: bool = True,
425
- _user: dict = _viewer):
426
- """The records immediately before/after `name` in the list's order (name
427
- ASC) — {"prev": name|None, "next": name|None}. Keyset queries on the
428
- primary key. include_disabled defaults True to step 1:1 with the master
429
- list, which shows disabled records too."""
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
- active = ("" if include_disabled or "disabled" not in db._get_table_columns(doctype)
435
- else "disabled = 0 AND ")
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 {active}name {cmp} ? '
440
- f"ORDER BY name {direction} LIMIT 1", [name])
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("<", "DESC"), "next": neighbor(">", "ASC")}
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`. `identity_alias` lets callers
240
- address the `name` PK under a friendlier key, like item's `item_code`.
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 master list's order (name ASC).
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) =>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.15"
3
+ version = "0.8.17"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes