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:
94
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
94
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
@@ -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.",
|
||||
}
|
||||
}
|
||||
@@ -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 = <T extends Record<string, unknown>>(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<string, unknown>
|
||||
: { 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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,10 @@ export type McpToolResult = {
|
||||
content: Array<{
|
||||
type: "text"
|
||||
text: string
|
||||
} | {
|
||||
type: "image"
|
||||
data: string
|
||||
mimeType: string
|
||||
}>
|
||||
structuredContent?: Record<string, unknown>
|
||||
isError?: boolean
|
||||
|
||||
Reference in New Issue
Block a user