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")
|
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 {
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult {
|
|||||||
? resultPayload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: resultPayload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
|
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(resultPayload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
|
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import {
|
|||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpContext, McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
|
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"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
@@ -800,6 +804,69 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document }
|
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",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type McpToolResult = {
|
|||||||
content: Array<{
|
content: Array<{
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
|
} | {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mimeType: string
|
||||||
}>
|
}>
|
||||||
structuredContent?: Record<string, unknown>
|
structuredContent?: Record<string, unknown>
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
|
|||||||
276
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
276
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
@@ -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<string, number> = {}
|
||||||
|
const titleSumsTransfer: Record<string, number> = {}
|
||||||
|
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<string, number>) => 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 }
|
||||||
|
}
|
||||||
@@ -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 deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||||
|
|
||||||
@@ -496,6 +506,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoiceData.rows.forEach((row, index) => {
|
invoiceData.rows.forEach((row, index) => {
|
||||||
|
const layoutTop = rowHeight
|
||||||
|
|
||||||
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
|
|
||||||
@@ -881,10 +892,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(rowHeight)
|
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
|
let endTextDiff = 35
|
||||||
|
const layoutContentEnd = rowHeight
|
||||||
|
|
||||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
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)
|
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, {
|
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
||||||
...endTextPosition,
|
...endTextPosition,
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -1030,6 +1059,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold,
|
font: fontBold,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (layout) layout.paymentQr = {
|
||||||
|
page: pages.length,
|
||||||
|
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
|
||||||
|
leftMm: qrX / mm,
|
||||||
|
}
|
||||||
|
|
||||||
lastPage.drawImage(paymentQrCode, {
|
lastPage.drawImage(paymentQrCode, {
|
||||||
x: qrX,
|
x: qrX,
|
||||||
y: qrY,
|
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()
|
return await pdfDoc.saveAsBase64()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
backend/src/utils/pdfPreview.ts
Normal file
30
backend/src/utils/pdfPreview.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
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