lambda-erp 0.8.34__tar.gz → 1.0.1__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-0.8.34 → lambda_erp-1.0.1}/PKG-INFO +1 -1
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/chat.py +56 -5
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/errors.py +4 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/main.py +3 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/pdf.py +106 -37
- lambda_erp-1.0.1/api/pdf_contract.py +280 -0
- lambda_erp-1.0.1/api/pdf_exports.py +64 -0
- lambda_erp-1.0.1/api/pdf_profiles.py +132 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/chat_api.py +32 -39
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/documents.py +9 -3
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/masters.py +14 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/mcp.py +7 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/services.py +10 -1
- lambda_erp-1.0.1/api/templates/_proposal_totals.html +11 -0
- lambda_erp-1.0.1/api/templates/_record_base.html +35 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/templates/document.html +4 -2
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/templates/proposal.html +5 -2
- lambda_erp-1.0.1/api/templates/record.html +1 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/tool_permissions.py +1 -1
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/frontend/src/api/client.ts +7 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/database.py +13 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/pyproject.toml +1 -1
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/.gitignore +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/LICENSE +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/README.md +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/attachments.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/auth.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/deps.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/list_values.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/oauth.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/providers.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/frontend/README.md +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/validation.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/lambda_erp/workflow.py +0 -0
- {lambda_erp-0.8.34 → lambda_erp-1.0.1}/terraform/README.md +0 -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
|
-
|
|
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
|
|
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.
|
|
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;
|
|
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,
|
|
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.
|
|
55
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
-
"""
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
324
|
-
"
|
|
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
|
-
|
|
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()
|