feat(mcp): Vorschau von Ausgangsbelegen mit Seitenbildern und Umbruchprüfung
- 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 <noreply@anthropic.com>
This commit is contained in:
96
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
96
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
Reference in New Issue
Block a user