Reichen Positionen oder Summen auf der letzten Seite in den Bereich des QR-Codes, wird er auf einer eigenen Folgeseite gedruckt. Die MCP-Vorschau meldet das und schlägt einen früheren Seitenumbruch vor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
7.5 KiB
TypeScript
126 lines
7.5 KiB
TypeScript
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)
|
|
// Rows overflow page 2, so the payment QR code gets an extra third page.
|
|
assert.equal(layout.pageCount, 3)
|
|
assert.equal(layout.paymentQr!.page, 3)
|
|
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"])
|
|
})
|
|
|
|
test("payment QR code moves to its own page instead of covering amounts", { 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 render = async (count: number) => {
|
|
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
|
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
|
|
return layout
|
|
}
|
|
|
|
const short = await render(3)
|
|
assert.equal(short.pageCount, 1)
|
|
assert.equal(short.paymentQr!.page, 1)
|
|
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
|
|
|
|
const long = await render(6)
|
|
assert.equal(long.pageCount, 2)
|
|
assert.equal(long.paymentQr!.page, 2)
|
|
assert.ok(long.rows.every((row) => row.page === 1))
|
|
const analysis = analyzeOutgoingDocumentLayout(long)
|
|
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
|
|
assert.ok(warning, JSON.stringify(analysis.warnings))
|
|
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
|
|
})
|