From 544d60708845809ea35b1d79aa00a8da57988cd3 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 28 Oct 2026 08:29:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20Vorschau=20von=20Ausgangsbelegen?= =?UTF-8?q?=20mit=20Seitenbildern=20und=20Umbruchpr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accounting.outgoing_documents.preview rendert den Beleg wie der Editor und liefert Seiten als PNG-Bilder im MCP-Ergebnis - Layout je Seite in mm mit Warnungen zu übervollen Seiten, allein stehenden Überschriften, leeren Seiten und vom Überweisungs-QR-Code überdeckten Beträgen samt Vorschlag für den Seitenumbruch - rows testet geänderte Positionen samt Seitenumbrüchen, ohne zu speichern - Belegdaten für das PDF als Backend-Port von getDocumentData (Titelsummen, Überträge, Abschläge, Optional-/Alternativpositionen) Co-Authored-By: Claude Opus 5.5 --- backend/src/mcp/outgoingDocumentLayout.ts | 94 ++++++ backend/src/mcp/result.ts | 9 + backend/src/mcp/tools/accounting.ts | 67 +++++ backend/src/mcp/types.ts | 4 + .../modules/outgoing-document-pdf.service.ts | 276 ++++++++++++++++++ backend/src/utils/pdf.ts | 42 ++- backend/src/utils/pdfPreview.ts | 30 ++ .../tests/mcpOutgoingDocumentPreview.test.ts | 96 ++++++ 8 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 backend/src/mcp/outgoingDocumentLayout.ts create mode 100644 backend/src/modules/outgoing-document-pdf.service.ts create mode 100644 backend/src/utils/pdfPreview.ts create mode 100644 backend/tests/mcpOutgoingDocumentPreview.test.ts diff --git a/backend/src/mcp/outgoingDocumentLayout.ts b/backend/src/mcp/outgoingDocumentLayout.ts new file mode 100644 index 00000000..bc774bb1 --- /dev/null +++ b/backend/src/mcp/outgoingDocumentLayout.ts @@ -0,0 +1,94 @@ +import { InvoicePdfLayout } from "../utils/pdf" + +/** Typical letterheads reserve the bottom ~35 mm of A4 for their footer. */ +export const DEFAULT_CONTENT_BOTTOM_MM = 262 + +type Warning = { type: string, page: number, message: string, insertPagebreakBeforeIndex?: number } + +const round = (value: number) => Math.round(value * 10) / 10 + +/** + * Summarises where rows land and suggests page breaks. FEDEO never breaks pages automatically, + * so content below the usable area runs into the letterhead footer or off the page. + */ +export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxContentBottomMm = DEFAULT_CONTENT_BOTTOM_MM) { + const warnings: Warning[] = [] + const blocks = [ + ...layout.rows.filter((row) => row.mode !== "pagebreak").map((row) => ({ page: row.page, bottomMm: row.bottomMm })), + ...(layout.totals ? [layout.totals] : []), + ...(layout.endText ? [layout.endText] : []), + ] + + const pages = Array.from({ length: layout.pageCount }, (_, offset) => { + const page = offset + 1 + const rows = layout.rows.filter((row) => row.page === page && row.mode !== "pagebreak") + const contentBottomMm = Math.max(0, ...blocks.filter((block) => block.page === page).map((block) => block.bottomMm)) + return { + page, + rows: rows.map((row) => ({ index: row.index, pos: row.pos, mode: row.mode, text: row.text, topMm: round(row.topMm), bottomMm: round(row.bottomMm) })), + contentBottomMm: round(contentBottomMm), + freeMm: round(maxContentBottomMm - contentBottomMm), + } + }) + + for (const page of pages) { + const overflowing = page.rows.find((row) => row.bottomMm > maxContentBottomMm) + if (overflowing) { + // Keep headings with their first position instead of leaving them at the page end. + let before = page.rows.indexOf(overflowing) + while (before > 0 && page.rows[before - 1].mode === "title") before -= 1 + const target = page.rows[before] + warnings.push({ + type: "overflow", + page: page.page, + message: `Position ${overflowing.pos || overflowing.index + 1} reicht bis ${overflowing.bottomMm} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`, + ...(target.index > 0 ? { insertPagebreakBeforeIndex: target.index } : {}), + }) + } + const last = page.rows[page.rows.length - 1] + if (last?.mode === "title" && page.page < layout.pageCount) { + warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index }) + } + if (!page.rows.length && page.page > 1 && !blocks.some((block) => block.page === page.page)) { + warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." }) + } + } + + // The payment QR code is drawn at a fixed spot on the last page, right next to the amount column. + const qr = layout.paymentQr + if (qr) { + const covered = layout.rows.find((row) => row.page === qr.page && row.mode !== "pagebreak" && row.bottomMm > qr.topMm) + const totalsCovered = layout.totals?.page === qr.page && layout.totals.bottomMm > qr.topMm + if (covered || totalsCovered) { + warnings.push({ + type: "paymentQrOverlap", + page: qr.page, + message: `Der Überweisungs-QR-Code (ab ${round(qr.topMm)} mm, rechts) überdeckt ${covered ? `Position ${covered.pos || covered.index + 1}` : "den Summenblock"}. Positionen und Summen auf der letzten Seite müssen oberhalb enden.`, + insertPagebreakBeforeIndex: covered && covered.index > 0 ? covered.index : layout.rows.length, + }) + } + } + + for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) { + if (!block || block.bottomMm <= maxContentBottomMm || pages[block.page - 1]?.rows.some((row) => row.bottomMm > maxContentBottomMm)) continue + warnings.push({ + type: "overflow", + page: block.page, + message: `${name} reicht bis ${round(block.bottomMm)} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`, + insertPagebreakBeforeIndex: layout.rows.length, + }) + } + + return { + layout: { + pageHeightMm: round(layout.pageHeightMm), + maxContentBottomMm, + pages, + totals: layout.totals && { page: layout.totals.page, topMm: round(layout.totals.topMm), bottomMm: round(layout.totals.bottomMm) }, + endText: layout.endText && { page: layout.endText.page, topMm: round(layout.endText.topMm), bottomMm: round(layout.endText.bottomMm) }, + paymentQr: layout.paymentQr && { page: layout.paymentQr.page, topMm: round(layout.paymentQr.topMm), leftMm: round(layout.paymentQr.leftMm) }, + }, + warnings, + hint: "Seitenumbruch setzen: an der Stelle insertPagebreakBeforeIndex ein Objekt {\"mode\":\"pagebreak\"} in rows einfügen (Index 0-basiert; rows.length = vor Summenblock). Zuerst mit rows in dieser Vorschau prüfen, dann mit accounting.outgoing_documents.update speichern. Die Bilder sind maßgeblich; die mm-Werte sind Schätzungen des Renderers.", + } +} diff --git a/backend/src/mcp/result.ts b/backend/src/mcp/result.ts index f894b25e..473165c7 100644 --- a/backend/src/mcp/result.ts +++ b/backend/src/mcp/result.ts @@ -2,6 +2,12 @@ import { McpToolResult } from "./types" const OMIT_ARCHIVED = Symbol("omit-archived") +/** Attach images to a tool payload; they become MCP image content next to the JSON text. */ +export const MCP_IMAGES = Symbol("mcp-images") +export type McpImage = { data: string, mimeType: string } +export const withImages = >(payload: T, images: McpImage[]) => + Object.assign(payload, { [MCP_IMAGES]: images }) + function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED { if (Array.isArray(value)) { return value @@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult { ? resultPayload as Record : { result: resultPayload } + const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || [] + return { content: [ { type: "text", text: JSON.stringify(resultPayload, null, 2), }, + ...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })), ], structuredContent, } diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index aa836534..779d7260 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -31,6 +31,10 @@ import { } from "../statementAllocations" import { McpContext, McpTool } from "../types" import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults" +import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout" +import { withImages } from "../result" +import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service" +import { renderPdfPagesAsPng } from "../../utils/pdfPreview" import { outgoingStatusTools } from "./outgoingStatus" export { prepareStatementAllocationInput } from "../statementAllocations" @@ -800,6 +804,69 @@ export const accountingTools: McpTool[] = [ return { document } }, }, + { + name: "accounting.outgoing_documents.preview", + title: "Ausgangsbeleg-Vorschau", + description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.", + requiredPermissions: ["accounting.outgoing_documents.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." }, + pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." }, + dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." }, + images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." }, + maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." }, + includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." }, + }, + additionalProperties: false, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + const dpi = args.dpi === undefined ? 80 : Number(args.dpi) + if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen") + const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm) + if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen") + if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10 + || args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) { + throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten") + } + + const [existing] = await context.server.db + .select() + .from(createddocuments) + .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) + .limit(1) + if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") + + const document: Record = { ...existing } + if (args.rows !== undefined) { + const payload: Record = { + rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])), + } + applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows) + document.rows = payload.rows + } + + const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document) + const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm) + const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1) + const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount) + const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi) + + return withImages({ + document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title }, + previewOnly: args.rows !== undefined, + pageCount: layout.pageCount, + imagePages: images.map((image) => image.page), + ...analysis, + ...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}), + }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" }))) + }, + }, { name: "accounting.outgoing_documents.finalize", title: "Ausgangsbeleg finalisieren", diff --git a/backend/src/mcp/types.ts b/backend/src/mcp/types.ts index 00738ccc..4ae65d10 100644 --- a/backend/src/mcp/types.ts +++ b/backend/src/mcp/types.ts @@ -13,6 +13,10 @@ export type McpToolResult = { content: Array<{ type: "text" text: string + } | { + type: "image" + data: string + mimeType: string }> structuredContent?: Record isError?: boolean diff --git a/backend/src/modules/outgoing-document-pdf.service.ts b/backend/src/modules/outgoing-document-pdf.service.ts new file mode 100644 index 00000000..7f8cded6 --- /dev/null +++ b/backend/src/modules/outgoing-document-pdf.service.ts @@ -0,0 +1,276 @@ +import { FastifyInstance } from "fastify" +import dayjs from "dayjs" +import { and, eq, inArray } from "drizzle-orm" +import { + authProfiles, + bankaccounts, + contacts, + contracts, + createddocuments, + customers, + letterheads, + plants, + products, + projects, + services, + tenants, + units, +} from "../../db/schema" +import { documentTemplateHandlebars } from "../utils/handlebars" +import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf" + +const quoteLikeDocumentTypes = ["quotes", "costEstimates"] +const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] +const invoiceLikeDocumentTypes = ["invoices", "advanceInvoices", "serialInvoices", "cancellationInvoices"] + +/** Same format as useCurrency in the frontend. */ +export const renderCurrency = (value: any, currency = "€") => + `${Number(value).toFixed(2).replace(".", ",")} ${currency}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".") + +const rowSum = (row: any) => Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100) + +export function documentNumberTitle(type: string) { + if (invoiceLikeDocumentTypes.includes(type)) return "Rechnungsnummer" + if (quoteLikeDocumentTypes.includes(type)) return type === "costEstimates" ? "Kostenschätzungsnummer" : "Angebotsnummer" + if (deliveryNoteLikeDocumentTypes.includes(type)) return type === "packingSlips" ? "Packscheinnummer" : "Lieferscheinnummer" + if (type === "confirmationOrders") return "Auftragsbestätigungsnr." + return "Belegnummer" +} + +const formatNumberLikeValue = (value: any) => { + if (value === null || value === undefined || value === "") return "-" + if (typeof value === "string" || typeof value === "number") return String(value) + const directValue = value.value ?? value.number ?? value.currentNumber ?? value.customerNumber ?? value.id + if (directValue !== null && directValue !== undefined && directValue !== "") return String(directValue) + if (value.prefix !== undefined || value.nextNumber !== undefined || value.suffix !== undefined) { + return `${value.prefix || ""}${value.nextNumber || ""}${value.suffix || ""}` || "-" + } + return "-" +} + +/** Port of documentTotal from the document editor. */ +export function calculateOutgoingDocumentTotals(document: any, advanceInvoices: any[]) { + let totalNet = 0, total19 = 0, totalNet19 = 0, total7 = 0, totalNet7 = 0, totalNet0 = 0 + const rows = document.rows || [] + rows.filter((row: any) => !row.optional && !row.alternative).forEach((row: any) => { + if (["pagebreak", "title", "text"].includes(row.mode)) return + const rowPrice = Number(rowSum(row).toFixed(3)) + totalNet += rowPrice + if (row.taxPercent === 19) { total19 += rowPrice * 0.19; totalNet19 += rowPrice } + else if (row.taxPercent === 7) { total7 += rowPrice * 0.07; totalNet7 += rowPrice } + else if (row.taxPercent === 0) totalNet0 += rowPrice + }) + + const titleSums: Record = {} + const titleSumsTransfer: Record = {} + let activeTitleKeys: string[] = [] + let transferCounter = 0 + rows.forEach((row: any) => { + if (row.mode === "title") { + const title = `${row.pos} - ${row.text}` + const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3) + titleSums[title] = 0 + if (activeTitleKeys.some(Boolean)) titleSumsTransfer[title] = transferCounter + activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1) + activeTitleKeys[titleLevel - 1] = title + activeTitleKeys = activeTitleKeys.slice(0, titleLevel) + } else if (!["pagebreak", "text"].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) { + const sum = rowSum(row) + activeTitleKeys.forEach((key) => { if (key) titleSums[key] = Number(titleSums[key]) + sum }) + transferCounter += sum + } + }) + + const totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2)) + const totalGrossAlreadyPaid = advanceInvoices.reduce((sum, invoice) => { + const advanceRow = (invoice?.rows || []).find((row: any) => row.advanceInvoiceData) + return advanceRow ? sum + advanceRow.price * ((100 + advanceRow.taxPercent) / 100) : sum + }, 0) + const totalSumToPay = document.type === "invoices" ? totalGross - totalGrossAlreadyPaid + : document.type === "cancellationInvoices" ? totalGross + totalGrossAlreadyPaid : 0 + + return { titleSums, titleSumsTransfer, totalNet, total19, totalNet19, total7, totalNet7, total0: 0, totalNet0, totalGross, totalGrossAlreadyPaid, totalSumToPay } +} + +export type OutgoingDocumentPdfContext = { + tenant: any + customer: any + contact?: any + contactPerson?: any + plant?: any + project?: any + contract?: any + units: any[] + products: any[] + services: any[] + bankAccounts: any[] + advanceInvoices: any[] +} + +/** Port of getDocumentData from the document editor, so MCP previews match the UI preview. */ +export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocumentPdfContext) { + const document = structuredClone(source) + document.rows = document.rows || [] + const address = document.address || {} + const customer = data.customer || {} + const contact = data.contact || null + const contactPerson = data.contactPerson || {} + const businessInfo = data.tenant.businessInfo || {} + const paymentQrBankAccount = data.bankAccounts.find((account) => !account.archived && !account.expired && account.iban) + const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban + const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic + const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || data.tenant.name + + if ((data.tenant.extraModules || []).includes("agriculture")) { + document.rows.forEach((row: any) => { + if (row.agriculture?.dieselUsage) { + row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}` + } + }) + } + + const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices) + const rows = document.rows.map((row: any) => { + if (!["pagebreak", "title"].includes(row.mode)) { + if (row.agriculture?.description) row.descriptionText = row.agriculture.description + else if (row.description) row.descriptionText = row.description + else delete row.descriptionText + } + if (["pagebreak", "title", "text"].includes(row.mode)) return row + if (row.mode === "normal") row.text = data.products.find((item) => item.id === row.product)?.name || "" + if (row.mode === "service") row.text = data.services.find((item) => item.id === row.service)?.name || "" + return { + ...row, + rowAmount: renderCurrency(rowSum(row)), + quantity: String(row.quantity).replace(".", ","), + unit: data.units.find((item) => item.id === row.unit)?.short || "", + pos: String(row.pos), + price: renderCurrency(row.price), + discountText: `(Rabatt: ${row.discountPercent} %)`, + } + }) + + const context = { + lohnkosten: document.report?.totalHoursSellingPrice ? renderCurrency(document.report.totalHoursSellingPrice) : null, + anrede: contact?.salutation || customer.salutation, + titel: contact?.title || customer.title, + vorname: contact?.firstName || customer.firstname, + nachname: contact?.lastName || customer.lastname, + kundenname: customer.name, + zahlungsziel_in_tagen: document.paymentDays, + zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift", + diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal, + } + const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX" + const formatSums = (sums: Record) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)])) + const recipientCountry = String(address.country ?? customer.infoData?.country ?? "").trim() + const contactTel = document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel + + return { + type: document.type, + taxType: document.taxType, + adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`, + recipient: [ + customer.name, + ...(customer.nameAddition ? [customer.nameAddition] : []), + ...(contact ? [`${contact.firstName} ${contact.lastName}`] : []), + address.street, + ...(address.special ? [address.special] : []), + `${address.zip} ${address.city}`, + ...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []), + ], + info: [ + { label: documentNumberTitle(document.type), content: document.documentNumber || "XXXX" }, + { label: "Kundennummer", content: formatNumberLikeValue(customer.customerNumber) }, + { label: "Belegdatum", content: formatDate(document.documentDate) }, + ...(document.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{ + label: document.deliveryDateType, + content: !["Lieferzeitraum", "Leistungszeitraum"].includes(document.deliveryDateType) + ? formatDate(document.deliveryDate) + : `${formatDate(document.deliveryDate)} - ${formatDate(document.deliveryDateEnd)}`, + }] : []), + { label: "Ansprechpartner", content: contactPerson.full_name }, + ...(contactTel ? [{ label: "Telefon", content: contactTel }] : []), + ...(contactPerson.email ? [{ label: "E-Mail", content: contactPerson.email }] : []), + ...(document.plant ? [{ label: "Objekt", content: data.plant?.name || "Objekt nicht gefunden" }] : []), + ...(document.project ? [{ label: "Projekt", content: data.project?.name || "Projekt nicht gefunden" }] : []), + ...(document.contract ? [{ label: "Vertrag", content: data.contract?.contractNumber || "Vertrag nicht gefunden" }] : []), + ], + title: document.title, + description: document.description, + endText: documentTemplateHandlebars.compile(document.endText || "")(context), + startText: documentTemplateHandlebars.compile(document.startText || "")(context), + rows, + totalArray: [ + { label: "Nettobetrag", content: renderCurrency(totals.totalNet) }, + ...[19, 7, 0].flatMap((rate) => rows.find((row: any) => row.taxPercent === rate) && document.taxType !== "13b UStG" ? [{ + label: `zzgl. ${rate}% USt auf ${renderCurrency((totals as any)[`totalNet${rate}`])}`, + content: renderCurrency((totals as any)[`total${rate}`]), + }] : []), + { label: "Gesamtbetrag", content: renderCurrency(totals.totalGross) }, + ], + total: { + totalNet: renderCurrency(totals.totalNet), + total19: renderCurrency(totals.total19), + total0: renderCurrency(totals.total0), + totalGross: renderCurrency(totals.totalGross), + totalGrossAlreadyPaid: renderCurrency(totals.totalGrossAlreadyPaid), + totalSumToPay: renderCurrency(totals.totalSumToPay), + titleSums: formatSums(totals.titleSums), + titleSumsTransfer: formatSums(totals.titleSumsTransfer), + }, + agriculture: document.agriculture, + usedAdvanceInvoices: data.advanceInvoices, + paymentQr: paymentQrIban && document.payment_type === "transfer" ? { + iban: paymentQrIban, + bic: paymentQrBic, + recipientName: paymentQrRecipient, + amount: totals.totalSumToPay, + remittance: document.documentNumber || document.title, + } : null, + } +} + +const one = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => { + if (id === null || id === undefined) return null + const [row] = await server.db.select().from(table).where(and(eq(table.id, id as any), eq(table.tenant, tenantId))).limit(1) + return row || null +} + +export async function renderOutgoingDocumentPdf(server: FastifyInstance, tenantId: number, document: any) { + if (!document.letterhead) throw Object.assign(new Error("Es ist kein Briefpapier ausgewählt"), { statusCode: 400 }) + if (!document.customer) throw Object.assign(new Error("Es ist kein Kunde ausgewählt"), { statusCode: 400 }) + const rows = document.rows || [] + const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[] + const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[] + const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[] + + const [[tenant], letterhead, customer, contact, plant, project, contract, unitRows, productRows, serviceRows, bankAccountRows, advanceInvoices, [contactPerson]] = await Promise.all([ + server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1), + one(server, letterheads, document.letterhead, tenantId), + one(server, customers, document.customer, tenantId), + one(server, contacts, document.contact, tenantId), + one(server, plants, document.plant, tenantId), + one(server, projects, document.project, tenantId), + one(server, contracts, document.contract, tenantId), + server.db.select().from(units), + productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [], + serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [], + server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))), + advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [], + document.created_by + ? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, document.created_by), eq(authProfiles.tenant_id, tenantId))).limit(1) + : [null], + ]) + if (!letterhead?.path) throw Object.assign(new Error("Briefpapier nicht gefunden"), { statusCode: 400 }) + if (!customer) throw Object.assign(new Error("Kunde nicht gefunden"), { statusCode: 400 }) + + const pdfData = buildOutgoingDocumentPdfData(document, { + tenant, customer, contact, contactPerson, plant, project, contract, + units: unitRows, products: productRows, services: serviceRows, bankAccounts: bankAccountRows, + advanceInvoices: advanceIds.map((id) => advanceInvoices.find((invoice: any) => invoice.id === id)).filter(Boolean), + }) + const layout: InvoicePdfLayout = { pageHeightMm: 297, rows: [], pageCount: 0 } + const pdf: any = await createInvoicePDF(server, "base64", pdfData, letterhead.path, layout) + return { pdf: Buffer.from(pdf.base64, "base64"), layout } +} diff --git a/backend/src/utils/pdf.ts b/backend/src/utils/pdf.ts index 72104895..a7bea5fc 100644 --- a/backend/src/utils/pdf.ts +++ b/backend/src/utils/pdf.ts @@ -117,7 +117,17 @@ const getDuration = (time) => { } -export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => { +/** Positions in mm from the top edge, recorded while rendering for layout checks (e.g. page breaks). */ +export type InvoicePdfLayout = { + pageHeightMm: number + rows: Array<{ index: number, id?: string, pos?: string, mode: string, text: string, page: number, topMm: number, bottomMm: number }> + totals?: { page: number, topMm: number, bottomMm: number } + endText?: { page: number, topMm: number, bottomMm: number } + paymentQr?: { page: number, topMm: number, leftMm: number } + pageCount: number +} + +export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string, layout?: InvoicePdfLayout) => { const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] const isPackingSlip = invoiceData?.type === "packingSlips" @@ -496,6 +506,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi } invoiceData.rows.forEach((row, index) => { + const layoutTop = rowHeight if (!["pagebreak", "title", "text"].includes(row.mode)) { @@ -881,10 +892,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi } } console.log(rowHeight) + layout?.rows.push({ + index, id: row.id, pos: row.pos, mode: row.mode, text: String(row.text || "").slice(0, 80), + // rowHeight is the baseline of the first text line; shift to the visible cap height. + page: pageCounter, topMm: row.mode === "pagebreak" ? 30 : layoutTop - 3, bottomMm: row.mode === "pagebreak" ? 30 : rowHeight - 3, + }) }) let endTextDiff = 35 + const layoutContentEnd = rowHeight if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) { pages[pageCounter - 1].drawLine({ @@ -1001,6 +1018,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1) + if (layout) { + const maxWidth = getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x) + const lines = String(invoiceData.endText || "").split("\n") + .reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0) + const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8 + // Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below. + const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0) + layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: rowHeight + (totalLines - 1) * 8 + 1 } + // drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm. + layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 } + } + pages[pageCounter - 1].drawText(invoiceData.endText, { ...endTextPosition, size: 10, @@ -1030,6 +1059,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi font: fontBold, }) + if (layout) layout.paymentQr = { + page: pages.length, + topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm, + leftMm: qrX / mm, + } + lastPage.drawImage(paymentQrCode, { x: qrX, y: qrY, @@ -1038,6 +1073,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi }) } + if (layout) { + layout.pageCount = pages.length + layout.pageHeightMm = page1.getHeight() / mm + } + return await pdfDoc.saveAsBase64() } diff --git a/backend/src/utils/pdfPreview.ts b/backend/src/utils/pdfPreview.ts new file mode 100644 index 00000000..ea977d27 --- /dev/null +++ b/backend/src/utils/pdfPreview.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +/** Renders selected PDF pages (1-based) to PNG with poppler's pdftoppm. */ +export async function renderPdfPagesAsPng(pdf: Buffer, pages: number[], dpi: number) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-preview-")) + try { + const input = path.join(dir, "document.pdf") + await fs.writeFile(input, pdf) + const images: Array<{ page: number, png: Buffer }> = [] + for (const page of pages) { + const prefix = path.join(dir, `page-${page}`) + try { + await execFileAsync("pdftoppm", ["-png", "-r", String(dpi), "-f", String(page), "-l", String(page), "-singlefile", input, prefix], { timeout: 30_000 }) + } catch (error: any) { + if (error?.code === "ENOENT") throw Object.assign(new Error("Die Seitenvorschau benötigt pdftoppm (poppler-utils) auf dem Server."), { statusCode: 501 }) + throw error + } + images.push({ page, png: await fs.readFile(`${prefix}.png`) }) + } + return images + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} diff --git a/backend/tests/mcpOutgoingDocumentPreview.test.ts b/backend/tests/mcpOutgoingDocumentPreview.test.ts new file mode 100644 index 00000000..43c5d964 --- /dev/null +++ b/backend/tests/mcpOutgoingDocumentPreview.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { execFileSync } from "node:child_process" +import { Readable } from "node:stream" +import { PDFDocument } from "pdf-lib" +import { initS3, s3 } from "../src/utils/s3" +import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf" +import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service" +import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout" +import { renderPdfPagesAsPng } from "../src/utils/pdfPreview" +import { asToolResult, withImages } from "../src/mcp/result" +import { mcpToolMap } from "../src/mcp/registry" + +const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })() + +const document = (rows: any[]) => ({ + id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28", + deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" }, + startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer", + usedAdvanceInvoices: [], report: {}, rows, +}) +const context = { + tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] }, + customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" }, + contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" }, + units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [], +} +const positions = (count: number) => Array.from({ length: count }, (_, index) => ({ + id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`, + description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0, +})) + +test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => { + const rows = [ + { pos: "1", mode: "title", text: "Bau", titleLevel: 1 }, + { pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 }, + { pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true }, + { mode: "pagebreak" }, + { pos: "2", mode: "title", text: "Elektro", titleLevel: 1 }, + { pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 }, + ] + const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, []) + assert.equal(totals.totalNet, 110) + assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 }) + assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 }) + const data: any = buildOutgoingDocumentPdfData(document(rows), context) + assert.equal(data.startText, "Hallo Ada,") + assert.equal(data.endText, "Zahlbar in 14 Tagen.") + assert.equal(data.rows[5].text, "Kabel") + assert.equal(data.rows[5].unit, "Stk") + assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €") + assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"]) + assert.equal(data.info[0].label, "Rechnungsnummer") +}) + +test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => { + const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [ + { index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 }, + { index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 }, + { index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 }, + ], totals: { page: 1, topMm: 270, bottomMm: 300 } } + const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262) + assert.equal(warnings.length, 1) + assert.equal(warnings[0].insertPagebreakBeforeIndex, 1) + assert.equal(summary.pages[0].freeMm, -38) +}) + +test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => { + const letterhead = await PDFDocument.create() + letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 }) + const background = Buffer.from(await letterhead.save()) + await initS3() + t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) })) + + const rows: any[] = positions(30) + rows.splice(12, 0, { mode: "pagebreak" }) + const data = buildOutgoingDocumentPdfData(document(rows), context) + const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 } + const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout) + assert.equal(layout.pageCount, 2) + assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2) + assert.ok(layout.endText && layout.totals) + const analysis = analyzeOutgoingDocumentLayout(layout) + assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings)) + + const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50) + assert.deepEqual(images.map((image) => image.page), [1, 2]) + assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) + const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))) + assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"]) + assert.deepEqual(result.structuredContent, { pageCount: 2 }) +}) + +test("preview tool is read-only for outgoing documents", () => { + assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"]) +})