lambda-erp 0.8.32__tar.gz → 0.8.34__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 (104) hide show
  1. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/chat.py +65 -24
  3. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/errors.py +9 -0
  4. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/pdf.py +3 -6
  5. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/availability.py +1 -1
  6. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/chat_api.py +8 -6
  7. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/documents.py +6 -0
  8. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/masters.py +116 -2
  9. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/services.py +71 -6
  10. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_reconciliation.py +6 -0
  11. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_transaction.py +14 -0
  12. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/budget.py +19 -8
  13. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/general_ledger.py +2 -1
  14. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/journal_entry.py +47 -35
  15. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/payment_entry.py +64 -25
  16. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/pos_invoice.py +6 -23
  17. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/purchase_invoice.py +11 -65
  18. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/sales_invoice.py +11 -88
  19. lambda_erp-0.8.34/lambda_erp/accounting/settlement.py +51 -0
  20. lambda_erp-0.8.34/lambda_erp/accounting/subscription.py +187 -0
  21. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/asset.py +9 -0
  22. lambda_erp-0.8.34/lambda_erp/assets/lifecycle.py +103 -0
  23. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/reservation.py +42 -6
  24. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/buying/purchase_order.py +9 -34
  25. lambda_erp-0.8.34/lambda_erp/controllers/item_prices.py +22 -0
  26. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/pricing_rule.py +11 -2
  27. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/taxes_and_totals.py +5 -3
  28. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/database.py +106 -4
  29. lambda_erp-0.8.34/lambda_erp/master_integrity.py +25 -0
  30. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/model.py +147 -105
  31. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/proposal.py +28 -1
  32. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/quotation.py +2 -1
  33. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/sales_order.py +14 -41
  34. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/delivery_note.py +10 -51
  35. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/purchase_receipt.py +14 -60
  36. lambda_erp-0.8.34/lambda_erp/stock/stock_entry.py +231 -0
  37. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/stock_ledger.py +13 -5
  38. lambda_erp-0.8.34/lambda_erp/validation.py +153 -0
  39. lambda_erp-0.8.34/lambda_erp/workflow.py +224 -0
  40. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/pyproject.toml +1 -1
  41. lambda_erp-0.8.32/lambda_erp/accounting/subscription.py +0 -132
  42. lambda_erp-0.8.32/lambda_erp/stock/stock_entry.py +0 -330
  43. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/.gitignore +0 -0
  44. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/LICENSE +0 -0
  45. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/README.md +0 -0
  46. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/__init__.py +0 -0
  47. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/attachments.py +0 -0
  48. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/auth.py +0 -0
  49. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/bootstrap.py +0 -0
  50. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/demo_limits.py +0 -0
  51. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/deps.py +0 -0
  52. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/list_values.py +0 -0
  53. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/main.py +0 -0
  54. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/oauth.py +0 -0
  55. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/providers.py +0 -0
  56. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/remarks_md.py +0 -0
  57. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/__init__.py +0 -0
  58. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/accounting.py +0 -0
  59. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/actions.py +0 -0
  60. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/admin.py +0 -0
  61. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/analytics.py +0 -0
  62. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/bank_reconciliation.py +0 -0
  63. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/bank_statements.py +0 -0
  64. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/mcp.py +0 -0
  65. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/proposals.py +0 -0
  66. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/reports.py +0 -0
  67. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/setup.py +0 -0
  68. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/templates/document.html +0 -0
  69. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/templates/proposal.html +0 -0
  70. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/tool_permissions.py +0 -0
  71. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/docs/agents/README.md +0 -0
  72. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/LICENSE +0 -0
  73. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/README.md +0 -0
  74. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/src/api/client.ts +0 -0
  75. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/__init__.py +0 -0
  76. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/__init__.py +0 -0
  77. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_account.py +0 -0
  78. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_statement_import.py +0 -0
  79. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/camt.py +0 -0
  80. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  81. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/revaluation.py +0 -0
  82. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/__init__.py +0 -0
  83. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/engine.py +0 -0
  84. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/pack.py +0 -0
  85. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  86. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  87. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  88. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  89. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  90. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  91. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/profiles.py +0 -0
  92. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/spine.py +0 -0
  93. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/__init__.py +0 -0
  94. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/buying/__init__.py +0 -0
  95. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/__init__.py +0 -0
  96. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/currency.py +0 -0
  97. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/defaults.py +0 -0
  98. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/exceptions.py +0 -0
  99. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/hooks.py +0 -0
  100. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/__init__.py +0 -0
  101. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/simulation.py +0 -0
  102. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/__init__.py +0 -0
  103. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/utils.py +0 -0
  104. {lambda_erp-0.8.32 → lambda_erp-0.8.34}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.32
3
+ Version: 0.8.34
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -179,6 +179,17 @@ def save_chat_message(session_id: str, role: str, content: str, message_type: st
179
179
  touch_session(session_id)
180
180
 
181
181
 
182
+ def _save_tool_trace(session_id: str, message_type: str, payload: dict):
183
+ """Audit visible tool activity without masking a completed business write."""
184
+ try:
185
+ save_chat_message(session_id, "tool", json.dumps(payload, default=str), message_type=message_type)
186
+ except Exception:
187
+ # A session can be deleted while a tool is running. In particular, a
188
+ # failed result audit must not turn a successful write into a retry.
189
+ get_db().conn.rollback()
190
+ logger.warning("Could not persist %s trace for session %s", message_type, session_id)
191
+
192
+
182
193
  def load_chat_history(session_id: str, limit: int = 50, before_id: int | None = None) -> list[dict]:
183
194
  """Load recent chat messages for a session.
184
195
 
@@ -189,13 +200,13 @@ def load_chat_history(session_id: str, limit: int = 50, before_id: int | None =
189
200
  if before_id:
190
201
  rows = db.sql(
191
202
  'SELECT id, role, message_type, content, metadata_json, created_at '
192
- 'FROM "Chat Message" WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?',
203
+ 'FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') AND id < ? ORDER BY id DESC LIMIT ?',
193
204
  [session_id, int(before_id), limit],
194
205
  )
195
206
  else:
196
207
  rows = db.sql(
197
208
  'SELECT id, role, message_type, content, metadata_json, created_at '
198
- 'FROM "Chat Message" WHERE session_id = ? ORDER BY id DESC LIMIT ?',
209
+ 'FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') ORDER BY id DESC LIMIT ?',
199
210
  [session_id, limit],
200
211
  )
201
212
  rows.reverse()
@@ -275,7 +286,7 @@ def load_serialized_chat_history(
275
286
  if oldest_id is not None:
276
287
  db = get_db()
277
288
  older = db.sql(
278
- 'SELECT 1 FROM "Chat Message" WHERE session_id = ? AND id < ? LIMIT 1',
289
+ 'SELECT 1 FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') AND id < ? LIMIT 1',
279
290
  [session_id, int(oldest_id)],
280
291
  )
281
292
  has_more = bool(older)
@@ -483,7 +494,7 @@ TOOLS = [
483
494
  "type": "function",
484
495
  "function": {
485
496
  "name": "get_document_fields",
486
- "description": "List the available columns of a document type and identify which are text fields. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns the default fields used by free-text search.",
497
+ "description": "List the available columns of a document type and identify which are text fields. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns required fields, conditional business rules, child requirements, dynamic links and supported transient inputs. Check these before creating or updating a document; unknown fields are rejected.",
487
498
  "parameters": {
488
499
  "type": "object",
489
500
  "properties": {
@@ -513,7 +524,7 @@ TOOLS = [
513
524
  "function": {
514
525
  "name": "create_document",
515
526
  "description": (
516
- "Create a new draft document (docstatus=0). The document is saved but NOT submitted. IMPORTANT: you MUST pass the 'data' object with ALL document fields — doctype alone is not enough. Example: {\"doctype\": \"purchase-order\", \"data\": {\"supplier\": \"SUPP-001\", \"company\": \"My Co\", \"transaction_date\": \"2026-04-14\", \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100}]}}\n"
527
+ "Create a new draft document (docstatus=0). The document is saved but NOT submitted. Use get_document_fields for required/conditional fields. Never omit an ambiguous business reference to make a call succeed: ask a targeted question. Relay _validation.warnings from saved documents. IMPORTANT: you MUST pass the 'data' object with ALL document fields — doctype alone is not enough. Example: {\"doctype\": \"purchase-order\", \"data\": {\"supplier\": \"SUPP-001\", \"company\": \"My Co\", \"transaction_date\": \"2026-04-14\", \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100}]}}\n"
517
528
  "Currency: `currency` is optional — it defaults to the customer/supplier's currency, else the company's base currency. To bill in a foreign currency, also pass `conversion_rate` = how many units of the company's base currency equal 1 unit of the document currency (no automatic FX lookup yet, so you MUST supply it for a foreign currency). Item rates stay in the document currency. Example (base USD, invoicing in EUR at 1 EUR = 1.10 USD): {\"doctype\": \"sales-invoice\", \"data\": {\"customer\": \"CUST-001\", \"company\": \"My Co\", \"posting_date\": \"2026-05-22\", \"currency\": \"EUR\", \"conversion_rate\": 1.10, \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 1, \"rate\": 100}]}}"
518
529
  ),
519
530
  "parameters": {
@@ -1469,20 +1480,7 @@ def _handle_list_documents(args):
1469
1480
 
1470
1481
 
1471
1482
  def _handle_get_document_fields(args):
1472
- doctype = args["doctype"]
1473
- table = services.SLUG_TO_DOCTYPE.get(doctype)
1474
- if not table:
1475
- return {"error": f"Unknown document type: {doctype}"}
1476
- db = get_db()
1477
- cls = services.DOCUMENT_CLASSES.get(table)
1478
- return {
1479
- "doctype": doctype,
1480
- "fields": sorted(db._get_table_columns(table)),
1481
- "text_fields": sorted(db._get_text_columns(table)),
1482
- "default_search_fields": services.document_search_columns(db, doctype),
1483
- "child_tables": sorted((cls.CHILD_TABLES or {}).keys()) if cls else [],
1484
- "link_fields": dict(getattr(cls, "LINK_FIELDS", None) or {}) if cls else {},
1485
- }
1483
+ return services.document_field_metadata(args["doctype"])
1486
1484
 
1487
1485
 
1488
1486
  def _handle_get_document(args):
@@ -1569,6 +1567,7 @@ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
1569
1567
 
1570
1568
 
1571
1569
  def _handle_get_master_fields(args):
1570
+ from api.routers.masters import master_requirements, MASTER_LINK_FIELDS
1572
1571
  db = get_db()
1573
1572
  master_type = args["master_type"]
1574
1573
  entry = services.MASTER_TABLES.get(master_type)
@@ -1577,6 +1576,7 @@ def _handle_get_master_fields(args):
1577
1576
  doctype, _ = entry
1578
1577
  default_search = _master_search_columns(db, doctype)
1579
1578
  bulk = services.master_bulk_text_columns(db, doctype)
1579
+ cls = services.DOCUMENT_CLASSES.get(doctype)
1580
1580
  return {
1581
1581
  "master_type": master_type,
1582
1582
  "fields": sorted(db._get_table_columns(doctype)),
@@ -1585,6 +1585,10 @@ def _handle_get_master_fields(args):
1585
1585
  "default_search_fields": default_search,
1586
1586
  # Large text fields searched ONLY when named in search_masters `fields`.
1587
1587
  "bulk_text_fields": bulk,
1588
+ "requirements": master_requirements(master_type),
1589
+ "link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
1590
+ "dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
1591
+ "input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | ({services.MASTER_IDENTITY_ALIAS[master_type]} if master_type in services.MASTER_IDENTITY_ALIAS else set())),
1588
1592
  }
1589
1593
 
1590
1594
 
@@ -2138,7 +2142,11 @@ def _handle_create_custom_analytics_report(args, user_info: dict | None = None,
2138
2142
 
2139
2143
  if not args.get("title"):
2140
2144
  return {"error": "title is required"}
2141
- return create_report_draft_record(args, user_info, source_chat_session_id=session_id)
2145
+ # ``intent`` is a tool-control field for the specialist handoff, not part
2146
+ # of the persisted declarative report. Keep every other key so the strict
2147
+ # ReportDraftPayload boundary still rejects unexpected/executable fields.
2148
+ payload = {key: value for key, value in args.items() if key != "intent"}
2149
+ return create_report_draft_record(payload, user_info, source_chat_session_id=session_id)
2142
2150
 
2143
2151
 
2144
2152
  def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
@@ -2352,12 +2360,30 @@ def _prompt_uom_context() -> str:
2352
2360
  )
2353
2361
 
2354
2362
 
2363
+ def _prompt_validation_context():
2364
+ from lambda_erp.validation import document_requirements
2365
+ from api.routers.masters import master_requirements
2366
+ groups = {}
2367
+ for doctype, cls in services.DOCUMENT_CLASSES.items():
2368
+ rules = document_requirements(cls)
2369
+ if not any(rules.values()):
2370
+ continue
2371
+ key = json.dumps(rules, sort_keys=True)
2372
+ groups.setdefault(key, []).append(doctype)
2373
+ for slug, (doctype, _) in services.MASTER_TABLES.items():
2374
+ if doctype not in services.DOCUMENT_CLASSES:
2375
+ key = json.dumps(master_requirements(slug), sort_keys=True)
2376
+ groups.setdefault(key, []).append(doctype + ' (master)')
2377
+ return "\n".join(f"- {', '.join(names)}: {rules}" for rules, names in groups.items())
2378
+
2379
+
2355
2380
  def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2356
2381
  user_name = user_info.get("full_name", "User") if user_info else "User"
2357
2382
  user_role = user_info.get("role", "viewer") if user_info else "viewer"
2358
2383
  company_context = _prompt_company_context()
2359
2384
  analytics_context = _prompt_analytics_context()
2360
2385
  uom_context = _prompt_uom_context()
2386
+ validation_context = _prompt_validation_context()
2361
2387
 
2362
2388
  # Deployment-registered types (register_doctype / register_master). Folded
2363
2389
  # into the type lists below so the assistant knows they exist and can act
@@ -2449,11 +2475,16 @@ If `jurisdiction.is_fallback` is true, tell the user their country isn't localiz
2449
2475
 
2450
2476
  You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
2451
2477
 
2478
+ ## Enforced document requirements (generated from backend rule metadata)
2479
+ {validation_context}
2480
+ Use get_document_fields for full field and relationship metadata. On updates these rules apply to the merged document, so already stored values need not be resubmitted. Never remove an ambiguous link to bypass a validation error.
2481
+
2452
2482
  ## Never fabricate actions or results
2453
- Only state that something was done — created, changed, enabled/disabled, booked, repointed — **after a tool call returns a success result that confirms it**, and check the returned record actually reflects the change (after `update_master` with {{"disabled": 0}}, confirm the returned row shows `disabled = 0`; after submitting a journal entry, confirm it posted non-zero GL). If a tool returns an error, a warning, or a record that doesn't reflect your intent, tell the user it did **not** work and why — never smooth it over as success.
2483
+ Only state that something was done — created, changed, enabled/disabled, booked, repointed — **after a tool call returns a success result that confirms it**, and check the returned record actually reflects the change (after `update_master` with {{"disabled": 0}}, confirm the returned row shows `disabled = 0`; after submitting a journal entry, confirm it posted non-zero GL). An error means the action failed. For a successful result with warnings, explain both what was saved and what remains incomplete (for example, pool capacity reserved but no machine assigned). If the record doesn't reflect your intent, explain the mismatch — never smooth it over as success.
2454
2484
  - If a capability doesn't exist, say so plainly. There is **no rename** for accounts or other masters — a master's identifying `name`/number is immutable; you can only edit display fields or create a new record and migrate. Never claim you "renamed" an account.
2455
2485
  - Never assert a field or capability is missing without checking (e.g. an item's income account) — verify with `get_master_fields` / `search_masters` first.
2456
2486
  - A master you can't find via `search_masters` may just be **disabled** (search hides disabled by default). Retry with `include_disabled: true` before concluding it doesn't exist — you need this to re-enable a disabled account.
2487
+ - Respect document requirements from get_document_fields. Missing required data or ambiguous matches require a targeted question, not guessed IDs, omitted links, or zero quantities. A tool error means the action failed; _validation.warnings must be explained.
2457
2488
  - If you can't confirm an action took effect, re-fetch and look, or tell the user you couldn't confirm it — never guess.
2458
2489
 
2459
2490
  ## Answering data questions — three paths
@@ -2639,9 +2670,11 @@ Use these only for items flagged `is_asset_tracked` on the Item master. Neither
2639
2670
  - **Item vs Asset:** the Item is the *type* ("17t Excavator") and carries the rate and pricing rules. An **Asset** (slug `asset`) is ONE physical unit of it. Three identical machines = one Item + three Assets. Create with `item_code` (required) plus `asset_tag` (plate / serial — optional but must be unique), `warehouse` (its home yard), and optionally `purchase_date`, `purchase_value`, `meter_reading`. `status` is Available / On Hire / Maintenance / Retired — the unit's state *today*, not a date range. Retired units leave the pool.
2640
2671
  - **Opting in:** creating an Asset for an Item that isn't asset-tracked is refused. Fix it by setting `is_asset_tracked` = 1 on the Item via update_master, then retry. Never suggest the user edit the database.
2641
2672
  - **Reservation** (slug `reservation`) books a time window. Required: `from_datetime` and `to_datetime` (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`; a bare date means midnight). Windows are **half-open** — a hire ending on the 19th and the next starting on the 19th do NOT clash. Two ways to book:
2642
- - **pooled** — set `item_code`, `warehouse` and `qty`: "*a* machine of this type from that yard". Use this at quotation/order time when the specific unit doesn't matter yet.
2673
+ - **pooled** — explicitly set `allocation_mode="Pool"`; set `item_code`, `warehouse` and `qty`: "*a* machine of this type from that yard". Use this at quotation/order time when the specific unit doesn't matter yet.
2643
2674
  - **unit** — set `asset`: "*that* machine". `item_code`, `warehouse` and `qty` are filled in from the Asset automatically.
2644
- - **Who it's for (do NOT skip this):** set `party` to the hiring customer's id (e.g. `CUST-005`) with `party_type = "Customer"`. The customer is almost always given by NAME — "reserve the 17t for Hans Meisterhans" — so FIRST resolve the name to a record with `search_masters` (master_type "customer") and set `party` to the matched id; never put the raw name in `party`. No match → create the customer (or ask which existing one is meant), then book. A reservation with no `party` is only for an internal block (maintenance, transport, transfer) — if the user named a customer, a booking without `party` set is a bug, not an option.
2675
+ - **Choosing a machine:** resolve the Item, then list active, non-discarded Assets and the relevant reservations. If multiple usable units match and the customer has not specified a yard/unit, ask which yard/plate is meant; do not guess and do not silently omit asset. Only create a Pool booking when the user intentionally wants unassigned capacity. A uniquely identified available unit can be booked by asset ID. Asset and reservation warehouse must match; record a transfer first if needed.
2676
+ - **Calendar:** Pool bookings appear on separate unassigned rows, not on a specific machine. Always tell the user when no machine is assigned. On a report of a missing booking, get_document and inspect asset, status, discarded and warehouse; do not claim a unit is booked merely because a Reservation exists. Out requires an asset.
2677
+ - **Who it's for (do NOT skip this):** set `party` to the hiring customer's id (e.g. `CUST-005`) with `party_type = "Customer"`. The customer is almost always given by NAME — "reserve the 17t for Hans Meisterhans" — so FIRST resolve the name to a record with `search_masters` (master_type "customer") and set `party` to the matched id; never put the raw name in `party`. No match → create the customer (or ask which existing one is meant), then book. A reservation with no `party` is only for an internal block (maintenance, transport, transfer) — if the user named a customer, a booking without `party` set is a bug, not an option. Internal blocks without party require an explicit purpose.
2645
2678
  - `status` is Reserved / Out / Returned / Cancelled. **Reserved and Out block the calendar; Returned and Cancelled do not** — so a hire that comes back early frees its slot the moment you set it to Returned. Link a booking to the order that caused it with `voucher_type` + `voucher_no`.
2646
2679
  - **Double-booking is refused at save time.** If you get "already committed" (that unit is taken) or "free at" (the yard's pool is exhausted for that window), do NOT retry blindly — report the conflict, then offer the alternatives: different dates, a different yard, or a different model.
2647
2680
  - To answer "what's free between X and Y", list reservations for the item over the window and compare against the Assets that exist; a unit with no overlapping Reserved/Out row is available.
@@ -3617,6 +3650,8 @@ async def run_thinking_loop(
3617
3650
  tool_call_id = tc.id
3618
3651
 
3619
3652
  await on_event({"type": "tool_call", "tool": fn_name, "args": fn_args})
3653
+ if session_id:
3654
+ _save_tool_trace(session_id, "tool_call", {"tool": fn_name, "args": fn_args})
3620
3655
 
3621
3656
  # LLM turns can run for many seconds. Re-resolve the principal at
3622
3657
  # the actual execution boundary so a demotion, disable, or key
@@ -3652,7 +3687,7 @@ async def run_thinking_loop(
3652
3687
  else:
3653
3688
  try:
3654
3689
  result = await asyncio.to_thread(handler, fn_args)
3655
- success = True
3690
+ success = not (isinstance(result, dict) and ("error" in result or result.get("ok") is False or result.get("failed", 0)))
3656
3691
  except Exception as e:
3657
3692
  result = {"error": str(e)}
3658
3693
  success = False
@@ -3673,6 +3708,10 @@ async def run_thinking_loop(
3673
3708
  "success": success,
3674
3709
  "summary": summary,
3675
3710
  }
3711
+ if isinstance(result, dict):
3712
+ event_payload["warnings"] = (result.get("_validation") or {}).get("warnings", [])
3713
+ if not success:
3714
+ event_payload["error"] = result.get("error") or "One or more operations failed; inspect the tool result."
3676
3715
  # Surface the report id so the sidebar can flash the specific draft.
3677
3716
  if (
3678
3717
  success
@@ -3681,6 +3720,8 @@ async def run_thinking_loop(
3681
3720
  and result.get("id")
3682
3721
  ):
3683
3722
  event_payload["report_id"] = result["id"]
3723
+ if session_id:
3724
+ _save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
3684
3725
  await on_event(event_payload)
3685
3726
 
3686
3727
  messages.append({
@@ -63,6 +63,15 @@ def _friendly_integrity(msg: str) -> str:
63
63
 
64
64
 
65
65
  async def _integrity_handler(request: Request, exc: Exception):
66
+ msg = str(exc)
67
+ code = getattr(exc, 'sqlstate', None)
68
+ if code == '23502' or 'NOT NULL constraint failed:' in msg:
69
+ column = getattr(getattr(exc, 'diag', None), 'column_name', None)
70
+ if not column and 'NOT NULL constraint failed:' in msg:
71
+ column = msg.split('NOT NULL constraint failed:', 1)[1].strip()
72
+ return JSONResponse(status_code=422, content={'detail': f'{column or "A required field"} is required'})
73
+ if code == '23503' or 'foreign key constraint failed' in msg.lower():
74
+ return JSONResponse(status_code=422, content={'detail': "This links to a record that does not exist or is still referenced."})
66
75
  return JSONResponse(status_code=409, content={"detail": _friendly_integrity(str(exc))})
67
76
 
68
77
 
@@ -266,6 +266,8 @@ def generate_proposal_pdf(name: str) -> bytes:
266
266
  """Render a Proposal (Sammelofferte): a cover letter plus each referenced
267
267
  Quotation as a lettered position (A, B, C…), then append the uploaded
268
268
  appendix PDF if there is one. Never mutates the quotations."""
269
+ from lambda_erp.selling.proposal import Proposal
270
+ Proposal.load(name).validate_for_output()
269
271
  proposal = load_document("proposal", name)
270
272
  db = get_db()
271
273
 
@@ -301,12 +303,7 @@ def generate_proposal_pdf(name: str) -> bytes:
301
303
  rows = sorted(proposal.get("quotations", []) or [], key=lambda r: r.get("idx") or 0)
302
304
  for i, row in enumerate(rows):
303
305
  qname = row.get("quotation")
304
- if not qname:
305
- continue
306
- try:
307
- quote = load_document("quotation", qname)
308
- except Exception:
309
- continue
306
+ quote = load_document("quotation", qname)
310
307
  items = quote.get("items", []) or []
311
308
  # Refresh each line's item_name from the master, consistent with the
312
309
  # single-document path.
@@ -117,7 +117,7 @@ def calendar_feed(
117
117
  "meter_reading": a.get("meter_reading"),
118
118
  }
119
119
  for a in db.get_all("Asset", filters=asset_filters, fields=["*"], order_by="item_code, asset_tag")
120
- if a.get("status") != "Retired"
120
+ if a.get("status") != "Retired" and not a.get("discarded")
121
121
  ]
122
122
 
123
123
  try:
@@ -78,11 +78,6 @@ def _extract_documents(reply: str, request: Request) -> list[dict]:
78
78
  return documents
79
79
 
80
80
 
81
- async def _noop_event(event: dict) -> None:
82
- """Discard streamed loop events — the REST response is the final reply only."""
83
- return None
84
-
85
-
86
81
  def _client_ip(request: Request) -> str | None:
87
82
  xff = request.headers.get("x-forwarded-for", "")
88
83
  if xff:
@@ -117,11 +112,17 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
117
112
 
118
113
  save_chat_message(target_session_id, "user", message)
119
114
 
115
+ tool_results = []
116
+
117
+ async def collect_event(event):
118
+ if event.get("type") == "tool_result":
119
+ tool_results.append({key: event[key] for key in ("tool", "success", "error", "warnings") if key in event})
120
+
120
121
  reply = await run_session_turn(
121
122
  target_session_id,
122
123
  message,
123
124
  caller,
124
- _noop_event,
125
+ collect_event,
125
126
  client_ip=_client_ip(request),
126
127
  replay_history=replay_history,
127
128
  channel="api",
@@ -133,6 +134,7 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
133
134
  "session_id": target_session_id,
134
135
  "title": session["title"] if session else None,
135
136
  "documents": _extract_documents(reply or "", request),
137
+ "tool_results": tool_results,
136
138
  }
137
139
 
138
140
 
@@ -37,6 +37,12 @@ _LIST_RESERVED = {
37
37
  }
38
38
 
39
39
 
40
+ @router.get("/{doctype_slug}/fields")
41
+ def document_fields(doctype_slug: str, _user: dict = _viewer):
42
+ from api.services import document_field_metadata
43
+ return document_field_metadata(doctype_slug)
44
+
45
+
40
46
  @router.get("/{doctype_slug}")
41
47
  def list_docs(
42
48
  doctype_slug: str,
@@ -18,6 +18,8 @@ from api.services import (
18
18
  )
19
19
  from api.auth import require_role, require_non_public_manager
20
20
  from api.list_values import distinct_list_values
21
+ from lambda_erp.exceptions import ValidationError
22
+ from lambda_erp.validation import missing
21
23
 
22
24
  router = APIRouter(prefix="/masters", tags=["masters"])
23
25
 
@@ -54,7 +56,7 @@ DELETE_REFERENCE_CHECKS = {
54
56
  ('SELECT 1 FROM "POS Invoice" WHERE company = ? LIMIT 1', "POS invoice"),
55
57
  ('SELECT 1 FROM "Budget" WHERE company = ? LIMIT 1', "budget"),
56
58
  ('SELECT 1 FROM "Subscription" WHERE company = ? LIMIT 1', "subscription"),
57
- ('SELECT 1 FROM "Bank Transaction" WHERE company = ? LIMIT 1', "bank transaction"),
59
+ ('SELECT 1 FROM "Bank Transaction" t JOIN "Bank Account" a ON a.name = t.bank_account WHERE a.company = ? LIMIT 1', "bank transaction"),
58
60
  ],
59
61
  "customer": [
60
62
  ('SELECT 1 FROM "Quotation" WHERE customer = ? LIMIT 1', "quotation"),
@@ -117,7 +119,6 @@ DELETE_REFERENCE_CHECKS = {
117
119
  ('SELECT 1 FROM "Tax Template Detail" WHERE account_head = ? LIMIT 1', "tax template detail"),
118
120
  ('SELECT 1 FROM "Company" WHERE round_off_account = ? OR default_receivable_account = ? OR default_payable_account = ? OR default_income_account = ? OR default_expense_account = ? OR stock_received_but_not_billed = ? OR stock_adjustment_account = ? OR accumulated_depreciation_account = ? OR depreciation_expense_account = ? LIMIT 1', "company"),
119
121
  ('SELECT 1 FROM "Warehouse" WHERE account = ? LIMIT 1', "warehouse"),
120
- ('SELECT 1 FROM "Pricing Rule" WHERE discount_account = ? LIMIT 1', "pricing rule"),
121
122
  ('SELECT 1 FROM "Budget" WHERE account = ? LIMIT 1', "budget"),
122
123
  ('SELECT 1 FROM "Bank Transaction" WHERE bank_account = ? LIMIT 1', "bank transaction"),
123
124
  ],
@@ -210,7 +211,78 @@ def _normalize_master_data(data: dict) -> dict:
210
211
  return normalized
211
212
 
212
213
 
214
+ MASTER_LINK_FIELDS = {
215
+ 'item': {'default_warehouse': 'Warehouse'},
216
+ 'warehouse': {'company': 'Company', 'account': 'Account', 'parent_warehouse': 'Warehouse'},
217
+ 'account': {'company': 'Company', 'parent_account': 'Account'},
218
+ 'cost-center': {'company': 'Company', 'parent_cost_center': 'Cost Center'},
219
+ }
220
+
221
+
222
+ def master_requirements(master_type):
223
+ from api import services
224
+ doctype, display = _get_table(master_type)
225
+ if doctype in services.DOCUMENT_CLASSES:
226
+ from lambda_erp.validation import document_requirements
227
+ return document_requirements(services.DOCUMENT_CLASSES[doctype])
228
+ from lambda_erp.master_integrity import structure_requirements, STRUCTURAL_FIELDS
229
+ return {'required': [display], 'conditional': ['Unknown input fields are rejected; supplied links must exist.'] + structure_requirements(master_type), 'children': {},
230
+ 'structural_fields': list(STRUCTURAL_FIELDS.get(master_type, ()))}
231
+
232
+
233
+ def _structural_reference(master_type, doctype, name):
234
+ """Check declared links too; new document types inherit reference protection.
235
+
236
+ Unlike best-effort delete diagnostics, validation must not swallow SQL
237
+ failures and then approve a structural change.
238
+ """
239
+ from api import services
240
+ db = get_db()
241
+ links = set()
242
+ for cls in services.DOCUMENT_CLASSES.values():
243
+ links.update((cls.DOCTYPE, field) for field, target in cls.LINK_FIELDS.items() if target == doctype)
244
+ for key, fields in cls.CHILD_LINK_FIELDS.items():
245
+ links.update((cls.CHILD_TABLES[key][0], field) for field, target in fields.items() if target == doctype)
246
+ for kind, fields in MASTER_LINK_FIELDS.items():
247
+ table, _ = _get_table(kind)
248
+ links.update((table, field) for field, target in fields.items() if target == doctype)
249
+ for table, field in sorted(links):
250
+ # Some declarations validate transient/defaulted inputs. Only stored
251
+ # columns can carry a persistent reference (SQLite masks missing ones).
252
+ if field not in db._get_table_columns(table):
253
+ continue
254
+ if db.sql(f'SELECT name FROM "{table}" WHERE "{field}" = ? LIMIT 1', [name]):
255
+ return f'{table}.{field}'
256
+ for query, label in [*DELETE_REFERENCE_CHECKS.get(master_type, []), *MASTER_REFERENCE_CHECKS.get(master_type, [])]:
257
+ if db.sql(query, [name] * query.count('?')):
258
+ return label
259
+ return None
260
+
261
+
262
+ def _validate_master_input(master_type, doctype, data, name=None):
263
+ db = get_db()
264
+ allowed = db._get_table_columns(doctype)
265
+ unknown = set(data) - allowed
266
+ if unknown:
267
+ raise ValidationError(f'{doctype}: Unknown field(s): {", ".join(sorted(unknown))}. Use get_master_fields.')
268
+ if name and data.get('name') not in (None, name):
269
+ raise ValidationError('Cannot change master identity; create a new record instead')
270
+ merged = dict(db.get_value(doctype, name, list(allowed)) or {}) if name else {}
271
+ merged.update(data)
272
+ for field in master_requirements(master_type)['required']:
273
+ if missing(merged.get(field)):
274
+ raise ValidationError(f'{doctype}: {field} is required')
275
+ for field, table in MASTER_LINK_FIELDS.get(master_type, {}).items():
276
+ if merged.get(field) and not db.exists(table, merged[field]):
277
+ raise ValidationError(f'{doctype}: {field} must reference an existing {table}')
278
+
279
+
213
280
  def create_master_record(master_type: str, data: dict) -> dict:
281
+ with get_db().atomic():
282
+ return _create_master_record(master_type, data)
283
+
284
+
285
+ def _create_master_record(master_type: str, data: dict) -> dict:
214
286
  doctype, _ = _get_table(master_type)
215
287
  if not doctype:
216
288
  raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
@@ -245,6 +317,12 @@ def create_master_record(master_type: str, data: dict) -> dict:
245
317
  if db.exists(doctype, doc["name"]):
246
318
  raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
247
319
 
320
+ from api import services
321
+ if doctype in services.DOCUMENT_CLASSES:
322
+ return _echo_identity_alias(master_type, services.create_document(services.DOCTYPE_TO_SLUG[doctype], doc))
323
+
324
+ _validate_master_input(master_type, doctype, doc)
325
+
248
326
  columns = db._get_table_columns(doctype)
249
327
  stamp = now()
250
328
  if "creation" in columns and not doc.get("creation"):
@@ -267,6 +345,13 @@ def create_master_record(master_type: str, data: dict) -> dict:
267
345
 
268
346
 
269
347
  def update_master_record(master_type: str, name: str, data: dict) -> dict:
348
+ from lambda_erp.master_integrity import STRUCTURAL_FIELDS
349
+ exclusive = bool(set(data) & set(STRUCTURAL_FIELDS.get(master_type, ())))
350
+ with get_db().atomic(exclusive_master_edit=exclusive):
351
+ return _update_master_record(master_type, name, data)
352
+
353
+
354
+ def _update_master_record(master_type: str, name: str, data: dict) -> dict:
270
355
  doctype, _ = _get_table(master_type)
271
356
  if not doctype:
272
357
  raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
@@ -289,7 +374,23 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
289
374
  ),
290
375
  )
291
376
 
377
+ if normalized.get('name') not in (None, name):
378
+ raise ValidationError('Cannot change master identity; create a new record instead')
379
+ from api import services
380
+ if doctype in services.DOCUMENT_CLASSES:
381
+ return _echo_identity_alias(master_type, services.update_document(services.DOCTYPE_TO_SLUG[doctype], name, normalized))
382
+
292
383
  update_fields = {k: v for k, v in normalized.items() if k != "name"}
384
+ if not db.exists(doctype, name):
385
+ raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
386
+ _validate_master_input(master_type, doctype, normalized, name)
387
+ from lambda_erp.master_integrity import structural_changes
388
+ stored = db.get_value(doctype, name, list(db._get_table_columns(doctype)))
389
+ changed = structural_changes(master_type, stored, normalized)
390
+ if changed:
391
+ reference = _structural_reference(master_type, doctype, name)
392
+ if reference:
393
+ raise ValidationError(f'{doctype} {name}: cannot change structural fields {", ".join(changed)}; referenced by {reference}. Use a new master or a deliberate migration.')
293
394
  if "modified" in db._get_table_columns(doctype) and "modified" not in update_fields:
294
395
  update_fields["modified"] = now()
295
396
  if update_fields:
@@ -559,6 +660,14 @@ def adjacent_master(
559
660
  return {"prev": neighbor("prev"), "next": neighbor("next")}
560
661
 
561
662
 
663
+ @router.get("/{master_type}/fields")
664
+ def master_fields(master_type: str, _user: dict = _viewer):
665
+ if not _get_table(master_type)[0]:
666
+ raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
667
+ from api.chat import _handle_get_master_fields
668
+ return _handle_get_master_fields({'master_type': master_type})
669
+
670
+
562
671
  @router.get("/{master_type}/{name}")
563
672
  def get_master(master_type: str, name: str, _user: dict = _viewer):
564
673
  doctype, _ = _get_table(master_type)
@@ -598,6 +707,11 @@ def delete_master_record(master_type: str, name: str) -> dict:
598
707
  if not db.exists(doctype, name):
599
708
  raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
600
709
 
710
+ from api import services
711
+ if doctype in services.DOCUMENT_CLASSES and 'discarded' in db._get_table_columns(doctype):
712
+ services.discard_document(services.DOCTYPE_TO_SLUG[doctype], name)
713
+ return {'ok': True, 'status': 'discarded'}
714
+
601
715
  reference = _find_reference(master_type, name)
602
716
  if reference:
603
717
  columns = db._get_table_columns(doctype)
@@ -458,16 +458,77 @@ def _default_tax_rows(cls, data: dict) -> list | None:
458
458
  ]
459
459
 
460
460
 
461
+ def document_field_metadata(doctype_slug: str) -> dict:
462
+ from lambda_erp.validation import document_requirements
463
+ doctype, cls = get_document_class(doctype_slug)
464
+ if not cls:
465
+ raise ValueError(f"Unknown document type: {doctype_slug}")
466
+ db = get_db()
467
+ return {
468
+ "doctype": doctype_slug,
469
+ "fields": sorted(db._get_table_columns(doctype)),
470
+ "text_fields": sorted(db._get_text_columns(doctype)),
471
+ "default_search_fields": document_search_columns(db, doctype_slug),
472
+ "child_tables": sorted(cls.CHILD_TABLES),
473
+ "link_fields": dict(cls.LINK_FIELDS),
474
+ "dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS),
475
+ "input_fields": sorted(cls.INPUT_FIELDS),
476
+ "read_only_fields": sorted(cls.SERVER_MANAGED_FIELDS),
477
+ "child_input_fields": {key: sorted(value) for key, value in cls.CHILD_INPUT_FIELDS.items()},
478
+ "requirements": document_requirements(cls),
479
+ }
480
+
481
+
482
+ def _validate_document_input(doctype, cls, data):
483
+ from lambda_erp.exceptions import ValidationError
484
+ if not isinstance(data, dict):
485
+ raise ValidationError("Document data must be an object")
486
+ # Response-only annotations can accompany a round trip through the form.
487
+ data = {key: value for key, value in data.items() if key != "_validation"}
488
+ db = get_db()
489
+ allowed = db._get_table_columns(doctype) | set(cls.CHILD_TABLES) | set(cls.INPUT_FIELDS)
490
+ unknown = set(data) - allowed
491
+ if unknown:
492
+ raise ValidationError(f"{doctype}: Unknown field(s): {', '.join(sorted(unknown))}. Use get_document_fields to inspect supported fields.")
493
+ for key, (table, _) in cls.CHILD_TABLES.items():
494
+ if key not in data:
495
+ continue
496
+ rows = data[key]
497
+ if not isinstance(rows, list):
498
+ raise ValidationError(f"{doctype}: {key} must be an array")
499
+ allowed_child = db._get_table_columns(table) | set(cls.CHILD_INPUT_FIELDS.get(key, ()))
500
+ for idx, row in enumerate(rows, 1):
501
+ if not isinstance(row, dict):
502
+ raise ValidationError(f"{doctype}: {key} row {idx} must be an object")
503
+ unknown = set(row) - allowed_child
504
+ if unknown:
505
+ raise ValidationError(f"{doctype}: {key} row {idx}: Unknown field(s): {', '.join(sorted(unknown))}")
506
+ return data
507
+
508
+
509
+ def _document_validation(doc):
510
+ warnings = []
511
+ if doc.DOCTYPE == 'Proposal' and (not doc.get('customer') or not doc.get('company') or not doc.get('quotations')):
512
+ warnings.append({'code': 'proposal_incomplete', 'message': 'Draft saved; PDF output requires Customer, Company and at least one Quotation.'})
513
+ if doc.DOCTYPE == "Reservation" and not doc.get("asset") and doc.get("status") in {"Reserved", "Out"}:
514
+ warnings.append({
515
+ "code": "asset_unassigned", "field": "asset",
516
+ "message": "Pool capacity is reserved; no specific machine is assigned. This booking appears on a pool row in the fleet calendar. Assign an asset before dispatch.",
517
+ })
518
+ return {"warnings": warnings}
519
+
520
+
461
521
  def create_document(doctype_slug: str, data: dict) -> dict:
462
522
  doctype, cls = get_document_class(doctype_slug)
463
523
  if not cls:
464
524
  raise ValueError(f"Unknown document type: {doctype_slug}")
525
+ data = _validate_document_input(doctype, cls, data)
465
526
  default_taxes = _default_tax_rows(cls, data)
466
527
  if default_taxes is not None:
467
528
  data = {**data, "taxes": default_taxes}
468
529
  doc = cls(data)
469
530
  doc.save()
470
- return doc.as_dict()
531
+ return load_document(doctype_slug, doc.name)
471
532
 
472
533
 
473
534
  def load_document(doctype_slug: str, name: str) -> dict:
@@ -475,13 +536,16 @@ def load_document(doctype_slug: str, name: str) -> dict:
475
536
  if not cls:
476
537
  raise ValueError(f"Unknown document type: {doctype_slug}")
477
538
  doc = cls.load(name)
478
- return doc.as_dict()
539
+ result = doc.as_dict()
540
+ result["_validation"] = _document_validation(doc)
541
+ return result
479
542
 
480
543
 
481
544
  def update_document(doctype_slug: str, name: str, data: dict) -> dict:
482
545
  doctype, cls = get_document_class(doctype_slug)
483
546
  if not cls:
484
547
  raise ValueError(f"Unknown document type: {doctype_slug}")
548
+ data = _validate_document_input(doctype, cls, data)
485
549
  doc = cls.load(name)
486
550
  # Update parent fields
487
551
  for key, value in data.items():
@@ -494,7 +558,7 @@ def update_document(doctype_slug: str, name: str, data: dict) -> dict:
494
558
  for row in data[table_name]:
495
559
  doc.append(table_name, _dict(row))
496
560
  doc.save()
497
- return doc.as_dict()
561
+ return load_document(doctype_slug, doc.name)
498
562
 
499
563
 
500
564
  def batch_update_documents(doctype_slug: str, updates: list) -> dict:
@@ -528,12 +592,13 @@ def batch_update_documents(doctype_slug: str, updates: list) -> dict:
528
592
  results.append({"name": name, "ok": False, "error": "missing 'data' object"})
529
593
  continue
530
594
  try:
531
- update_document(doctype_slug, name, data)
532
- results.append({"name": name, "ok": True})
595
+ saved = update_document(doctype_slug, name, data)
596
+ results.append({"name": name, "ok": True, "warnings": saved.get('_validation', {}).get('warnings', [])})
533
597
  updated += 1
534
598
  except Exception as e: # noqa: BLE001 — per-item best effort; report, don't abort
535
599
  results.append({"name": name, "ok": False, "error": str(e)})
536
- return {"updated": updated, "failed": len(results) - updated, "results": results}
600
+ warnings = [{**warning, 'name': row['name']} for row in results for warning in row.get('warnings', [])]
601
+ return {"updated": updated, "failed": len(results) - updated, "results": results, '_validation': {'warnings': warnings}}
537
602
 
538
603
 
539
604
  def submit_document(doctype_slug: str, name: str) -> dict: