lambda-erp 1.3.2__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.
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/PKG-INFO +3 -1
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/attachments.py +17 -2
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/bootstrap.py +5 -1
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/chat.py +156 -27
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/erp_context.py +28 -3
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/main.py +6 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/pdf.py +152 -49
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/pdf_contract.py +52 -10
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/pdf_exports.py +4 -2
- lambda_erp-1.4.0/api/pdf_fonts.py +214 -0
- lambda_erp-1.4.0/api/pdf_pages.py +197 -0
- lambda_erp-1.4.0/api/pdf_templates.py +1109 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/mcp.py +8 -2
- lambda_erp-1.4.0/api/routers/pdf_templates.py +62 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/reports.py +7 -2
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/setup.py +6 -2
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/services.py +31 -0
- lambda_erp-1.4.0/api/templates/delivery_note.html +173 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/templates/document.html +25 -1
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/templates/proposal.html +9 -2
- lambda_erp-1.4.0/api/templates/reservation.html +132 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/tool_permissions.py +4 -1
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/frontend/src/api/client.ts +29 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/sales_invoice.py +2 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/buying/purchase_order.py +2 -0
- lambda_erp-1.4.0/lambda_erp/controllers/item_prices.py +50 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/pricing_rule.py +3 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/database.py +105 -1
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/selling/quotation.py +5 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/selling/sales_order.py +3 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/stock/delivery_note.py +2 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/pyproject.toml +3 -1
- lambda_erp-1.3.2/lambda_erp/controllers/item_prices.py +0 -22
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/.gitignore +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/LICENSE +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/README.md +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/auth.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/chat_links.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/demo_limits.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/deps.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/errors.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/list_values.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/mentions.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/menu_layout.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/oauth.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/providers.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/record_notes.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/remarks_md.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/accounting.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/actions.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/admin.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/analytics.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/availability.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/documents.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/masters.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/routers/proposals.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/templates/record.html +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/time_filters.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/api/write_context.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/docs/agents/README.md +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/frontend/LICENSE +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/frontend/README.md +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/model.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.3.2 → lambda_erp-1.4.0}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.3.2 → 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
|
+
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
3399
|
+
"""Generate a short title for the chat from its first message.
|
|
3303
3400
|
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
|
3335
|
-
input=
|
|
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
|
-
|
|
3477
|
-
|
|
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
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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")
|