lambda-erp 0.8.34__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 (108) hide show
  1. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/chat.py +56 -5
  3. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/errors.py +4 -0
  4. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/main.py +3 -0
  5. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/pdf.py +106 -37
  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.34 → lambda_erp-1.0.0}/api/routers/chat_api.py +32 -39
  10. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/documents.py +9 -3
  11. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/mcp.py +7 -0
  12. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/services.py +10 -1
  13. lambda_erp-1.0.0/api/templates/_proposal_totals.html +11 -0
  14. lambda_erp-1.0.0/api/templates/_record_base.html +35 -0
  15. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/templates/document.html +4 -2
  16. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/templates/proposal.html +5 -2
  17. lambda_erp-1.0.0/api/templates/record.html +1 -0
  18. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/tool_permissions.py +1 -1
  19. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/frontend/src/api/client.ts +7 -0
  20. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/database.py +13 -0
  21. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/pyproject.toml +1 -1
  22. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/.gitignore +0 -0
  23. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/LICENSE +0 -0
  24. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/README.md +0 -0
  25. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/__init__.py +0 -0
  26. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/attachments.py +0 -0
  27. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/auth.py +0 -0
  28. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/bootstrap.py +0 -0
  29. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/demo_limits.py +0 -0
  30. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/deps.py +0 -0
  31. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/list_values.py +0 -0
  32. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/oauth.py +0 -0
  33. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/providers.py +0 -0
  34. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/remarks_md.py +0 -0
  35. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/__init__.py +0 -0
  36. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/accounting.py +0 -0
  37. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/actions.py +0 -0
  38. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/admin.py +0 -0
  39. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/analytics.py +0 -0
  40. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/availability.py +0 -0
  41. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/bank_reconciliation.py +0 -0
  42. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/bank_statements.py +0 -0
  43. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/masters.py +0 -0
  44. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/proposals.py +0 -0
  45. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/reports.py +0 -0
  46. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/api/routers/setup.py +0 -0
  47. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/docs/agents/README.md +0 -0
  48. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/frontend/LICENSE +0 -0
  49. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/frontend/README.md +0 -0
  50. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/__init__.py +0 -0
  51. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/__init__.py +0 -0
  52. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_account.py +0 -0
  53. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  54. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
  55. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  56. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/budget.py +0 -0
  57. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/camt.py +0 -0
  58. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  59. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/general_ledger.py +0 -0
  60. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/journal_entry.py +0 -0
  61. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/payment_entry.py +0 -0
  62. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  63. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  64. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/revaluation.py +0 -0
  65. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  66. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/settlement.py +0 -0
  67. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  68. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/engine.py +0 -0
  69. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/pack.py +0 -0
  70. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  71. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  72. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  73. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  74. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  75. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  76. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  77. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/spine.py +0 -0
  78. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/accounting/subscription.py +0 -0
  79. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/assets/__init__.py +0 -0
  80. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/assets/asset.py +0 -0
  81. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/assets/lifecycle.py +0 -0
  82. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/assets/reservation.py +0 -0
  83. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/buying/__init__.py +0 -0
  84. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/buying/purchase_order.py +0 -0
  85. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/__init__.py +0 -0
  86. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/currency.py +0 -0
  87. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/defaults.py +0 -0
  88. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/item_prices.py +0 -0
  89. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  90. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  91. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/exceptions.py +0 -0
  92. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/hooks.py +0 -0
  93. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/master_integrity.py +0 -0
  94. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/model.py +0 -0
  95. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/selling/__init__.py +0 -0
  96. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/selling/proposal.py +0 -0
  97. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/selling/quotation.py +0 -0
  98. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/selling/sales_order.py +0 -0
  99. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/simulation.py +0 -0
  100. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/stock/__init__.py +0 -0
  101. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/stock/delivery_note.py +0 -0
  102. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  103. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/stock/stock_entry.py +0 -0
  104. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/stock/stock_ledger.py +0 -0
  105. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/utils.py +0 -0
  106. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/validation.py +0 -0
  107. {lambda_erp-0.8.34 → lambda_erp-1.0.0}/lambda_erp/workflow.py +0 -0
  108. {lambda_erp-0.8.34 → 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.34
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
@@ -504,6 +504,16 @@ TOOLS = [
504
504
  },
505
505
  },
506
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
+ },
507
517
  {
508
518
  "type": "function",
509
519
  "function": {
@@ -1479,6 +1489,15 @@ def _handle_list_documents(args):
1479
1489
  return rows
1480
1490
 
1481
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
+
1482
1501
  def _handle_get_document_fields(args):
1483
1502
  return services.document_field_metadata(args["doctype"])
1484
1503
 
@@ -2240,6 +2259,7 @@ def _handle_apply_company_setup(args):
2240
2259
  TOOL_HANDLERS = {
2241
2260
  "list_documents": _handle_list_documents,
2242
2261
  "get_document_fields": _handle_get_document_fields,
2262
+ "generate_document_pdf": _handle_generate_document_pdf,
2243
2263
  "get_document": _handle_get_document,
2244
2264
  "create_document": _handle_create_document,
2245
2265
  "update_document": _handle_update_document,
@@ -2409,6 +2429,11 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2409
2429
  + ", ".join(f"`{field}`" for field in meta["fields"])
2410
2430
  )
2411
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
+ )
2412
2437
 
2413
2438
  # Channel-aware link guidance. On the "web" channel the reader is a browser
2414
2439
  # inside the ERP, so web-relative links are clickable. On the "api" channel the
@@ -2422,7 +2447,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
2422
2447
  markdown_links_section = """## Referring to records
2423
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."""
2424
2449
  document_links_section = """## Delivering document PDFs
2425
- 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."""
2426
2451
  else:
2427
2452
  markdown_links_section = """## Always use markdown links
2428
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.
@@ -2436,14 +2461,14 @@ When referencing records, always use clickable markdown links so the user can op
2436
2461
 
2437
2462
  **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
2438
2463
  - **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
2439
- - **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.
2440
2465
  The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
2441
2466
 
2442
2467
  **Master records** (customer, supplier, item, warehouse, company):
2443
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)
2444
2469
  - NEVER use `/app/...` for masters — that path is only for transactional documents.
2445
2470
 
2446
- 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."""
2447
2472
 
2448
2473
  if user_role == "admin":
2449
2474
  role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
@@ -2583,7 +2608,7 @@ Shape — note it does NOT use `items`:
2583
2608
  - Parent fields: `title` (e.g. "Offerte"), `customer`, `company`, `proposal_date`, `partner_name`, `partner_email`, `cover_letter` (the intro/greeting letter text).
2584
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).
2585
2610
 
2586
- 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>`).
2587
2612
 
2588
2613
  ### Recurring offer lines (quotation line `frequency`)
2589
2614
  A **Quotation** line item carries an optional `frequency` that controls how it is billed and totalled on the offer:
@@ -3468,6 +3493,7 @@ async def run_thinking_loop(
3468
3493
  # immediately affects resource-aware handlers as well as the shared policy.
3469
3494
  principal_ref = [user_info]
3470
3495
  tool_handlers = dict(TOOL_HANDLERS)
3496
+ tool_handlers["generate_document_pdf"] = lambda args: _handle_generate_document_pdf(args, principal_ref[0])
3471
3497
  # delete_master needs the caller's role (admin-only) — scoped here rather
3472
3498
  # than in TOOL_HANDLERS, whose handlers are called with (args) only.
3473
3499
  tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, principal_ref[0])
@@ -3720,6 +3746,10 @@ async def run_thinking_loop(
3720
3746
  and result.get("id")
3721
3747
  ):
3722
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
3723
3753
  if session_id:
3724
3754
  _save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
3725
3755
  await on_event(event_payload)
@@ -3912,8 +3942,15 @@ async def run_session_turn(
3912
3942
 
3913
3943
  messages.extend(conversation)
3914
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
+
3915
3952
  await run_thinking_loop(
3916
- messages, on_event,
3953
+ messages, pdf_events,
3917
3954
  session_id=session_id,
3918
3955
  user_info=user_info,
3919
3956
  client_ip=client_ip,
@@ -3923,6 +3960,20 @@ async def run_session_turn(
3923
3960
  for msg in reversed(messages):
3924
3961
  if msg.get("role") == "assistant" and msg.get("content"):
3925
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)
3926
3977
  save_chat_message(session_id, "assistant", assistant_content)
3927
3978
  if on_assistant_message:
3928
3979
  await on_assistant_message(session_id, assistant_content)
@@ -76,6 +76,10 @@ async def _integrity_handler(request: Request, exc: Exception):
76
76
 
77
77
 
78
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)
79
83
 
80
84
  @app.exception_handler(ValueError)
81
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()
@@ -2,10 +2,12 @@
2
2
 
3
3
  import io
4
4
  import os
5
+ from dataclasses import dataclass
5
6
  from jinja2 import Environment, FileSystemLoader, ChoiceLoader
6
7
  from weasyprint import HTML
7
8
  from lambda_erp.database import get_db
8
- from api.services import load_document
9
+ from api.services import load_document, get_document_class
10
+ from api.pdf_contract import prepare, validate_rendered_pdf, PDFError
9
11
  from api.remarks_md import render_remarks
10
12
  from lambda_erp.controllers.taxes_and_totals import split_by_frequency
11
13
 
@@ -51,8 +53,8 @@ def register_pdf_context(fn) -> None:
51
53
  assembled context (doc, company_info, party_info, currency, items, …) and
52
54
  may return a dict of EXTRA keys to merge in — e.g. a computed Swiss QR-bill
53
55
  image for invoices. Lets a deployment add per-document content the template
54
- can't compute itself. Exceptions are swallowed so a buggy provider can't
55
- break PDF generation.
56
+ can't compute itself. Return None when inapplicable. Exceptions fail the
57
+ export so required content cannot silently disappear.
56
58
  """
57
59
  _pdf_context_providers.append(fn)
58
60
 
@@ -60,6 +62,7 @@ def register_pdf_context(fn) -> None:
60
62
  DOC_CONFIG = {
61
63
  "Quotation": ("Quotation", "customer", "customer_name", "Customer", "Customer"),
62
64
  "Sales Order": ("Sales Order", "customer", "customer_name", "Customer", "Customer"),
65
+ "POS Invoice": ("POS Invoice", "customer", "customer_name", "Customer", "Customer"),
63
66
  "Sales Invoice": ("Sales Invoice", "customer", "customer_name", "Customer", "Customer"),
64
67
  "Purchase Order": ("Purchase Order", "supplier", "supplier_name", "Supplier", "Supplier"),
65
68
  "Purchase Invoice": ("Purchase Invoice", "supplier", "supplier_name", "Supplier", "Supplier"),
@@ -79,17 +82,43 @@ def _get_dict(row):
79
82
  return dict(row) if row else {}
80
83
 
81
84
 
85
+ @dataclass(frozen=True)
86
+ class RenderedPDF:
87
+ data: bytes
88
+ modified: str | None
89
+ status: str
90
+ warnings: list
91
+
92
+
82
93
  def generate_pdf(doctype_slug: str, name: str) -> bytes:
83
- """Generate a PDF for a document and return raw bytes."""
84
- # The Proposal (Sammelofferte) has a wholly different shape — several
85
- # quotations rendered as lettered positions + an appended appendix — so it
86
- # gets its own render path rather than the single-document template below.
87
- if doctype_slug == "proposal":
88
- return generate_proposal_pdf(name)
94
+ return render_document_pdf(doctype_slug, name).data
95
+
89
96
 
97
+ def render_document_pdf(doctype_slug: str, name: str) -> RenderedPDF:
98
+ """Single validated renderer used by UI, REST, MCP and generated files."""
99
+ doctype, cls = get_document_class(doctype_slug)
100
+ if not cls:
101
+ raise PDFError(f'Unknown PDF document type: {doctype_slug}', code='pdf_unsupported')
90
102
  doc = load_document(doctype_slug, name)
91
- db = get_db()
103
+ contract = prepare(doctype, doc)
104
+ kind = contract['profile'].kind
105
+ try:
106
+ if kind == 'proposal':
107
+ result = _render_proposal_pdf(name, doc, contract)
108
+ elif kind == 'commercial':
109
+ result = _render_commercial_pdf(doctype_slug, name, doc, contract)
110
+ else:
111
+ result = _render_record_pdf(doctype, name, doc, contract)
112
+ except PDFError:
113
+ raise
114
+ except Exception as exc:
115
+ raise PDFError(f'PDF rendering failed for {doctype} {name}: {exc}', code='pdf_render_failed') from exc
116
+ validate_rendered_pdf(result, contract['tokens'])
117
+ return RenderedPDF(result, doc.get('modified'), contract['status'], contract['warnings'])
92
118
 
119
+
120
+ def _render_commercial_pdf(doctype_slug, name, doc, contract):
121
+ db = get_db()
93
122
  # Resolve doctype display name
94
123
  doctype = doctype_slug.replace("-", " ").title()
95
124
  # Fix multi-word: "Sales Invoice" not "Sales-Invoice"
@@ -100,14 +129,13 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
100
129
 
101
130
  config = DOC_CONFIG.get(doctype)
102
131
  if not config:
103
- # Fallback for unknown types
104
- config = (doctype, None, None, None, "Party")
132
+ raise PDFError(f'No commercial PDF mapping for {doctype}', code='pdf_unsupported')
105
133
 
106
134
  title, party_field, party_name_field, party_doctype, party_label = config
107
135
 
108
136
  # Credit note / debit note titles
109
137
  if doc.get("is_return"):
110
- if doctype == "Sales Invoice":
138
+ if doctype in {"Sales Invoice", "POS Invoice"}:
111
139
  title = "Credit Note"
112
140
  elif doctype == "Purchase Invoice":
113
141
  title = "Debit Note"
@@ -146,7 +174,7 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
146
174
  company_name = company_info.get("company_name") or company_id
147
175
 
148
176
  # Currency
149
- currency = doc.get("currency", "USD") or "USD"
177
+ currency = doc["currency"]
150
178
 
151
179
  # Meta fields (varies by doc type)
152
180
  meta_fields = []
@@ -200,6 +228,8 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
200
228
  base_url = template.filename or os.path.join(TEMPLATE_DIR, "document.html")
201
229
  context = dict(
202
230
  doc=doc,
231
+ pdf_status=contract["status"],
232
+ pdf_warnings=contract["warnings"],
203
233
  title=title,
204
234
  company_name=company_name,
205
235
  company_info=company_info,
@@ -227,14 +257,14 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
227
257
  )
228
258
 
229
259
  # Let deployment plugins augment the context (e.g. a Swiss QR-bill image for
230
- # invoices). A provider that raises must not break PDF generation.
260
+ # invoices). Provider errors must fail the export.
231
261
  for provider in _pdf_context_providers:
232
262
  try:
233
263
  extra = provider(doctype, name, context)
234
264
  if extra:
235
265
  context.update(extra)
236
- except Exception:
237
- pass
266
+ except Exception as exc:
267
+ raise PDFError('PDF context provider failed', code='pdf_context_failed') from exc
238
268
 
239
269
  html_str = template.render(**context)
240
270
 
@@ -242,35 +272,30 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
242
272
 
243
273
 
244
274
  def _append_pdf(base_pdf: bytes, extra_pdf: bytes) -> bytes:
245
- """Concatenate `extra_pdf` after `base_pdf`. Used to staple the uploaded
246
- appendix onto the rendered offers. A corrupt/unreadable appendix must not
247
- sink the whole proposal, so on any failure we return the base unchanged."""
248
- try:
249
- from pypdf import PdfReader, PdfWriter
250
- except Exception:
251
- return base_pdf
275
+ """A supplied appendix is required output; corruption must be reported."""
276
+ from pypdf import PdfReader, PdfWriter
252
277
  try:
253
278
  writer = PdfWriter()
254
279
  for src in (base_pdf, extra_pdf):
255
280
  reader = PdfReader(io.BytesIO(src))
281
+ if not reader.pages:
282
+ raise ValueError('Empty appendix')
256
283
  for page in reader.pages:
257
284
  writer.add_page(page)
258
285
  out = io.BytesIO()
259
286
  writer.write(out)
260
287
  return out.getvalue()
261
- except Exception:
262
- return base_pdf
288
+ except Exception as exc:
289
+ raise PDFError('Proposal appendix is not a readable PDF', code='pdf_invalid_appendix') from exc
263
290
 
264
291
 
265
292
  def generate_proposal_pdf(name: str) -> bytes:
266
- """Render a Proposal (Sammelofferte): a cover letter plus each referenced
267
- Quotation as a lettered position (A, B, C…), then append the uploaded
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()
271
- proposal = load_document("proposal", name)
272
- db = get_db()
293
+ # Keep the public extension import; all paths use the shared contract.
294
+ return generate_pdf('proposal', name)
295
+
273
296
 
297
+ def _render_proposal_pdf(name, proposal, contract):
298
+ db = get_db()
274
299
  # Customer (party) — looked up live so a corrected name/address shows.
275
300
  party_name = proposal.get("customer_name") or proposal.get("customer") or ""
276
301
  party_info = {}
@@ -303,7 +328,16 @@ def generate_proposal_pdf(name: str) -> bytes:
303
328
  rows = sorted(proposal.get("quotations", []) or [], key=lambda r: r.get("idx") or 0)
304
329
  for i, row in enumerate(rows):
305
330
  qname = row.get("quotation")
331
+ if not qname:
332
+ raise PDFError('Proposal PDF requires a Quotation in every row')
306
333
  quote = load_document("quotation", qname)
334
+ if quote.get('company') != proposal.get('company') or quote.get('customer') != proposal.get('customer'):
335
+ raise PDFError('Proposal PDF: quotation company and customer must match')
336
+ quote_contract = prepare('Quotation', quote)
337
+ # Each included offer must be rendered completely. Its state is printed
338
+ # alongside its number, including historical cancelled/voided offers.
339
+ contract['tokens'].extend(quote_contract['tokens'])
340
+ contract['warnings'].extend(quote_contract['warnings'])
307
341
  items = quote.get("items", []) or []
308
342
  # Refresh each line's item_name from the master, consistent with the
309
343
  # single-document path.
@@ -317,11 +351,16 @@ def generate_proposal_pdf(name: str) -> bytes:
317
351
  positions.append({
318
352
  "letter": chr(ord("A") + i),
319
353
  "quotation": qname,
354
+ "pdf_status": quote_contract["status"],
320
355
  "title": row.get("position_title") or default_title or qname,
321
356
  "blurb": row.get("position_blurb") or quote.get("remarks") or "",
322
357
  "is_recommended": bool(row.get("is_recommended")),
323
- "currency": quote.get("currency", "USD") or "USD",
324
- "grand_total": quote.get("grand_total") or 0,
358
+ "currency": quote["currency"],
359
+ "net_total": quote["net_total"],
360
+ "grand_total": quote["grand_total"],
361
+ "total_taxes_and_charges": quote["total_taxes_and_charges"],
362
+ "taxes": quote.get("taxes") or [],
363
+ "recurring_summary": split_by_frequency(quote)[1],
325
364
  "items": items,
326
365
  })
327
366
 
@@ -333,6 +372,8 @@ def generate_proposal_pdf(name: str) -> bytes:
333
372
  base_url = template.filename or os.path.join(TEMPLATE_DIR, "proposal.html")
334
373
  context = dict(
335
374
  proposal=proposal,
375
+ pdf_status=contract["status"],
376
+ pdf_warnings=contract["warnings"],
336
377
  title=proposal.get("title") or "Offerte",
337
378
  company_name=company_name,
338
379
  company_info=company_info,
@@ -348,8 +389,8 @@ def generate_proposal_pdf(name: str) -> bytes:
348
389
  extra = provider("Proposal", name, context)
349
390
  if extra:
350
391
  context.update(extra)
351
- except Exception:
352
- pass
392
+ except Exception as exc:
393
+ raise PDFError('PDF context provider failed', code='pdf_context_failed') from exc
353
394
 
354
395
  html_str = template.render(**context)
355
396
  pdf_bytes = HTML(string=html_str, base_url=base_url).write_pdf()
@@ -364,3 +405,31 @@ def generate_proposal_pdf(name: str) -> bytes:
364
405
  pdf_bytes = _append_pdf(pdf_bytes, bytes(data))
365
406
 
366
407
  return pdf_bytes
408
+
409
+
410
+ # Shared record layouts use strict contexts; a misspelled field must fail rather
411
+ # than quietly disappear. Deployment overrides can extend these same blocks.
412
+ def _render_record_pdf(doctype, name, doc, contract):
413
+ from jinja2 import StrictUndefined
414
+ template = _jinja_env.overlay(undefined=StrictUndefined).get_template('record.html')
415
+ company = contract['company']
416
+ page_size_row = get_db().sql('SELECT value FROM "Settings" WHERE key = ?', ['pdf_page_size'])
417
+ context = dict(
418
+ title=contract['profile'].title, name=name, pdf_status=contract['status'],
419
+ pdf_warnings=contract['warnings'], print_fields=contract['fields'],
420
+ print_tables=contract['tables'], print_labels={}, print_title=contract['profile'].title,
421
+ company_info=company, company_name=company.get('company_name') or doc.get('company') or '',
422
+ company_currency=contract['company_currency'] if doctype in {'Journal Entry', 'Budget', 'Subscription', 'Pricing Rule'} else '',
423
+ party_name=contract['party_name'],
424
+ page_size=page_size_row[0]['value'] if page_size_row else 'A4',
425
+ )
426
+ for provider in _pdf_context_providers:
427
+ # A registered provider may be optional, but required providers must
428
+ # propagate failures (for example a mandatory payment part).
429
+ try:
430
+ extra = provider(doctype, name, context)
431
+ if extra:
432
+ context.update(extra)
433
+ except Exception as exc:
434
+ raise PDFError('PDF context provider failed', code='pdf_context_failed') from exc
435
+ return HTML(string=template.render(**context), base_url=template.filename).write_pdf()