lambda-erp 0.8.33__tar.gz → 1.0.0__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 (110) hide show
  1. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/chat.py +116 -28
  3. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/errors.py +13 -0
  4. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/main.py +3 -0
  5. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/pdf.py +106 -40
  6. lambda_erp-1.0.0/api/pdf_contract.py +280 -0
  7. lambda_erp-1.0.0/api/pdf_exports.py +64 -0
  8. lambda_erp-1.0.0/api/pdf_profiles.py +132 -0
  9. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/availability.py +1 -1
  10. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/chat_api.py +40 -45
  11. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/documents.py +15 -3
  12. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/masters.py +116 -2
  13. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/mcp.py +7 -0
  14. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/services.py +81 -7
  15. lambda_erp-1.0.0/api/templates/_proposal_totals.html +11 -0
  16. lambda_erp-1.0.0/api/templates/_record_base.html +35 -0
  17. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/templates/document.html +4 -2
  18. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/templates/proposal.html +5 -2
  19. lambda_erp-1.0.0/api/templates/record.html +1 -0
  20. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/tool_permissions.py +1 -1
  21. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/src/api/client.ts +7 -0
  22. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_reconciliation.py +6 -0
  23. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_transaction.py +14 -0
  24. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/budget.py +19 -8
  25. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/general_ledger.py +2 -1
  26. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/journal_entry.py +47 -35
  27. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/payment_entry.py +64 -25
  28. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/pos_invoice.py +6 -23
  29. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/purchase_invoice.py +11 -65
  30. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/sales_invoice.py +11 -88
  31. lambda_erp-1.0.0/lambda_erp/accounting/settlement.py +51 -0
  32. lambda_erp-1.0.0/lambda_erp/accounting/subscription.py +187 -0
  33. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/asset.py +9 -0
  34. lambda_erp-1.0.0/lambda_erp/assets/lifecycle.py +103 -0
  35. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/reservation.py +42 -6
  36. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/buying/purchase_order.py +9 -34
  37. lambda_erp-1.0.0/lambda_erp/controllers/item_prices.py +22 -0
  38. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/pricing_rule.py +11 -2
  39. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/taxes_and_totals.py +5 -3
  40. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/database.py +119 -4
  41. lambda_erp-1.0.0/lambda_erp/master_integrity.py +25 -0
  42. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/model.py +147 -105
  43. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/proposal.py +28 -1
  44. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/quotation.py +2 -1
  45. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/sales_order.py +14 -41
  46. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/delivery_note.py +10 -51
  47. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/purchase_receipt.py +14 -60
  48. lambda_erp-1.0.0/lambda_erp/stock/stock_entry.py +231 -0
  49. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/stock_ledger.py +13 -5
  50. lambda_erp-1.0.0/lambda_erp/validation.py +153 -0
  51. lambda_erp-1.0.0/lambda_erp/workflow.py +224 -0
  52. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/pyproject.toml +1 -1
  53. lambda_erp-0.8.33/lambda_erp/accounting/subscription.py +0 -132
  54. lambda_erp-0.8.33/lambda_erp/stock/stock_entry.py +0 -330
  55. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/.gitignore +0 -0
  56. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/LICENSE +0 -0
  57. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/README.md +0 -0
  58. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/__init__.py +0 -0
  59. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/attachments.py +0 -0
  60. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/auth.py +0 -0
  61. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/bootstrap.py +0 -0
  62. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/demo_limits.py +0 -0
  63. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/deps.py +0 -0
  64. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/list_values.py +0 -0
  65. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/oauth.py +0 -0
  66. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/providers.py +0 -0
  67. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/remarks_md.py +0 -0
  68. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/__init__.py +0 -0
  69. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/accounting.py +0 -0
  70. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/actions.py +0 -0
  71. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/admin.py +0 -0
  72. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/analytics.py +0 -0
  73. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/bank_reconciliation.py +0 -0
  74. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/bank_statements.py +0 -0
  75. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/proposals.py +0 -0
  76. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/reports.py +0 -0
  77. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/setup.py +0 -0
  78. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/docs/agents/README.md +0 -0
  79. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/LICENSE +0 -0
  80. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/README.md +0 -0
  81. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/__init__.py +0 -0
  82. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/__init__.py +0 -0
  83. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_account.py +0 -0
  84. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
  85. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/camt.py +0 -0
  86. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  87. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/revaluation.py +0 -0
  88. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  89. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/engine.py +0 -0
  90. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/pack.py +0 -0
  91. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  92. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  93. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  94. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  95. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  96. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  97. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  98. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/spine.py +0 -0
  99. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/__init__.py +0 -0
  100. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/buying/__init__.py +0 -0
  101. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/__init__.py +0 -0
  102. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/currency.py +0 -0
  103. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/defaults.py +0 -0
  104. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/exceptions.py +0 -0
  105. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/hooks.py +0 -0
  106. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/__init__.py +0 -0
  107. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/simulation.py +0 -0
  108. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/__init__.py +0 -0
  109. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/utils.py +0 -0
  110. {lambda_erp-0.8.33 → lambda_erp-1.0.0}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.33
3
+ Version: 1.0.0
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": {
@@ -493,6 +504,16 @@ TOOLS = [
493
504
  },
494
505
  },
495
506
  },
507
+ {
508
+ "type": "function",
509
+ "function": {
510
+ "name": "generate_document_pdf",
511
+ "description": "Generate and validate the actual PDF before offering or sending it. Returns a downloadable file snapshot only after successful rendering and content checks. Read get_document_fields.pdf for support and output requirements. Relay errors; never invent a PDF URL or claim a file is attached without this tool succeeding.",
512
+ "parameters": {"type": "object", "properties": {
513
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
514
+ "name": {"type": "string"}}, "required": ["doctype", "name"]},
515
+ },
516
+ },
496
517
  {
497
518
  "type": "function",
498
519
  "function": {
@@ -513,7 +534,7 @@ TOOLS = [
513
534
  "function": {
514
535
  "name": "create_document",
515
536
  "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"
537
+ "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
538
  "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
539
  ),
519
540
  "parameters": {
@@ -1468,21 +1489,17 @@ def _handle_list_documents(args):
1468
1489
  return rows
1469
1490
 
1470
1491
 
1492
+ def _handle_generate_document_pdf(args, user=None):
1493
+ from api.pdf_exports import create_pdf_export
1494
+ from api.pdf_contract import PDFError
1495
+ try:
1496
+ return create_pdf_export(args.get('doctype'), args.get('name'), user)
1497
+ except PDFError as exc:
1498
+ return {'error': str(exc), 'code': exc.code, 'fields': exc.fields}
1499
+
1500
+
1471
1501
  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
- }
1502
+ return services.document_field_metadata(args["doctype"])
1486
1503
 
1487
1504
 
1488
1505
  def _handle_get_document(args):
@@ -1569,6 +1586,7 @@ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
1569
1586
 
1570
1587
 
1571
1588
  def _handle_get_master_fields(args):
1589
+ from api.routers.masters import master_requirements, MASTER_LINK_FIELDS
1572
1590
  db = get_db()
1573
1591
  master_type = args["master_type"]
1574
1592
  entry = services.MASTER_TABLES.get(master_type)
@@ -1577,6 +1595,7 @@ def _handle_get_master_fields(args):
1577
1595
  doctype, _ = entry
1578
1596
  default_search = _master_search_columns(db, doctype)
1579
1597
  bulk = services.master_bulk_text_columns(db, doctype)
1598
+ cls = services.DOCUMENT_CLASSES.get(doctype)
1580
1599
  return {
1581
1600
  "master_type": master_type,
1582
1601
  "fields": sorted(db._get_table_columns(doctype)),
@@ -1585,6 +1604,10 @@ def _handle_get_master_fields(args):
1585
1604
  "default_search_fields": default_search,
1586
1605
  # Large text fields searched ONLY when named in search_masters `fields`.
1587
1606
  "bulk_text_fields": bulk,
1607
+ "requirements": master_requirements(master_type),
1608
+ "link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
1609
+ "dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
1610
+ "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
1611
  }
1589
1612
 
1590
1613
 
@@ -2236,6 +2259,7 @@ def _handle_apply_company_setup(args):
2236
2259
  TOOL_HANDLERS = {
2237
2260
  "list_documents": _handle_list_documents,
2238
2261
  "get_document_fields": _handle_get_document_fields,
2262
+ "generate_document_pdf": _handle_generate_document_pdf,
2239
2263
  "get_document": _handle_get_document,
2240
2264
  "create_document": _handle_create_document,
2241
2265
  "update_document": _handle_update_document,
@@ -2356,12 +2380,30 @@ def _prompt_uom_context() -> str:
2356
2380
  )
2357
2381
 
2358
2382
 
2383
+ def _prompt_validation_context():
2384
+ from lambda_erp.validation import document_requirements
2385
+ from api.routers.masters import master_requirements
2386
+ groups = {}
2387
+ for doctype, cls in services.DOCUMENT_CLASSES.items():
2388
+ rules = document_requirements(cls)
2389
+ if not any(rules.values()):
2390
+ continue
2391
+ key = json.dumps(rules, sort_keys=True)
2392
+ groups.setdefault(key, []).append(doctype)
2393
+ for slug, (doctype, _) in services.MASTER_TABLES.items():
2394
+ if doctype not in services.DOCUMENT_CLASSES:
2395
+ key = json.dumps(master_requirements(slug), sort_keys=True)
2396
+ groups.setdefault(key, []).append(doctype + ' (master)')
2397
+ return "\n".join(f"- {', '.join(names)}: {rules}" for rules, names in groups.items())
2398
+
2399
+
2359
2400
  def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2360
2401
  user_name = user_info.get("full_name", "User") if user_info else "User"
2361
2402
  user_role = user_info.get("role", "viewer") if user_info else "viewer"
2362
2403
  company_context = _prompt_company_context()
2363
2404
  analytics_context = _prompt_analytics_context()
2364
2405
  uom_context = _prompt_uom_context()
2406
+ validation_context = _prompt_validation_context()
2365
2407
 
2366
2408
  # Deployment-registered types (register_doctype / register_master). Folded
2367
2409
  # into the type lists below so the assistant knows they exist and can act
@@ -2387,6 +2429,11 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2387
2429
  + ", ".join(f"`{field}`" for field in meta["fields"])
2388
2430
  )
2389
2431
  chat_doctype_section = _chat_doctype_section()
2432
+ from api.pdf_profiles import pdf_metadata
2433
+ chat_doctype_section += "\n## PDF output contracts\n" + "\n".join(
2434
+ f"- {services.DOCTYPE_TO_SLUG[dt]}: {json.dumps(pdf_metadata(dt), ensure_ascii=False)}"
2435
+ for dt in services.DOCUMENT_CLASSES
2436
+ )
2390
2437
 
2391
2438
  # Channel-aware link guidance. On the "web" channel the reader is a browser
2392
2439
  # inside the ERP, so web-relative links are clickable. On the "api" channel the
@@ -2400,7 +2447,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2400
2447
  markdown_links_section = """## Referring to records
2401
2448
  Your reply is relayed to an external application (the user's Lambda assistant / app), NOT the ERP web interface. Links into the ERP web UI are NOT clickable there, so refer to every record by its human identifier in plain text — e.g. "Quotation **QTN-2298**", "Sales Invoice **SINV-0012**". Do not paste `/app/...`, `/masters/...`, or `/reports/...` URLs; they do not work outside the ERP."""
2402
2449
  document_links_section = """## Delivering document PDFs
2403
- When the user wants a document as a file (an invoice, quotation, delivery note, etc.), include exactly one canonical PDF reference in the form `/api/documents/{doctype-slug}/{name}/pdf` — e.g. `/api/documents/sales-invoice/SINV-0001/pdf`. The platform detects this reference, fetches the PDF, and delivers it to the user as a downloadable attachment. So phrase it as the file being provided ("I've attached the PDF of QTN-2298"), NOT as a link to click. Do not include `/app/...` view links or `/masters/...` links — name the record instead."""
2450
+ Call generate_document_pdf for every requested file. Only a successful result means a file is generated and available for attachment. The platform attaches that exact validated file. Do not invent PDF URLs or claim generation after an error. Name the document in prose; no /app or /masters view links on this channel."""
2404
2451
  else:
2405
2452
  markdown_links_section = """## Always use markdown links
2406
2453
  Every URL you mention in chat MUST be written as a markdown link `[label](url)` — never a bare URL on its own. The chat UI only turns `[label](url)` into a proper clickable link. A bare `/reports/analytics?report_id=...` still works (a fallback linkifier catches it), but markdown form is the expected shape.
@@ -2414,14 +2461,14 @@ When referencing records, always use clickable markdown links so the user can op
2414
2461
 
2415
2462
  **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
2416
2463
  - **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
2417
- - **PDF link:** `/api/documents/{doctype-slug}/{name}/pdf` — e.g. [Download PDF](/api/documents/sales-invoice/SINV-0001/pdf)
2464
+ - **PDF files:** call generate_document_pdf. Only link the returned download_url after successful generation; never construct PDF links yourself.
2418
2465
  The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
2419
2466
 
2420
2467
  **Master records** (customer, supplier, item, warehouse, company):
2421
2468
  - **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
2422
2469
  - NEVER use `/app/...` for masters — that path is only for transactional documents.
2423
2470
 
2424
- Always include the view link after creating, submitting, converting, or updating a record. Include the PDF link when the user asks for a printable version or when sharing an invoice/quotation."""
2471
+ Always include the view link after creating, submitting, converting, or updating a record. Call generate_document_pdf when a printable file is requested. Only confirm and link a successfully generated file."""
2425
2472
 
2426
2473
  if user_role == "admin":
2427
2474
  role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
@@ -2453,11 +2500,16 @@ If `jurisdiction.is_fallback` is true, tell the user their country isn't localiz
2453
2500
 
2454
2501
  You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
2455
2502
 
2503
+ ## Enforced document requirements (generated from backend rule metadata)
2504
+ {validation_context}
2505
+ 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.
2506
+
2456
2507
  ## Never fabricate actions or results
2457
- 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.
2508
+ 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.
2458
2509
  - 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.
2459
2510
  - 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.
2460
2511
  - 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.
2512
+ - 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.
2461
2513
  - If you can't confirm an action took effect, re-fetch and look, or tell the user you couldn't confirm it — never guess.
2462
2514
 
2463
2515
  ## Answering data questions — three paths
@@ -2556,7 +2608,7 @@ Shape — note it does NOT use `items`:
2556
2608
  - Parent fields: `title` (e.g. "Offerte"), `customer`, `company`, `proposal_date`, `partner_name`, `partner_email`, `cover_letter` (the intro/greeting letter text).
2557
2609
  - Child table `quotations[]` — one row per offer to include, in display order. Each row has: `quotation` (the name of an existing Quotation), optional `position_title` and `position_blurb` (default from the quotation), and `is_recommended` (0 or 1 — set 1 on the offer you recommend, which draws an "Empfehlung" badge).
2558
2610
 
2559
- To build one: ensure each offer already exists as its own Quotation (create them first if needed), then call create_document with doctype "proposal" and a data object whose `quotations` array references those quotations by name. Do NOT submit it; link the user to the PDF at `/api/documents/proposal/<name>/pdf` (and the editor at `/app/proposal/<name>`).
2611
+ To build one: ensure each offer already exists as its own Quotation (create them first if needed), then call create_document with doctype "proposal" and a data object whose `quotations` array references those quotations by name. Do NOT submit it; call generate_document_pdf with doctype proposal and link the returned download_url (and the editor at `/app/proposal/<name>`).
2560
2612
 
2561
2613
  ### Recurring offer lines (quotation line `frequency`)
2562
2614
  A **Quotation** line item carries an optional `frequency` that controls how it is billed and totalled on the offer:
@@ -2643,9 +2695,11 @@ Use these only for items flagged `is_asset_tracked` on the Item master. Neither
2643
2695
  - **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.
2644
2696
  - **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.
2645
2697
  - **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:
2646
- - **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.
2698
+ - **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.
2647
2699
  - **unit** — set `asset`: "*that* machine". `item_code`, `warehouse` and `qty` are filled in from the Asset automatically.
2648
- - **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.
2700
+ - **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.
2701
+ - **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.
2702
+ - **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.
2649
2703
  - `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`.
2650
2704
  - **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.
2651
2705
  - 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.
@@ -3439,6 +3493,7 @@ async def run_thinking_loop(
3439
3493
  # immediately affects resource-aware handlers as well as the shared policy.
3440
3494
  principal_ref = [user_info]
3441
3495
  tool_handlers = dict(TOOL_HANDLERS)
3496
+ tool_handlers["generate_document_pdf"] = lambda args: _handle_generate_document_pdf(args, principal_ref[0])
3442
3497
  # delete_master needs the caller's role (admin-only) — scoped here rather
3443
3498
  # than in TOOL_HANDLERS, whose handlers are called with (args) only.
3444
3499
  tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, principal_ref[0])
@@ -3621,6 +3676,8 @@ async def run_thinking_loop(
3621
3676
  tool_call_id = tc.id
3622
3677
 
3623
3678
  await on_event({"type": "tool_call", "tool": fn_name, "args": fn_args})
3679
+ if session_id:
3680
+ _save_tool_trace(session_id, "tool_call", {"tool": fn_name, "args": fn_args})
3624
3681
 
3625
3682
  # LLM turns can run for many seconds. Re-resolve the principal at
3626
3683
  # the actual execution boundary so a demotion, disable, or key
@@ -3656,7 +3713,7 @@ async def run_thinking_loop(
3656
3713
  else:
3657
3714
  try:
3658
3715
  result = await asyncio.to_thread(handler, fn_args)
3659
- success = True
3716
+ success = not (isinstance(result, dict) and ("error" in result or result.get("ok") is False or result.get("failed", 0)))
3660
3717
  except Exception as e:
3661
3718
  result = {"error": str(e)}
3662
3719
  success = False
@@ -3677,6 +3734,10 @@ async def run_thinking_loop(
3677
3734
  "success": success,
3678
3735
  "summary": summary,
3679
3736
  }
3737
+ if isinstance(result, dict):
3738
+ event_payload["warnings"] = (result.get("_validation") or {}).get("warnings", [])
3739
+ if not success:
3740
+ event_payload["error"] = result.get("error") or "One or more operations failed; inspect the tool result."
3680
3741
  # Surface the report id so the sidebar can flash the specific draft.
3681
3742
  if (
3682
3743
  success
@@ -3685,6 +3746,12 @@ async def run_thinking_loop(
3685
3746
  and result.get("id")
3686
3747
  ):
3687
3748
  event_payload["report_id"] = result["id"]
3749
+ if fn_name == "generate_document_pdf":
3750
+ event_payload["document_request"] = {key: fn_args.get(key) for key in ("doctype", "name")}
3751
+ if success and fn_name == "generate_document_pdf" and isinstance(result, dict) and result.get("artifact_id"):
3752
+ event_payload["document"] = result
3753
+ if session_id:
3754
+ _save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
3688
3755
  await on_event(event_payload)
3689
3756
 
3690
3757
  messages.append({
@@ -3875,8 +3942,15 @@ async def run_session_turn(
3875
3942
 
3876
3943
  messages.extend(conversation)
3877
3944
 
3945
+ generated_files = []
3946
+
3947
+ async def pdf_events(event):
3948
+ if event.get('type') == 'tool_result' and event.get('tool') == 'generate_document_pdf' and event.get('success') and event.get('document'):
3949
+ generated_files.append(event['document'])
3950
+ await on_event(event)
3951
+
3878
3952
  await run_thinking_loop(
3879
- messages, on_event,
3953
+ messages, pdf_events,
3880
3954
  session_id=session_id,
3881
3955
  user_info=user_info,
3882
3956
  client_ip=client_ip,
@@ -3886,6 +3960,20 @@ async def run_session_turn(
3886
3960
  for msg in reversed(messages):
3887
3961
  if msg.get("role") == "assistant" and msg.get("content"):
3888
3962
  assistant_content = msg["content"]
3963
+ allowed_urls = {d[key] for d in generated_files for key in ('download_url', 'pdf_url')}
3964
+ pdf_pattern = r"/api/(?:v1/)?documents/[^\s`<>)]*/pdf(?:\?[^\s`<>)]*)?"
3965
+ ungenerated = []
3966
+ def verified_link(match):
3967
+ if match.group(0) in allowed_urls:
3968
+ return match.group(0)
3969
+ ungenerated.append(match.group(0))
3970
+ return '[PDF not generated]'
3971
+ assistant_content = re.sub(pdf_pattern, verified_link, assistant_content)
3972
+ if ungenerated:
3973
+ event = {'type': 'tool_result', 'tool': 'generate_document_pdf', 'success': False,
3974
+ 'error': 'The reply referenced a PDF that was not generated. Generate the file before offering it.', 'warnings': [], 'document_request': {'doctype': None, 'name': None}}
3975
+ await on_event(event)
3976
+ _save_tool_trace(session_id, 'tool_result', event)
3889
3977
  save_chat_message(session_id, "assistant", assistant_content)
3890
3978
  if on_assistant_message:
3891
3979
  await on_assistant_message(session_id, assistant_content)
@@ -63,10 +63,23 @@ 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
 
69
78
  def register_exception_handlers(app: FastAPI):
79
+ from api.pdf_contract import PDFError
80
+ async def pdf_error_handler(request, exc):
81
+ return JSONResponse(status_code=422, content={"detail": {"code": exc.code, "message": str(exc), "fields": exc.fields}})
82
+ app.add_exception_handler(PDFError, pdf_error_handler)
70
83
 
71
84
  @app.exception_handler(ValueError)
72
85
  async def value_error(request: Request, exc: ValueError):
@@ -61,6 +61,9 @@ async def lifespan(app: FastAPI):
61
61
  from api.services import apply_plugin_schema
62
62
  apply_plugin_schema()
63
63
 
64
+ from api.pdf_profiles import validate_pdf_registry
65
+ validate_pdf_registry()
66
+
64
67
  # Ensure the demo spend log table exists before any LLM call happens.
65
68
  from api.demo_limits import init_schema as init_demo_spend_schema
66
69
  init_demo_spend_schema()