lambda-erp 1.3.3__tar.gz → 1.4.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 (125) hide show
  1. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/PKG-INFO +3 -1
  2. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/attachments.py +17 -2
  3. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/bootstrap.py +5 -1
  4. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/chat.py +156 -27
  5. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/erp_context.py +28 -3
  6. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/main.py +6 -0
  7. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/pdf.py +152 -49
  8. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/pdf_contract.py +52 -10
  9. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/pdf_exports.py +4 -2
  10. lambda_erp-1.4.0/api/pdf_fonts.py +214 -0
  11. lambda_erp-1.4.0/api/pdf_pages.py +197 -0
  12. lambda_erp-1.4.0/api/pdf_templates.py +1109 -0
  13. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/mcp.py +8 -2
  14. lambda_erp-1.4.0/api/routers/pdf_templates.py +62 -0
  15. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/reports.py +7 -2
  16. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/setup.py +6 -2
  17. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/services.py +31 -0
  18. lambda_erp-1.4.0/api/templates/delivery_note.html +173 -0
  19. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/templates/document.html +25 -1
  20. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/templates/proposal.html +9 -2
  21. lambda_erp-1.4.0/api/templates/reservation.html +132 -0
  22. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/tool_permissions.py +4 -1
  23. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/frontend/src/api/client.ts +29 -0
  24. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/sales_invoice.py +2 -0
  25. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/buying/purchase_order.py +2 -0
  26. lambda_erp-1.4.0/lambda_erp/controllers/item_prices.py +50 -0
  27. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/pricing_rule.py +3 -0
  28. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/database.py +105 -1
  29. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/selling/quotation.py +5 -0
  30. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/selling/sales_order.py +3 -0
  31. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/stock/delivery_note.py +2 -0
  32. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/pyproject.toml +3 -1
  33. lambda_erp-1.3.3/lambda_erp/controllers/item_prices.py +0 -22
  34. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/.gitignore +0 -0
  35. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/LICENSE +0 -0
  36. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/README.md +0 -0
  37. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/__init__.py +0 -0
  38. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/auth.py +0 -0
  39. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/chat_links.py +0 -0
  40. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/demo_limits.py +0 -0
  41. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/deps.py +0 -0
  42. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/errors.py +0 -0
  43. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/list_values.py +0 -0
  44. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/mcp_oauth.py +0 -0
  45. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/mentions.py +0 -0
  46. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/menu_layout.py +0 -0
  47. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/oauth.py +0 -0
  48. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/pdf_profiles.py +0 -0
  49. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/providers.py +0 -0
  50. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/record_notes.py +0 -0
  51. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/remarks_md.py +0 -0
  52. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/__init__.py +0 -0
  53. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/accounting.py +0 -0
  54. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/actions.py +0 -0
  55. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/admin.py +0 -0
  56. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/analytics.py +0 -0
  57. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/availability.py +0 -0
  58. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/bank_reconciliation.py +0 -0
  59. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/bank_statements.py +0 -0
  60. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/chat_api.py +0 -0
  61. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/documents.py +0 -0
  62. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/masters.py +0 -0
  63. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/routers/proposals.py +0 -0
  64. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/templates/_proposal_totals.html +0 -0
  65. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/templates/_record_base.html +0 -0
  66. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/templates/record.html +0 -0
  67. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/time_filters.py +0 -0
  68. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/api/write_context.py +0 -0
  69. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/docs/agents/README.md +0 -0
  70. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/frontend/LICENSE +0 -0
  71. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/frontend/README.md +0 -0
  72. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/__init__.py +0 -0
  73. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/__init__.py +0 -0
  74. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_account.py +0 -0
  75. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  76. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
  77. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  78. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/budget.py +0 -0
  79. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/camt.py +0 -0
  80. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  81. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/general_ledger.py +0 -0
  82. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/journal_entry.py +0 -0
  83. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/payment_entry.py +0 -0
  84. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  85. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  86. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/revaluation.py +0 -0
  87. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/settlement.py +0 -0
  88. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  89. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/engine.py +0 -0
  90. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/pack.py +0 -0
  91. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  92. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  93. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  94. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  95. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  96. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  97. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  98. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/spine.py +0 -0
  99. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/accounting/subscription.py +0 -0
  100. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/assets/__init__.py +0 -0
  101. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/assets/asset.py +0 -0
  102. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/assets/lifecycle.py +0 -0
  103. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/assets/reservation.py +0 -0
  104. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/buying/__init__.py +0 -0
  105. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/__init__.py +0 -0
  106. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/currency.py +0 -0
  107. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/defaults.py +0 -0
  108. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/item_price.py +0 -0
  109. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  110. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/exceptions.py +0 -0
  111. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/hooks.py +0 -0
  112. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/master_integrity.py +0 -0
  113. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/model.py +0 -0
  114. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/selling/__init__.py +0 -0
  115. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/selling/proposal.py +0 -0
  116. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/simulation.py +0 -0
  117. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/stock/__init__.py +0 -0
  118. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  119. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/stock/stock_entry.py +0 -0
  120. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/stock/stock_ledger.py +0 -0
  121. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/timestamps.py +0 -0
  122. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/utils.py +0 -0
  123. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/validation.py +0 -0
  124. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/lambda_erp/workflow.py +0 -0
  125. {lambda_erp-1.3.3 → lambda_erp-1.4.0}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.3.3
3
+ Version: 1.4.0
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ Requires-Python: >=3.10
9
9
  Requires-Dist: bcrypt>=5
10
10
  Requires-Dist: defusedxml>=0.7
11
11
  Requires-Dist: fastapi>=0.115
12
+ Requires-Dist: fonttools[woff]>=4.38
12
13
  Requires-Dist: holidays>=0.40
13
14
  Requires-Dist: httpx>=0.27
14
15
  Requires-Dist: jinja2>=3.1
@@ -16,6 +17,7 @@ Requires-Dist: openai>=1.0
16
17
  Requires-Dist: openpyxl>=3.1
17
18
  Requires-Dist: pydantic>=2.0
18
19
  Requires-Dist: pypdf>=4.0
20
+ Requires-Dist: pypdfium2>=4.30
19
21
  Requires-Dist: python-dateutil>=2.8
20
22
  Requires-Dist: python-dotenv>=1.0
21
23
  Requires-Dist: python-jose[cryptography]>=3.3
@@ -56,10 +56,14 @@ STRUCTURED_TOOL_MIME_TYPES = {
56
56
  "application/xml", "text/xml", "application/zip", "application/x-zip-compressed",
57
57
  }
58
58
 
59
+ # Font files, for PDF templates (add_attachment_to_pdf_template). The model gets
60
+ # a one-line description, never the bytes.
61
+ FONT_MIME_TYPES = {"font/ttf", "font/otf", "font/woff", "font/woff2"}
62
+
59
63
  ALLOWED_MIME_TYPES = {
60
64
  "image/png", "image/jpeg", "image/gif", "image/webp",
61
65
  "application/pdf",
62
- } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES
66
+ } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES | FONT_MIME_TYPES
63
67
 
64
68
  # Extension -> canonical mime, so a correctly-named file still uploads when the
65
69
  # browser/OS mislabels its type (Office files are frequently sent as
@@ -81,6 +85,7 @@ _EXT_TO_MIME = {
81
85
  "pdf": "application/pdf",
82
86
  "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
83
87
  "gif": "image/gif", "webp": "image/webp",
88
+ "ttf": "font/ttf", "otf": "font/otf", "woff": "font/woff", "woff2": "font/woff2",
84
89
  }
85
90
  _MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
86
91
 
@@ -256,7 +261,7 @@ async def upload_attachment(
256
261
  status_code=400,
257
262
  detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
258
263
  "spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), "
259
- "and structured bank files (XML/ZIP)."),
264
+ "structured bank files (XML/ZIP) and fonts (TTF/OTF/WOFF/WOFF2)."),
260
265
  )
261
266
 
262
267
  chunks: list[bytes] = []
@@ -417,6 +422,16 @@ def build_multimodal_content(attachment: dict) -> dict:
417
422
  "the conversation; use the dedicated bank-statement preview tool.]"
418
423
  ),
419
424
  }
425
+ if mime in FONT_MIME_TYPES:
426
+ from api.pdf_fonts import font_file_info
427
+ try:
428
+ font = font_file_info(attachment["data"])
429
+ what = f'family "{font["family"]}", style {font["style"]}, embedding: {font["embedding"]}'
430
+ except ValueError as exc:
431
+ what = str(exc)
432
+ return {"type": "text", "text": (
433
+ f"[Font file: {filename} ({what}; id {attachment['id']}). To print a PDF template with it, "
434
+ "call add_attachment_to_pdf_template with this attachment id.]")}
420
435
  if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
421
436
  text = attachment["data"].decode("utf-8", errors="replace")
422
437
  return {"type": "text", "text": f"[File: {filename}]\n{text}"}
@@ -112,6 +112,7 @@ def bootstrap_demo() -> None:
112
112
  f"({DEMO_SIM_START} -> {DEMO_SIM_END}, seed={DEMO_SIM_SEED})",
113
113
  flush=True,
114
114
  )
115
+ from api.pdf_templates import pin_only
115
116
  from lambda_erp.simulation import HistoricalSimulator
116
117
 
117
118
  sim = HistoricalSimulator(
@@ -120,7 +121,10 @@ def bootstrap_demo() -> None:
120
121
  end=DEMO_SIM_END,
121
122
  seed=DEMO_SIM_SEED,
122
123
  )
123
- sim.run(simulate_activity=True)
124
+ # Rendering an archive PDF per seeded offer would turn a ~3-minute
125
+ # boot into far longer than the host's startup window allows.
126
+ with pin_only():
127
+ sim.run(simulate_activity=True)
124
128
  else:
125
129
  print(f"[bootstrap] simulation data present ({qtn_count} quotations), skipping", flush=True)
126
130
 
@@ -17,6 +17,7 @@ import json
17
17
  import logging
18
18
  import os
19
19
  import re
20
+ import time
20
21
  import traceback
21
22
  import uuid
22
23
  from datetime import date
@@ -566,7 +567,7 @@ TOOLS = [
566
567
  "include_meta": {"type": "boolean", "description": "Use true for complete lists or overviews: returns exact total and pagination/coverage metadata."},
567
568
  "include_discarded": {"type": "boolean", "description": "Include discarded drafts when checking recent changes; default false."},
568
569
  "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
569
- "filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, in, not in, contains, like, not like, is null, is not null. IN example: {\"type\": [\"in\", [\"call\", \"email\", \"note\"]]}. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
570
+ "filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, in, not in, contains, like, not like, is null, is not null. IN example: {\"type\": [\"in\", [\"call\", \"email\", \"note\"]]}. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns, and on documents with lines (offers, orders, invoices, delivery notes, receipts, stock entries, subscriptions) also any line's item code, item name or description; narrow the document's own columns with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
570
571
  "order_by": {"type": "string", "description": "Optional column to sort by. Defaults to the time_filter field when supplied, otherwise creation."},
571
572
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
572
573
  "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
@@ -1074,6 +1075,36 @@ TOOLS = [
1074
1075
  },
1075
1076
  },
1076
1077
  },
1078
+ {
1079
+ "type": "function",
1080
+ "function": {
1081
+ "name": "inspect_pdf_fonts",
1082
+ "description": "List the exact fonts an uploaded PDF uses (read from the file, not guessed from its look), or identify an uploaded font file, and say for each whether PDF templates can print with it: installed, metric_compatible (another name with the same letter widths) or missing (falls back). Use it before styling a PDF template after a reference PDF.",
1083
+ "parameters": {
1084
+ "type": "object",
1085
+ "properties": {
1086
+ "attachment_id": {"type": "string", "description": "The PDF or font file attachment (from list_chat_attachments)."},
1087
+ },
1088
+ "required": ["attachment_id"],
1089
+ },
1090
+ },
1091
+ },
1092
+ {
1093
+ "type": "function",
1094
+ "function": {
1095
+ "name": "add_attachment_to_pdf_template",
1096
+ "description": "Add a font file (ttf, otf, woff, woff2) or image (png, jpg, gif, webp) the user uploaded in this chat to a PDF template's working copy as an asset. Reference it by its file name in the template: url(\"name.woff2\") in @font-face (the result includes a ready @font-face rule), <img src=\"logo.png\">. Only add fonts the user may use: a commercial font needs a licence that allows embedding it in PDFs; mention that when a user uploads one.",
1097
+ "parameters": {
1098
+ "type": "object",
1099
+ "properties": {
1100
+ "template": {"type": "string", "description": "PDF template id (e.g. PDFT-0001)."},
1101
+ "attachment_id": {"type": "string", "description": "The uploaded font or image (from list_chat_attachments)."},
1102
+ "filename": {"type": "string", "description": "Asset file name to use in the template; defaults to the upload's name."},
1103
+ },
1104
+ "required": ["template", "attachment_id"],
1105
+ },
1106
+ },
1107
+ },
1077
1108
  {
1078
1109
  "type": "function",
1079
1110
  "function": {
@@ -1570,6 +1601,11 @@ def _handle_list_documents(args):
1570
1601
  return {"error": str(exc)}
1571
1602
 
1572
1603
 
1604
+ # Tools whose result is a stored PDF export the reply may link to; the reply
1605
+ # check in run_session_turn rejects any other /documents/.../pdf link.
1606
+ PDF_EXPORT_TOOLS = ("generate_document_pdf", "preview_pdf_template")
1607
+
1608
+
1573
1609
  def _handle_generate_document_pdf(args, user=None):
1574
1610
  from api.pdf_exports import create_pdf_export
1575
1611
  from api.pdf_contract import PDFError
@@ -1989,6 +2025,56 @@ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_i
1989
2025
  }
1990
2026
 
1991
2027
 
2028
+ def _chat_attachment(args, session_id, user_id):
2029
+ if not session_id or not user_id:
2030
+ return None, {"error": "No active chat session for attachments."}
2031
+ from api.attachments import get_attachments_by_ids
2032
+ atts = get_attachments_by_ids([args.get("attachment_id") or ""], user_id)
2033
+ if not atts:
2034
+ return None, {"error": "Attachment not found or access denied."}
2035
+ return atts[0], None
2036
+
2037
+
2038
+ def _handle_inspect_pdf_fonts(args, session_id: str | None = None, user_id: str | None = None):
2039
+ from api import pdf_fonts
2040
+ from api.attachments import FONT_MIME_TYPES
2041
+ att, error = _chat_attachment(args, session_id, user_id)
2042
+ if error:
2043
+ return error
2044
+ try:
2045
+ if att["mime_type"] == "application/pdf":
2046
+ fonts = pdf_fonts.fonts_in_pdf(att["data"])
2047
+ elif att["mime_type"] in FONT_MIME_TYPES:
2048
+ info = pdf_fonts.font_file_info(att["data"])
2049
+ fonts = [{**info, **pdf_fonts.availability(info["family"]),
2050
+ "note": "Add it to a template with add_attachment_to_pdf_template to print with this exact file."}]
2051
+ else:
2052
+ return {"error": f"{att['filename']} is neither a PDF nor a font file."}
2053
+ except Exception as exc: # noqa: BLE001 — a malformed upload is the user's file, not a server fault
2054
+ return {"error": f"Could not read the fonts of {att['filename']}: {exc}"}
2055
+ return {"filename": att["filename"], "fonts": fonts, "installed_fonts": list(pdf_fonts.installed_families())}
2056
+
2057
+
2058
+ def _handle_add_attachment_to_pdf_template(args, user=None, session_id: str | None = None,
2059
+ user_id: str | None = None):
2060
+ from api import pdf_templates
2061
+ att, error = _chat_attachment(args, session_id, user_id)
2062
+ if error:
2063
+ return error
2064
+ filename = args.get("filename") or re.sub(r"[^A-Za-z0-9._-]+", "-", att["filename"]).strip("-")
2065
+ try:
2066
+ result = pdf_templates.add_pdf_template_asset(args.get("template") or "", filename, att["data"], user)
2067
+ except (ValueError, pdf_templates.PDFError) as exc:
2068
+ return {"error": str(exc)}
2069
+ out = {"template": result["template"], "asset": filename, "assets": result["assets"]}
2070
+ if result.get("font"):
2071
+ out["font"] = result["font"]
2072
+ out["note"] = "Add the css rule to the template's <style> and use the family in font-family."
2073
+ else:
2074
+ out["note"] = f'Reference it as <img src="{filename}"> or url("{filename}").'
2075
+ return out
2076
+
2077
+
1992
2078
  def _load_camt_chat_attachments(args, user_id: str | None):
1993
2079
  attachment_ids = args.get("attachment_ids") or []
1994
2080
  if not user_id:
@@ -2348,6 +2434,8 @@ TOOL_HANDLERS = {
2348
2434
  "retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
2349
2435
  "list_chat_attachments": _handle_list_chat_attachments,
2350
2436
  "retrieve_chat_attachment": _handle_retrieve_chat_attachment,
2437
+ "inspect_pdf_fonts": _handle_inspect_pdf_fonts,
2438
+ "add_attachment_to_pdf_template": _handle_add_attachment_to_pdf_template,
2351
2439
  "preview_bank_statement_attachments": _handle_preview_bank_statement_attachments,
2352
2440
  "import_bank_statement_attachments": _handle_import_bank_statement_attachments,
2353
2441
  "list_bank_reconciliation_queue": _handle_list_bank_reconciliation_queue,
@@ -2719,6 +2807,10 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
2719
2807
  - A line starting with `>>` is a price line, split by the FIRST `|` pipe: the text BEFORE the pipe goes in the left (frequency) column, the text AFTER it in the right (amount) column — both placed literally as you write them, NOT interpreted (it does not know "Monatlich" is a frequency or that "CHF 380" is a number). Omit the pipe to put everything in the amount column. The two columns line up with the line-items table's frequency/amount columns, so use it for a separately- or recurring-billed item, e.g. `>> Monatlich | CHF 380.—`.
2720
2808
  - Separate blocks with a blank line; a single newline is just a line break.
2721
2809
  Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
2810
+ Quotations also have `intro_text`: the opening text (greeting, "Gerne unterbreiten wir Ihnen …"), printed above the line items with the same markup; `remarks` is the closing text / conditions after the totals. Put each in its own field. Both may use placeholders filled in at print time: {{ customer_name }}, {{ valid_till }}, {{ transaction_date }}, {{ name }}, {{ company }}, {{ currency }}. New offers start with the default texts (get_pdf_text_defaults / set_pdf_text_defaults); sending a field explicitly overrides its default.
2811
+
2812
+ ### Deductions on a line
2813
+ To take an amount off one position (already invoiced, credited from a previous project, a project rebate), keep the position at its full price and put the deduction on that same line: `price_list_rate` = full price per unit, `discount_amount` = deduction per unit, `discount_label` = its printed text (e.g. "Abzüglich bereits verrechnet (Rechnung SINV-0003)"). The line then counts the net (rate = price_list_rate − discount_amount); the PDF prints the full price and the deduction as an italic sub-line. A deduction can't exceed its position's price, so a line never goes negative and keeps its tax rate. Sending only `rate` with a new deduction takes that rate as the full price. To remove one, set `discount_amount` 0, `discount_label` empty and `rate` back to the full price. The deduction is carried into the sales order and invoice. There are no negative lines: a deduction always belongs to a position.
2722
2814
 
2723
2815
  ### Internal notes (never printed)
2724
2816
  `remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
@@ -3292,17 +3384,23 @@ def _title_model() -> str:
3292
3384
  return os.environ.get("LAMBDA_ERP_TITLE_MODEL") or TITLE_MODEL
3293
3385
 
3294
3386
 
3387
+ # Background title tasks, kept referenced so they aren't garbage-collected
3388
+ # before they finish.
3389
+ _title_tasks: set[asyncio.Task] = set()
3390
+
3391
+
3295
3392
  async def generate_title(
3296
3393
  session_id: str,
3297
3394
  user_message: str,
3298
- assistant_message: str,
3395
+ assistant_message: str = "",
3299
3396
  client_ip: str | None = None,
3300
3397
  user_role: str | None = None,
3301
3398
  ):
3302
- """Generate a short title for the chat based on the first exchange.
3399
+ """Generate a short title for the chat from its first message.
3303
3400
 
3304
- Called after the first assistant reply. Runs in the background so it
3305
- doesn't block the response.
3401
+ Started in the background as soon as the first message arrives, so the
3402
+ title shows up while the reply is still being written. Without a text
3403
+ message (attachments only) it runs after the first reply and uses that too.
3306
3404
  """
3307
3405
  api_key = os.environ.get("OPENAI_API_KEY", "")
3308
3406
  if not api_key or api_key == "sk-your-key-here":
@@ -3325,14 +3423,18 @@ async def generate_title(
3325
3423
  return
3326
3424
 
3327
3425
  settled = False
3426
+ started = time.monotonic()
3427
+ exchange = f"User: {user_message[:200]}"
3428
+ if assistant_message:
3429
+ exchange += f"\nAssistant: {assistant_message[:200]}"
3328
3430
  try:
3329
3431
  # Responses API with low effort: GPT-6 models reason, and the
3330
3432
  # output budget must leave room for that before the title itself.
3331
3433
  response = await asyncio.to_thread(
3332
3434
  client.responses.create,
3333
3435
  model=title_model,
3334
- instructions="Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else.",
3335
- input=f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}",
3436
+ instructions="Generate a very short title (3-6 words, no quotes) for this chat based on its first message. Just the title, nothing else.",
3437
+ input=exchange,
3336
3438
  max_output_tokens=400,
3337
3439
  reasoning={"effort": "low"},
3338
3440
  )
@@ -3353,6 +3455,8 @@ async def generate_title(
3353
3455
  title = (response.output_text or "").strip().strip('"\'')[:120]
3354
3456
  if title:
3355
3457
  update_session_title(session_id, title)
3458
+ print(f"[chat_title] model={title_model} session_id={session_id} "
3459
+ f"ms={int((time.monotonic() - started) * 1000)}", flush=True)
3356
3460
  else:
3357
3461
  logger.warning("[chat_title] model=%s returned no title (status=%s)",
3358
3462
  title_model, getattr(response, "status", None))
@@ -3473,8 +3577,11 @@ def _responses_msg_content(content):
3473
3577
  if ptype == "text":
3474
3578
  parts.append({"type": "input_text", "text": p.get("text", "")})
3475
3579
  elif ptype == "image_url":
3476
- url = (p.get("image_url") or {}).get("url")
3477
- parts.append({"type": "input_image", "image_url": url})
3580
+ image = p.get("image_url") or {}
3581
+ part = {"type": "input_image", "image_url": image.get("url")}
3582
+ if image.get("detail"):
3583
+ part["detail"] = image["detail"]
3584
+ parts.append(part)
3478
3585
  elif ptype == "file":
3479
3586
  f = p.get("file") or {}
3480
3587
  item = {"type": "input_file"}
@@ -3654,6 +3761,9 @@ async def run_thinking_loop(
3654
3761
  tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
3655
3762
  user_id_for_tools = user_info.get("name") if user_info else None
3656
3763
  tool_handlers["list_chat_attachments"] = lambda args: _handle_list_chat_attachments(args, session_id, user_id_for_tools)
3764
+ tool_handlers["inspect_pdf_fonts"] = lambda args: _handle_inspect_pdf_fonts(args, session_id, user_id_for_tools)
3765
+ tool_handlers["add_attachment_to_pdf_template"] = lambda args: _handle_add_attachment_to_pdf_template(
3766
+ args, principal_ref[0], session_id, user_id_for_tools)
3657
3767
  tool_handlers["preview_bank_statement_attachments"] = (
3658
3768
  lambda args: _handle_preview_bank_statement_attachments(args, user_id_for_tools)
3659
3769
  )
@@ -3844,9 +3954,13 @@ async def run_thinking_loop(
3844
3954
  # Pull any multimodal content out of the tool result before serializing.
3845
3955
  # retrieve_chat_attachment returns a {"_multimodal_content": {...}} block
3846
3956
  # which we inject as a fresh user message so the LLM can "see" the file.
3957
+ # view_pdf_pages returns rendered pages under "_images" the same way;
3958
+ # they are shown to the model for this turn and never persisted.
3847
3959
  multimodal_block = None
3960
+ page_images = None
3848
3961
  if isinstance(result, dict):
3849
3962
  multimodal_block = result.pop("_multimodal_content", None)
3963
+ page_images = result.pop("_images", None)
3850
3964
 
3851
3965
  result_str = json.dumps(result, default=str)
3852
3966
  summary = result_str[:500] + "..." if len(result_str) > 500 else result_str
@@ -3871,7 +3985,7 @@ async def run_thinking_loop(
3871
3985
  event_payload["report_id"] = result["id"]
3872
3986
  if fn_name == "generate_document_pdf":
3873
3987
  event_payload["document_request"] = {key: fn_args.get(key) for key in ("doctype", "name")}
3874
- if success and fn_name == "generate_document_pdf" and isinstance(result, dict) and result.get("artifact_id"):
3988
+ if success and fn_name in PDF_EXPORT_TOOLS and isinstance(result, dict) and result.get("artifact_id"):
3875
3989
  event_payload["document"] = result
3876
3990
  if session_id:
3877
3991
  _save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
@@ -3885,6 +3999,9 @@ async def run_thinking_loop(
3885
3999
 
3886
4000
  if multimodal_block:
3887
4001
  pending_multimodal.append(multimodal_block)
4002
+ if page_images:
4003
+ from api.pdf_pages import image_parts
4004
+ pending_multimodal.extend(image_parts(page_images))
3888
4005
 
3889
4006
  # After this batch of tool calls, inject any retrieved attachments as
3890
4007
  # a new user message so the LLM can actually see them on the next turn.
@@ -3892,7 +4009,7 @@ async def run_thinking_loop(
3892
4009
  messages.append({
3893
4010
  "role": "user",
3894
4011
  "content": [
3895
- {"type": "text", "text": "Here are the attachments you requested:"},
4012
+ {"type": "text", "text": "Here are the attachments and PDF pages you requested:"},
3896
4013
  *pending_multimodal,
3897
4014
  ],
3898
4015
  })
@@ -4040,10 +4157,34 @@ async def run_session_turn(
4040
4157
  Hooks (all optional, async):
4041
4158
  - ``on_event(event)`` streamed loop events (WS -> socket; REST -> no-op)
4042
4159
  - ``on_assistant_message(sid, content)`` fired after the reply is persisted
4043
- - ``on_title(sid, title)`` fired after a first-reply title is generated
4160
+ - ``on_title(sid, title)`` fired once the first message's title is generated
4044
4161
  """
4045
4162
  is_first_reply = count_assistant_messages(session_id) == 0
4046
4163
 
4164
+ def start_title(assistant_text: str = ""):
4165
+ async def _gen_and_notify():
4166
+ await generate_title(
4167
+ session_id,
4168
+ user_content,
4169
+ assistant_text,
4170
+ client_ip=client_ip,
4171
+ user_role=(user_info or {}).get("role"),
4172
+ )
4173
+ if on_title:
4174
+ session = get_session(session_id)
4175
+ if session:
4176
+ await on_title(session_id, session["title"])
4177
+ task = asyncio.create_task(_gen_and_notify())
4178
+ _title_tasks.add(task)
4179
+ task.add_done_callback(_title_tasks.discard)
4180
+
4181
+ # Title the chat from the first message right away, in parallel with the
4182
+ # reply: waiting for the whole reply left "New Chat" in the sidebar for
4183
+ # the length of the turn plus the title call.
4184
+ title_started = is_first_reply and bool((user_content or "").strip())
4185
+ if title_started:
4186
+ start_title()
4187
+
4047
4188
  messages = [{"role": "system", "content": build_system_prompt(user_info, channel=channel)}]
4048
4189
 
4049
4190
  if replay_history:
@@ -4077,7 +4218,7 @@ async def run_session_turn(
4077
4218
  generated_files = []
4078
4219
 
4079
4220
  async def pdf_events(event):
4080
- if event.get('type') == 'tool_result' and event.get('tool') == 'generate_document_pdf' and event.get('success') and event.get('document'):
4221
+ if event.get('type') == 'tool_result' and event.get('tool') in PDF_EXPORT_TOOLS and event.get('success') and event.get('document'):
4081
4222
  generated_files.append(event['document'])
4082
4223
  await on_event(event)
4083
4224
 
@@ -4111,20 +4252,8 @@ async def run_session_turn(
4111
4252
  await on_assistant_message(session_id, assistant_content)
4112
4253
  break
4113
4254
 
4114
- if is_first_reply and assistant_content:
4115
- async def _gen_and_notify():
4116
- await generate_title(
4117
- session_id,
4118
- user_content,
4119
- assistant_content,
4120
- client_ip=client_ip,
4121
- user_role=(user_info or {}).get("role"),
4122
- )
4123
- if on_title:
4124
- session = get_session(session_id)
4125
- if session:
4126
- await on_title(session_id, session["title"])
4127
- asyncio.create_task(_gen_and_notify())
4255
+ if is_first_reply and not title_started and assistant_content:
4256
+ start_title(assistant_content)
4128
4257
 
4129
4258
  return assistant_content
4130
4259
 
@@ -25,12 +25,37 @@ status and warnings. Correct validation errors using the field metadata and user
25
25
  Use list/search filters and pagination; an empty or partial search is not proof of zero
26
26
  stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
27
27
  Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
28
- field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
29
- internal note instead (add_record_note / list_record_notes): never printed, visible to
30
- signed-in colleagues only, and @mentions there notify colleagues. Never put internal
28
+ field; offers also have a printed `intro_text` (opening text above the lines). A deduction
29
+ on a position (already invoiced, rebate) goes on that line: price_list_rate = full price,
30
+ discount_amount = deduction per unit, discount_label = its printed text. Internal
31
+ team remarks ("customer disputes line 2", "@Anna please check") go in an internal note
32
+ instead (add_record_note / list_record_notes): never printed, visible to signed-in
33
+ colleagues only, and @mentions there notify colleagues. Never put internal
31
34
  remarks into `remarks`, and never copy internal notes into a document the customer sees.
32
35
  Requests about the user's own sidebar menu ("hide rentals", "put CRM first") use
33
36
  get_menu_layout, then update_menu_layout. They change only that user's menu, not access.
37
+ How a document's PDF looks is its PDF template (layout). Offers, sales orders, sales
38
+ invoices (also credit notes), purchase orders, delivery notes, proposals and reservations
39
+ each have their own templates and default; a template belongs to one type. To change a
40
+ look, get_pdf_template (read its html, context_reference and required first; for the
41
+ built-in 'standard' pass doctype), then update_pdf_template with the complete html. For
42
+ "the invoice in the style of our offer", create_pdf_template with doctype Sales Invoice and
43
+ based_on the offer template, then adapt it to the invoice's variables (e.g. the QR bill).
44
+ After every change, look at the result yourself with view_pdf_pages (area top/bottom for
45
+ small details; every page you changed). When the user gave a reference PDF or image, view
46
+ the same page and area of it (view_pdf_pages with its attachment_id) and of your render,
47
+ compare them. Fix what doesn't match before you say it is done; stop after about three
48
+ rounds and say what is still off. A successful render proves only that the PDF printed,
49
+ not that it looks right.
50
+ To match a reference PDF's fonts, read them with inspect_pdf_fonts (never guess from the
51
+ look). If one is missing, say so, name the closest installed font, and ask for the font
52
+ file; a font uploaded in the chat goes into the template with add_attachment_to_pdf_template
53
+ (commercial fonts need a licence that allows embedding them in PDFs).
54
+ Edit the working copy in place; never create a new template for each change. Publish
55
+ (publish_pdf_template) only when the user asked for it; otherwise offer it and give them
56
+ preview_pdf_template's download. Pick a layout for one document with its `pdf_template`
57
+ field, for all of a type with set_default_pdf_template. A submitted document keeps the layout
58
+ and PDF it was issued with.
34
59
  Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
35
60
  download that artifact, check its SHA-256 and deliver it through the calling application's
36
61
  attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
@@ -21,6 +21,7 @@ from api.mcp_oauth import router as mcp_oauth_router, OAuthError, oauth_error_ha
21
21
  from api.attachments import router as attachments_router
22
22
  from api.chat import chat_websocket, router as chat_router
23
23
  from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
24
+ from api.routers import pdf_templates as pdf_templates_router
24
25
 
25
26
 
26
27
  def load_plugins() -> None:
@@ -63,6 +64,10 @@ async def lifespan(app: FastAPI):
63
64
  initialize_record_notes()
64
65
  from api.menu_layout import initialize_menu_layout
65
66
  initialize_menu_layout()
67
+ from api.pdf_templates import initialize_pdf_templates
68
+ initialize_pdf_templates()
69
+ from api.pdf_pages import initialize_pdf_pages
70
+ initialize_pdf_pages()
66
71
 
67
72
  # Load customer extension plugins before anything creates documents.
68
73
  load_plugins()
@@ -125,6 +130,7 @@ app.include_router(mcp_oauth_router)
125
130
  app.include_router(attachments_router, prefix="/api")
126
131
  app.include_router(documents.router, prefix="/api")
127
132
  app.include_router(proposals.router, prefix="/api")
133
+ app.include_router(pdf_templates_router.router, prefix="/api")
128
134
  app.include_router(masters.router, prefix="/api")
129
135
  app.include_router(actions.router, prefix="/api")
130
136
  app.include_router(availability.router, prefix="/api")