lambda-erp 0.6.13__tar.gz → 0.6.14__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 (84) hide show
  1. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/PKG-INFO +1 -1
  2. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/attachments.py +72 -14
  3. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/chat.py +97 -15
  4. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/mcp.py +2 -1
  5. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/services.py +155 -52
  6. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/pyproject.toml +1 -1
  7. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/.gitignore +0 -0
  8. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/LICENSE +0 -0
  9. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/README.md +0 -0
  10. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/__init__.py +0 -0
  11. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/auth.py +0 -0
  12. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/demo_limits.py +0 -0
  14. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/deps.py +0 -0
  15. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/errors.py +0 -0
  16. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/main.py +0 -0
  17. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/oauth.py +0 -0
  18. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/pdf.py +0 -0
  19. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/providers.py +0 -0
  20. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/chat_api.py +0 -0
  27. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/documents.py +0 -0
  28. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/proposals.py +0 -0
  30. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/reports.py +0 -0
  31. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/setup.py +0 -0
  32. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/templates/document.html +0 -0
  33. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/LICENSE +0 -0
  36. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/README.md +0 -0
  37. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/src/api/client.ts +0 -0
  38. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/__init__.py +0 -0
  39. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/__init__.py +0 -0
  40. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/bank_transaction.py +0 -0
  41. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/budget.py +0 -0
  42. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  43. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/general_ledger.py +0 -0
  44. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/journal_entry.py +0 -0
  45. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/payment_entry.py +0 -0
  46. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/pos_invoice.py +0 -0
  47. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/purchase_invoice.py +0 -0
  48. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/revaluation.py +0 -0
  49. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/sales_invoice.py +0 -0
  50. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/__init__.py +0 -0
  51. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/engine.py +0 -0
  52. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/pack.py +0 -0
  53. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  54. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  55. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  56. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  57. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  58. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  59. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/profiles.py +0 -0
  60. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/spine.py +0 -0
  61. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/subscription.py +0 -0
  62. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/buying/__init__.py +0 -0
  63. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/buying/purchase_order.py +0 -0
  64. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/__init__.py +0 -0
  65. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/currency.py +0 -0
  66. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/defaults.py +0 -0
  67. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/pricing_rule.py +0 -0
  68. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  69. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/database.py +0 -0
  70. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/utils.py +0 -0
  84. {lambda_erp-0.6.13 → lambda_erp-0.6.14}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.6.13
3
+ Version: 0.6.14
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -16,10 +16,55 @@ router = APIRouter(prefix="/chat", tags=["chat-attachments"])
16
16
 
17
17
  MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
18
18
  MAX_ATTACHMENTS_PER_SESSION = 100 # sanity cap
19
+
20
+ # Binary Office / OpenDocument formats. The chat model reads these directly via
21
+ # the OpenAI `file` content part (the same mechanism PDFs use) — the endpoint
22
+ # accepts the raw bytes and reasons about them, so there is NO server-side
23
+ # conversion/parsing here. Keep this set authoritative: the upload gate, the
24
+ # extension map, and build_multimodal_content all read from it.
25
+ OFFICE_MIME_TYPES = {
26
+ # Spreadsheets
27
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx
28
+ "application/vnd.ms-excel", # xls
29
+ "application/vnd.oasis.opendocument.spreadsheet", # ods
30
+ # Word-processing documents
31
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx
32
+ "application/msword", # doc
33
+ "application/vnd.oasis.opendocument.text", # odt
34
+ # Presentations
35
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx
36
+ "application/vnd.ms-powerpoint", # ppt
37
+ "application/vnd.oasis.opendocument.presentation", # odp
38
+ }
39
+
40
+ # Plain-text formats — inlined into the prompt as text (no file part needed).
41
+ TEXT_MIME_TYPES = {"text/csv", "text/plain"}
42
+
19
43
  ALLOWED_MIME_TYPES = {
20
44
  "image/png", "image/jpeg", "image/gif", "image/webp",
21
45
  "application/pdf",
46
+ } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES
47
+
48
+ # Extension -> canonical mime, so a correctly-named file still uploads when the
49
+ # browser/OS mislabels its type (Office files are frequently sent as
50
+ # application/octet-stream, and CSV as application/vnd.ms-excel).
51
+ _EXT_TO_MIME = {
52
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
53
+ "xls": "application/vnd.ms-excel",
54
+ "ods": "application/vnd.oasis.opendocument.spreadsheet",
55
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
56
+ "doc": "application/msword",
57
+ "odt": "application/vnd.oasis.opendocument.text",
58
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
59
+ "ppt": "application/vnd.ms-powerpoint",
60
+ "odp": "application/vnd.oasis.opendocument.presentation",
61
+ "csv": "text/csv",
62
+ "txt": "text/plain",
63
+ "pdf": "application/pdf",
64
+ "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
65
+ "gif": "image/gif", "webp": "image/webp",
22
66
  }
67
+ _MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
23
68
 
24
69
  UPLOAD_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "uploads")
25
70
 
@@ -46,14 +91,7 @@ def _safe_ext(filename: str, mime: str) -> str:
46
91
  ext = filename.rsplit(".", 1)[1].lower()
47
92
  if len(ext) <= 5 and ext.isalnum():
48
93
  return ext
49
- mime_ext = {
50
- "image/png": "png",
51
- "image/jpeg": "jpg",
52
- "image/gif": "gif",
53
- "image/webp": "webp",
54
- "application/pdf": "pdf",
55
- }
56
- return mime_ext.get(mime, "bin")
94
+ return _MIME_TO_EXT.get(mime, "bin")
57
95
 
58
96
 
59
97
  @router.post("/attachments")
@@ -65,7 +103,17 @@ async def upload_attachment(
65
103
  """Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
66
104
  mime = (file.content_type or "application/octet-stream").lower()
67
105
  if mime not in ALLOWED_MIME_TYPES:
68
- raise HTTPException(status_code=400, detail=f"Unsupported file type: {mime}. Allowed: images, PDF.")
106
+ # Browsers/OSes often mislabel Office files (e.g. octet-stream) fall
107
+ # back to the filename extension so a correctly-named file still uploads.
108
+ ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else ""
109
+ if ext in _EXT_TO_MIME:
110
+ mime = _EXT_TO_MIME[ext]
111
+ else:
112
+ raise HTTPException(
113
+ status_code=400,
114
+ detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
115
+ "spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument)."),
116
+ )
69
117
 
70
118
  data = await file.read()
71
119
  if len(data) > MAX_ATTACHMENT_SIZE:
@@ -186,23 +234,33 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
186
234
 
187
235
 
188
236
  def build_multimodal_content(attachment: dict) -> dict:
189
- """Convert an attachment dict (with raw data) into an OpenAI multimodal content part."""
237
+ """Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
238
+
239
+ Images go as `image_url`; plain-text files (CSV/TXT) are inlined as text;
240
+ PDFs and binary Office/OpenDocument files go as a `file` part carrying the
241
+ raw bytes — the model reads them directly, no server-side conversion.
242
+ """
190
243
  mime = attachment["mime_type"]
191
- data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
244
+ filename = attachment["filename"]
192
245
  if mime.startswith("image/"):
246
+ data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
193
247
  return {
194
248
  "type": "image_url",
195
249
  "image_url": {"url": f"data:{mime};base64,{data_b64}"},
196
250
  }
197
- if mime == "application/pdf":
251
+ if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
252
+ text = attachment["data"].decode("utf-8", errors="replace")
253
+ return {"type": "text", "text": f"[File: {filename}]\n{text}"}
254
+ if mime == "application/pdf" or mime in OFFICE_MIME_TYPES:
255
+ data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
198
256
  return {
199
257
  "type": "file",
200
258
  "file": {
201
- "filename": attachment["filename"],
259
+ "filename": filename,
202
260
  "file_data": f"data:{mime};base64,{data_b64}",
203
261
  },
204
262
  }
205
- return {"type": "text", "text": f"[Unsupported attachment: {attachment['filename']}]"}
263
+ return {"type": "text", "text": f"[Unsupported attachment: {filename}]"}
206
264
 
207
265
 
208
266
  def list_session_attachments(session_id: str, user_id: str) -> list[dict]:
@@ -458,15 +458,17 @@ TOOLS = [
458
458
  "type": "function",
459
459
  "function": {
460
460
  "name": "list_documents",
461
- "description": "List or search documents of a given type. Returns an array of document summaries (header fields only — child tables like 'items' and 'taxes' are NOT included to keep results compact). Use get_document to drill into a single document's line items. Results are ordered by creation DESC (newest first).",
461
+ "description": "List or search documents of a given type. Returns an array of document summaries (header fields only — child tables like 'items' and 'taxes' are NOT included to keep results compact). Use get_document to drill into a single document's line items. Results are ordered by creation DESC (newest first). Page past `limit` with `offset`; trim the payload to only the columns you need with `fields`.",
462
462
  "parameters": {
463
463
  "type": "object",
464
464
  "properties": {
465
465
  "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
466
- "filters": {"type": "object", "description": "Optional equality filters on any column, e.g. {\"status\": \"Draft\", \"customer\": \"CUST-001\", \"lead_id\": \"LEAD-3316\"}", "default": {}},
466
+ "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.", "default": {}},
467
467
  "order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
468
468
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
469
469
  "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
470
+ "offset": {"type": "integer", "description": "Skip this many rows before returning — for paging past `limit`. Default 0.", "default": 0},
471
+ "fields": {"type": "array", "items": {"type": "string"}, "description": "Optional: return ONLY these columns (plus name) instead of every field — keeps results lean when you need just a few. Unknown fields are ignored."},
470
472
  },
471
473
  "required": ["doctype"],
472
474
  },
@@ -521,6 +523,41 @@ TOOLS = [
521
523
  },
522
524
  },
523
525
  },
526
+ {
527
+ "type": "function",
528
+ "function": {
529
+ "name": "batch_update_documents",
530
+ "description": (
531
+ "Update MANY existing documents of the SAME doctype in ONE call — far cheaper than a "
532
+ "separate update_document per record when applying changes across a set (e.g. tagging or "
533
+ "qualifying a batch of leads). Pass `doctype` and `updates`: an array of {\"name\", \"data\"} "
534
+ "objects where `data` holds the fields to change on that record (identical shape to "
535
+ "update_document's `data`, including child-table arrays). Best-effort and per-item: each "
536
+ "record is updated independently, so one failure doesn't undo the others. Max 200 per call. "
537
+ "Only drafts (docstatus=0) can be updated. Returns {\"updated\", \"failed\", "
538
+ "\"results\": [{\"name\", \"ok\", \"error\"?}]}."
539
+ ),
540
+ "parameters": {
541
+ "type": "object",
542
+ "properties": {
543
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
544
+ "updates": {
545
+ "type": "array",
546
+ "description": "Up to 200 {name, data} objects — one per record to update.",
547
+ "items": {
548
+ "type": "object",
549
+ "properties": {
550
+ "name": {"type": "string", "description": "Document name/ID to update"},
551
+ "data": {"type": "object", "description": "Fields to change on this record (same as update_document's data)."},
552
+ },
553
+ "required": ["name", "data"],
554
+ },
555
+ },
556
+ },
557
+ "required": ["doctype", "updates"],
558
+ },
559
+ },
560
+ },
524
561
  {
525
562
  "type": "function",
526
563
  "function": {
@@ -746,7 +783,7 @@ TOOLS = [
746
783
  "type": "function",
747
784
  "function": {
748
785
  "name": "list_chat_attachments",
749
- "description": "List all files (PDFs, images) the user has uploaded in this chat session. Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need to find an attachment to retrieve.",
786
+ "description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument). Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need to find an attachment to retrieve.",
750
787
  "parameters": {"type": "object", "properties": {}},
751
788
  },
752
789
  },
@@ -754,7 +791,7 @@ TOOLS = [
754
791
  "type": "function",
755
792
  "function": {
756
793
  "name": "retrieve_chat_attachment",
757
- "description": "Retrieve a previously uploaded file (PDF or image) by its id and inject it back into the conversation so you can read/analyze it. Use this when the user asks a follow-up about a file they uploaded earlier in the chat but it's no longer in the immediate context. Call list_chat_attachments first if you don't know the id.",
794
+ "description": "Retrieve a previously uploaded file (PDF, image, spreadsheet, or document) by its id and inject it back into the conversation so you can read/analyze it — e.g. to cross-check a spreadsheet of companies against the CRM. Use this when the user asks a follow-up about a file they uploaded earlier in the chat but it's no longer in the immediate context. Call list_chat_attachments first if you don't know the id.",
758
795
  "parameters": {
759
796
  "type": "object",
760
797
  "properties": {
@@ -1251,22 +1288,59 @@ def build_tools():
1251
1288
  # ---------------------------------------------------------------------------
1252
1289
 
1253
1290
 
1291
+ # Filter/order keys that aren't real columns but are meaningful to list_documents.
1292
+ _LIST_META_FILTER_KEYS = {"from_date", "to_date", "date_field", "search", "search_fields"}
1293
+
1294
+
1295
+ def _validate_filter_columns(doctype_slug, filters) -> str | None:
1296
+ """Reject a filter naming a column the doctype doesn't have. The key is
1297
+ interpolated into SQL, so this both closes that injection seam and hands the
1298
+ model a clean error instead of a 500. Returns an error string, or None."""
1299
+ if not filters:
1300
+ return None
1301
+ cols = services.document_columns(doctype_slug)
1302
+ if not cols:
1303
+ return None # unknown doctype -> handled downstream
1304
+ bad = [k for k in filters if k not in cols and k not in _LIST_META_FILTER_KEYS]
1305
+ if bad:
1306
+ return (f"Unknown filter column(s) for {doctype_slug}: {', '.join(bad)}. "
1307
+ f"Valid columns include: {', '.join(sorted(cols)[:40])}.")
1308
+ return None
1309
+
1310
+
1254
1311
  def _handle_list_documents(args):
1255
1312
  # List views don't need child tables — strip them so the LLM can see more rows
1256
1313
  # within the tool-result budget. Use get_document to drill into one doc.
1257
- cls_entry = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(args["doctype"], ""))
1314
+ doctype = args["doctype"]
1315
+ filters = args.get("filters") or {}
1316
+ err = _validate_filter_columns(doctype, filters)
1317
+ if err:
1318
+ return {"error": err}
1319
+ order_by = args.get("order_by")
1320
+ cols = services.document_columns(doctype)
1321
+ if order_by and cols and order_by not in cols:
1322
+ return {"error": f"Unknown order_by column for {doctype}: {order_by}"}
1323
+
1324
+ cls_entry = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(doctype, ""))
1258
1325
  child_keys = list(cls_entry.CHILD_TABLES.keys()) if cls_entry and cls_entry.CHILD_TABLES else []
1259
1326
 
1260
- rows = services.list_documents(
1261
- args["doctype"],
1262
- filters=args.get("filters"),
1263
- limit=args.get("limit", 20),
1264
- order_by=args.get("order_by"),
1265
- order=args.get("order", "desc"),
1266
- )
1267
- for row in rows:
1268
- for key in child_keys:
1269
- row.pop(key, None)
1327
+ fields = args.get("fields")
1328
+ try:
1329
+ rows = services.list_documents(
1330
+ doctype,
1331
+ filters=filters,
1332
+ limit=args.get("limit", 20),
1333
+ offset=args.get("offset", 0) or 0,
1334
+ order_by=order_by,
1335
+ order=args.get("order", "desc"),
1336
+ fields=fields,
1337
+ )
1338
+ except ValueError as e: # bad filter operator / malformed operator list
1339
+ return {"error": str(e)}
1340
+ if not fields: # projection already excludes child tables
1341
+ for row in rows:
1342
+ for key in child_keys:
1343
+ row.pop(key, None)
1270
1344
  return rows
1271
1345
 
1272
1346
 
@@ -1285,6 +1359,13 @@ def _handle_update_document(args):
1285
1359
  return services.update_document(args["doctype"], args["name"], args.get("data", {}))
1286
1360
 
1287
1361
 
1362
+ def _handle_batch_update_documents(args):
1363
+ try:
1364
+ return services.batch_update_documents(args["doctype"], args.get("updates"))
1365
+ except ValueError as e: # bad doctype / empty or oversized batch
1366
+ return {"error": str(e)}
1367
+
1368
+
1288
1369
  def _handle_submit_document(args):
1289
1370
  return services.submit_document(args["doctype"], args["name"])
1290
1371
 
@@ -1813,6 +1894,7 @@ TOOL_HANDLERS = {
1813
1894
  "get_document": _handle_get_document,
1814
1895
  "create_document": _handle_create_document,
1815
1896
  "update_document": _handle_update_document,
1897
+ "batch_update_documents": _handle_batch_update_documents,
1816
1898
  "submit_document": _handle_submit_document,
1817
1899
  "cancel_document": _handle_cancel_document,
1818
1900
  "discard_document": _handle_discard_document,
@@ -44,7 +44,8 @@ _EXCLUDE = {
44
44
  # Mirror the REST permission model: reads are viewer+, writes are manager+,
45
45
  # delete_master is admin-only (the handler also re-checks).
46
46
  _WRITE = {
47
- "create_document", "update_document", "submit_document", "cancel_document",
47
+ "create_document", "update_document", "batch_update_documents",
48
+ "submit_document", "cancel_document",
48
49
  "discard_document", "convert_document", "create_master", "update_master",
49
50
  }
50
51
  _ADMIN = {"delete_master"}
@@ -313,6 +313,45 @@ def update_document(doctype_slug: str, name: str, data: dict) -> dict:
313
313
  return doc.as_dict()
314
314
 
315
315
 
316
+ def batch_update_documents(doctype_slug: str, updates: list) -> dict:
317
+ """Apply many ``{"name", "data"}`` updates to ONE doctype in a single call.
318
+
319
+ Best-effort and per-item: each update runs as its own update_document (its
320
+ own save + hooks), so a failing row doesn't roll back the others. Returns
321
+ ``{"updated", "failed", "results": [{"name", "ok", "error"?}]}``. Because it
322
+ reuses update_document, every plugin save-hook still fires — e.g. a lead
323
+ update carrying a transient ``_note`` records its timeline Activity here too.
324
+ """
325
+ doctype, cls = get_document_class(doctype_slug)
326
+ if not cls:
327
+ raise ValueError(f"Unknown document type: {doctype_slug}")
328
+ if not isinstance(updates, list) or not updates:
329
+ raise ValueError("updates must be a non-empty array of {name, data} objects")
330
+ if len(updates) > _BATCH_MAX:
331
+ raise ValueError(f"Too many updates ({len(updates)}); max {_BATCH_MAX} per call")
332
+
333
+ results, updated = [], 0
334
+ for i, item in enumerate(updates):
335
+ if not isinstance(item, dict):
336
+ results.append({"name": None, "ok": False, "error": f"item {i} is not an object"})
337
+ continue
338
+ name = item.get("name")
339
+ data = item.get("data")
340
+ if not name:
341
+ results.append({"name": None, "ok": False, "error": f"item {i} is missing 'name'"})
342
+ continue
343
+ if not isinstance(data, dict) or not data:
344
+ results.append({"name": name, "ok": False, "error": "missing 'data' object"})
345
+ continue
346
+ try:
347
+ update_document(doctype_slug, name, data)
348
+ results.append({"name": name, "ok": True})
349
+ updated += 1
350
+ except Exception as e: # noqa: BLE001 — per-item best effort; report, don't abort
351
+ results.append({"name": name, "ok": False, "error": str(e)})
352
+ return {"updated": updated, "failed": len(results) - updated, "results": results}
353
+
354
+
316
355
  def submit_document(doctype_slug: str, name: str) -> dict:
317
356
  doctype, cls = get_document_class(doctype_slug)
318
357
  if not cls:
@@ -473,8 +512,87 @@ def _order_clause(order_by: str = None, order: str = "desc") -> str:
473
512
  return f'"{order_by}" {direction}'
474
513
 
475
514
 
515
+ # --- Filter operators (safe, whitelisted) ---
516
+ #
517
+ # A list-style filter value is normally a scalar (equality), but it may also be
518
+ # an operator form so callers can express inequality / range / NULL checks:
519
+ # {"amount": [">", 100]} {"fit": ["!=", "A"]}
520
+ # {"fit": ["is null"]} {"main_email": ["is not null"]}
521
+ # The operator is looked up in this whitelist and NEVER interpolated from raw
522
+ # caller input, so an operator filter can't inject SQL. (The column name is
523
+ # validated separately against the doctype's real columns by the callers.)
524
+ _FILTER_OPS = {
525
+ "=": "=", "==": "=", "!=": "!=", "<>": "!=",
526
+ ">": ">", "<": "<", ">=": ">=", "<=": "<=",
527
+ "like": "LIKE", "not like": "NOT LIKE",
528
+ "is null": "IS NULL", "is not null": "IS NOT NULL",
529
+ }
530
+ _NULLARY_OPS = {"IS NULL", "IS NOT NULL"} # take no bound value
531
+
532
+ # Max records a single batch write may touch (keeps one call bounded).
533
+ _BATCH_MAX = 200
534
+
535
+
536
+ def _resolve_op(raw) -> str:
537
+ op = _FILTER_OPS.get(str(raw).strip().lower())
538
+ if not op:
539
+ raise ValueError(
540
+ f"Unsupported filter operator: {raw!r} "
541
+ f"(allowed: {', '.join(sorted(set(_FILTER_OPS)))})"
542
+ )
543
+ return op
544
+
545
+
546
+ def _filter_atom(col: str, value):
547
+ """Build (sql_clause, params) for one filter entry. `value` may be:
548
+ scalar -> `"col" = ?`
549
+ [op, val] -> `"col" <op> ?` (op whitelisted)
550
+ [op] -> `"col" IS [NOT] NULL` (nullary op only)
551
+ Raises ValueError on an unknown operator or a malformed operator list — the
552
+ tool/REST layer surfaces that as a clean error rather than bad SQL."""
553
+ if isinstance(value, (list, tuple)):
554
+ if len(value) == 1:
555
+ op = _resolve_op(value[0])
556
+ if op not in _NULLARY_OPS:
557
+ raise ValueError(f"Filter operator {value[0]!r} requires a value")
558
+ return f'"{col}" {op}', []
559
+ if len(value) == 2:
560
+ op = _resolve_op(value[0])
561
+ if op in _NULLARY_OPS:
562
+ return f'"{col}" {op}', [] # value ignored for IS [NOT] NULL
563
+ return f'"{col}" {op} ?', [value[1]]
564
+ raise ValueError(
565
+ f"Malformed filter for {col!r}: expected a scalar, [op], or [op, value]"
566
+ )
567
+ return f'"{col}" = ?', [value]
568
+
569
+
570
+ def _where_from_filters(db_filters: dict):
571
+ """(where_parts, params) for a dict of already-parsed equality/operator
572
+ filters, via the whitelisted _filter_atom builder."""
573
+ where_parts, params = [], []
574
+ for k, v in db_filters.items():
575
+ clause, ps = _filter_atom(k, v)
576
+ where_parts.append(clause)
577
+ params.extend(ps)
578
+ return where_parts, params
579
+
580
+
581
+ def _projection_columns(doctype_slug: str, fields) -> set:
582
+ """The set of real columns to keep for a `fields` projection — always
583
+ includes 'name'. Unknown field names are dropped (validated against the
584
+ doctype's real columns), so a projection can never widen or inject."""
585
+ real = document_columns(doctype_slug)
586
+ keep = {"name"}
587
+ for f in (fields or []):
588
+ if f in real:
589
+ keep.add(f)
590
+ return keep
591
+
592
+
476
593
  def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
477
- include_discarded: bool = False, order_by: str = None, order: str = "desc") -> list:
594
+ include_discarded: bool = False, order_by: str = None, order: str = "desc",
595
+ fields: list = None) -> list:
478
596
  doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
479
597
  if not doctype:
480
598
  raise ValueError(f"Unknown document type: {doctype_slug}")
@@ -520,31 +638,39 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
520
638
 
521
639
  search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
522
640
 
523
- # If both from and to are set, the dict can only hold one constraint per key
524
- # Fall through to a raw SQL query for that case
641
+ order_clause = _order_clause(order_by, order)
642
+
643
+ # get_all handles only pure-equality filters (no OFFSET, no free-text OR-group,
644
+ # no whitelisted operator/NULL forms), so drop to raw SQL whenever any of those
645
+ # is in play. An operator filter shows up as a list/tuple value in db_filters.
646
+ has_op_filter = any(isinstance(v, (list, tuple)) for v in db_filters.values())
647
+
525
648
  if date_field and from_date and to_date:
526
- return _list_with_date_range(
649
+ # Both bounds set: the dict can only hold one constraint per key, so this
650
+ # path adds both date conditions explicitly.
651
+ rows = _list_with_date_range(
527
652
  db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset,
528
653
  extra_where=search_where, extra_params=search_params,
529
654
  )
530
-
531
- order_clause = _order_clause(order_by, order)
532
-
533
- # get_all can't express OFFSET or a free-text OR-group, so drop to raw SQL
534
- # whenever either is in play.
535
- if offset or search_where:
536
- return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause,
655
+ elif offset or search_where or has_op_filter:
656
+ rows = _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause,
537
657
  extra_where=search_where, extra_params=search_params)
658
+ else:
659
+ rows = db.get_all(
660
+ doctype,
661
+ filters=db_filters if db_filters else None,
662
+ fields=["*"],
663
+ order_by=order_clause,
664
+ limit=limit,
665
+ )
666
+ rows = _attach_children(db, doctype_slug, rows)
538
667
 
539
- rows = db.get_all(
540
- doctype,
541
- filters=db_filters if db_filters else None,
542
- fields=["*"],
543
- order_by=order_clause,
544
- limit=limit,
545
- )
546
-
547
- return _attach_children(db, doctype_slug, rows)
668
+ # `fields` projection: keep only the requested real columns (+ name) to trim
669
+ # the payload — child tables (never selected here) drop out automatically.
670
+ if fields:
671
+ keep = _projection_columns(doctype_slug, fields)
672
+ rows = [{k: v for k, v in row.items() if k in keep} for row in rows]
673
+ return rows
548
674
 
549
675
 
550
676
  def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
@@ -588,14 +714,9 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
588
714
  if date_field and to_date:
589
715
  where_parts.append(f'"{date_field}" <= ?')
590
716
  params.append(to_date)
591
- for k, v in db_filters.items():
592
- if isinstance(v, (list, tuple)) and len(v) == 2:
593
- op, val = v
594
- where_parts.append(f'"{k}" {op} ?')
595
- params.append(val)
596
- else:
597
- where_parts.append(f'"{k}" = ?')
598
- params.append(v)
717
+ fparts, fparams = _where_from_filters(db_filters)
718
+ where_parts.extend(fparts)
719
+ params.extend(fparams)
599
720
 
600
721
  search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
601
722
  if search_where:
@@ -639,12 +760,8 @@ def _filter_where(db, doctype: str, doctype_slug: str, filters: dict, include_di
639
760
  where_parts.append(f'"{date_field}" >= ?'); params.append(from_date)
640
761
  if date_field and to_date:
641
762
  where_parts.append(f'"{date_field}" <= ?'); params.append(to_date)
642
- for k, v in db_filters.items():
643
- if isinstance(v, (list, tuple)) and len(v) == 2:
644
- op, val = v
645
- where_parts.append(f'"{k}" {op} ?'); params.append(val)
646
- else:
647
- where_parts.append(f'"{k}" = ?'); params.append(v)
763
+ fparts, fparams = _where_from_filters(db_filters)
764
+ where_parts.extend(fparts); params.extend(fparams)
648
765
  search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
649
766
  if search_where:
650
767
  where_parts.append(search_where)
@@ -692,16 +809,7 @@ def adjacent_documents(doctype_slug: str, name: str, filters: dict = None,
692
809
 
693
810
  def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC",
694
811
  extra_where=None, extra_params=None):
695
- where_parts = []
696
- params = []
697
- for k, v in db_filters.items():
698
- if isinstance(v, (list, tuple)) and len(v) == 2:
699
- op, val = v
700
- where_parts.append(f'"{k}" {op} ?')
701
- params.append(val)
702
- else:
703
- where_parts.append(f'"{k}" = ?')
704
- params.append(v)
812
+ where_parts, params = _where_from_filters(db_filters)
705
813
  if extra_where:
706
814
  where_parts.append(extra_where)
707
815
  params.extend(extra_params or [])
@@ -740,14 +848,9 @@ def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field,
740
848
  """List documents when both from_date and to_date are set."""
741
849
  where_parts = [f'"{date_field}" >= ?', f'"{date_field}" <= ?']
742
850
  params = [from_date, to_date]
743
- for k, v in extra_filters.items():
744
- if isinstance(v, (list, tuple)) and len(v) == 2:
745
- op, val = v
746
- where_parts.append(f'"{k}" {op} ?')
747
- params.append(val)
748
- else:
749
- where_parts.append(f'"{k}" = ?')
750
- params.append(v)
851
+ fparts, fparams = _where_from_filters(extra_filters)
852
+ where_parts.extend(fparts)
853
+ params.extend(fparams)
751
854
  if extra_where:
752
855
  where_parts.append(extra_where)
753
856
  params.extend(extra_params or [])
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.6.13"
3
+ version = "0.6.14"
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