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:
2026-10-28 08:29:17 +01:00
parent c10bfac23b
commit 544d607088
8 changed files with 617 additions and 1 deletions

View File

@@ -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<string, any> = { ...existing }
if (args.rows !== undefined) {
const payload: Record<string, unknown> = {
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",