Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae8b7ad9e | |||
| 544d607088 | |||
| c10bfac23b |
@@ -2,18 +2,37 @@
|
||||
name: 🐛 Bug Report
|
||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||
title: '[BUG] '
|
||||
labels: Problem
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Beschreibung**
|
||||
|
||||
Eine klare und prägnante Beschreibung des Fehlers.
|
||||
|
||||
**Reproduktion**
|
||||
Schritte, um den Fehler zu reproduzieren:
|
||||
|
||||
Entweder:
|
||||
1. Gehe zu '...'
|
||||
2. Klicke auf '...'
|
||||
3. Scrolle runter zu '...'
|
||||
4. Siehe Fehler
|
||||
|
||||
Oder Link zur Seite
|
||||
|
||||
**Erwartetes Verhalten**
|
||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
||||
|
||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||
|
||||
**Umgebung:**
|
||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
||||
- Projekt-Version: [z.B. v1.0.2]
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
||||
@@ -2,16 +2,19 @@
|
||||
name: ✨ Feature Request
|
||||
about: Schlage eine Idee für dieses Projekt vor.
|
||||
title: '[FEATURE] '
|
||||
labels: Funktionswunsch
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||
|
||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
||||
|
||||
**Lösungsvorschlag**
|
||||
|
||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
||||
|
||||
**Alternativen**
|
||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
||||
@@ -17,7 +17,8 @@ export const outgoingRowsSchema = {
|
||||
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||
text: { type: "string" },
|
||||
descriptionText: { type: "string" },
|
||||
description: { type: "string", description: "Positionsbeschreibung. Ohne Angabe wird sie bei mode=normal/service aus Artikel bzw. Leistung übernommen, ebenso fehlender Name, Einheit, Preis und USt-Satz." },
|
||||
descriptionText: { type: "string", description: "Veraltet; wird als description übernommen, wenn description fehlt." },
|
||||
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||
quantity: { type: "number", default: 1 },
|
||||
|
||||
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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 && page.page !== layout.paymentQr?.page && !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 renderer moves the payment QR code to an extra page when the amounts reach its spot.
|
||||
const qr = layout.paymentQr
|
||||
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
|
||||
if (qr && qrAlone) {
|
||||
const previous = pages[qr.page - 2]
|
||||
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
|
||||
let before = reaching ? previous.rows.indexOf(reaching) : -1
|
||||
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
|
||||
warnings.push({
|
||||
type: "paymentQrOwnPage",
|
||||
page: qr.page,
|
||||
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
|
||||
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].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.",
|
||||
}
|
||||
}
|
||||
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { products, services } from "../../db/schema"
|
||||
import { McpContext } from "./types"
|
||||
|
||||
const isMissing = (value: unknown) => value === undefined || value === null || value === ""
|
||||
const ids = (rows: Record<string, any>[], mode: string, key: string) =>
|
||||
[...new Set(rows.filter((row) => row.mode === mode && Number.isSafeInteger(Number(row[key])) && Number(row[key]) > 0).map((row) => Number(row[key])))]
|
||||
|
||||
const parseRow = (row: unknown) => {
|
||||
if (typeof row !== "string") return row
|
||||
try { return JSON.parse(row) } catch { return row }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the document editor: selecting a product or service fills name, description, unit,
|
||||
* price, tax rate and (for services) cost centre. Values passed by the caller always win.
|
||||
*/
|
||||
export async function applyOutgoingRowCatalogDefaults(context: Pick<McpContext, "server" | "tenantId">, rows: unknown) {
|
||||
if (!Array.isArray(rows)) return rows
|
||||
const parsed = rows.map(parseRow)
|
||||
const objects = parsed.filter((row): row is Record<string, any> => !!row && typeof row === "object" && !Array.isArray(row))
|
||||
const productIds = ids(objects, "normal", "product")
|
||||
const serviceIds = ids(objects, "service", "service")
|
||||
const [productRows, serviceRows] = await Promise.all([
|
||||
productIds.length ? context.server.db.select().from(products)
|
||||
.where(and(eq(products.tenant, context.tenantId), eq(products.archived, false), inArray(products.id, productIds))) : [],
|
||||
serviceIds.length ? context.server.db.select().from(services)
|
||||
.where(and(eq(services.tenant, context.tenantId), eq(services.archived, false), inArray(services.id, serviceIds))) : [],
|
||||
])
|
||||
const productMap = new Map<number, any>(productRows.map((row: any) => [row.id, row] as const))
|
||||
const serviceMap = new Map<number, any>(serviceRows.map((row: any) => [row.id, row] as const))
|
||||
|
||||
return parsed.map((row, index) => {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||
const result = { ...(row as Record<string, any>) }
|
||||
let defaults: Record<string, unknown> | null = null
|
||||
if (result.mode === "normal" && !isMissing(result.product)) {
|
||||
const product = productMap.get(Number(result.product))
|
||||
if (!product) throw new Error(`Position ${index + 1}: Artikel ${result.product} nicht gefunden oder archiviert`)
|
||||
defaults = { text: product.name, description: product.description, unit: product.unit, price: product.selling_price, taxPercent: product.tax_percentage }
|
||||
} else if (result.mode === "service" && !isMissing(result.service)) {
|
||||
const service = serviceMap.get(Number(result.service))
|
||||
if (!service) throw new Error(`Position ${index + 1}: Leistung ${result.service} nicht gefunden oder archiviert`)
|
||||
defaults = {
|
||||
text: service.name, description: service.description, unit: service.unit,
|
||||
price: service.sellingPriceComposed?.total || service.sellingPrice, taxPercent: service.taxPercentage, costCentre: service.costcentre,
|
||||
}
|
||||
}
|
||||
// The editor stores the position text in description; descriptionText is only derived for the PDF.
|
||||
if (isMissing(result.description) && !isMissing(result.descriptionText)) result.description = result.descriptionText
|
||||
if (!defaults) return result
|
||||
for (const key of ["text", "description", "unit", "costCentre"]) {
|
||||
if (isMissing(result[key]) && !isMissing(defaults[key])) result[key] = defaults[key]
|
||||
}
|
||||
if (isMissing(result.price) && isMissing(result.inputPrice) && !isMissing(defaults.price)) {
|
||||
result.inputPrice = Number(defaults.price)
|
||||
result.price = Number(defaults.price)
|
||||
}
|
||||
if (isMissing(result.taxPercent) && !isMissing(defaults.taxPercent)) result.taxPercent = Number(defaults.taxPercent)
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
statementAllocationUuidArg,
|
||||
} 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"
|
||||
@@ -650,6 +655,7 @@ export const accountingTools: McpTool[] = [
|
||||
},
|
||||
async handler(context, args) {
|
||||
assertNoManualDocumentNumber(args)
|
||||
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
||||
@@ -718,6 +724,7 @@ export const accountingTools: McpTool[] = [
|
||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
|
||||
|
||||
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||
payload.state = "Entwurf"
|
||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
||||
@@ -797,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",
|
||||
@@ -836,6 +906,7 @@ export const accountingTools: McpTool[] = [
|
||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
||||
|
||||
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 isPackingSlip = invoiceData?.type === "packingSlips"
|
||||
|
||||
@@ -153,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
- paymentQrLayout.sizeMm * mm
|
||||
}
|
||||
|
||||
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
|
||||
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
|
||||
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
|
||||
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
|
||||
let paymentQrOnNewPage = false
|
||||
|
||||
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||
if (!paymentQrCode) return 500
|
||||
if (!paymentQrCode || paymentQrOnNewPage) return 500
|
||||
|
||||
return Math.max(
|
||||
240,
|
||||
@@ -496,6 +512,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
|
||||
invoiceData.rows.forEach((row, index) => {
|
||||
const layoutTop = rowHeight
|
||||
|
||||
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||
|
||||
@@ -881,10 +898,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
}
|
||||
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
|
||||
const layoutContentEnd = rowHeight
|
||||
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
|
||||
let amountsBottomMm = rowHeight - 3
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawLine({
|
||||
@@ -999,8 +1024,23 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
}
|
||||
|
||||
// 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)
|
||||
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
|
||||
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
|
||||
|
||||
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
|
||||
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
|
||||
// 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, {
|
||||
...endTextPosition,
|
||||
size: 10,
|
||||
@@ -1012,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
|
||||
if (paymentQrCode) {
|
||||
// Invoice-like documents decide this before placing the end text.
|
||||
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
|
||||
}
|
||||
if (paymentQrOnNewPage) {
|
||||
// Positions or totals reach the QR area; never draw the code over amounts.
|
||||
const qrPage = pdfDoc.addPage()
|
||||
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
|
||||
pages.push(qrPage)
|
||||
}
|
||||
const lastPage = pages[pages.length - 1]
|
||||
const qrSize = paymentQrLayout.sizeMm * mm
|
||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||
@@ -1030,6 +1080,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold,
|
||||
})
|
||||
|
||||
if (layout) layout.paymentQr = {
|
||||
page: pages.length,
|
||||
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
|
||||
leftMm: qrX / mm,
|
||||
}
|
||||
|
||||
lastPage.drawImage(paymentQrCode, {
|
||||
x: qrX,
|
||||
y: qrY,
|
||||
@@ -1038,6 +1094,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
})
|
||||
}
|
||||
|
||||
if (layout) {
|
||||
layout.pageCount = pages.length
|
||||
layout.pageHeightMm = page1.getHeight() / mm
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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"))
|
||||
})
|
||||
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getTableName } from "drizzle-orm"
|
||||
import { applyOutgoingRowCatalogDefaults } from "../src/mcp/outgoingRowDefaults"
|
||||
|
||||
const catalog: Record<string, any[]> = {
|
||||
products: [{ id: 1, name: "Kabel", description: "NYM-J 3x1,5", unit: 2, selling_price: 1.25, tax_percentage: 19 }],
|
||||
services: [{ id: 5, name: "Montage", description: "Montage vor Ort", unit: 3, sellingPrice: 50, sellingPriceComposed: { total: 55 }, taxPercentage: 7, costcentre: "cc" }],
|
||||
}
|
||||
const context: any = { tenantId: 7, server: { db: { select: () => ({ from: (table: any) => ({ where: async () => catalog[getTableName(table)] }) }) } } }
|
||||
|
||||
test("selected products and services bring their description and editor defaults", async () => {
|
||||
const rows: any = await applyOutgoingRowCatalogDefaults(context, [
|
||||
{ mode: "normal", product: 1, quantity: 3 },
|
||||
JSON.stringify({ mode: "service", service: 5 }),
|
||||
{ mode: "free", text: "Frei", descriptionText: "Alt" },
|
||||
])
|
||||
assert.deepEqual(rows[0], { mode: "normal", product: 1, quantity: 3, text: "Kabel", description: "NYM-J 3x1,5", unit: 2, inputPrice: 1.25, price: 1.25, taxPercent: 19 })
|
||||
assert.equal(rows[1].description, "Montage vor Ort")
|
||||
assert.equal(rows[1].price, 55)
|
||||
assert.equal(rows[1].costCentre, "cc")
|
||||
assert.equal(rows[2].description, "Alt")
|
||||
})
|
||||
|
||||
test("caller values win and unknown catalog entries are rejected", async () => {
|
||||
const [row]: any = await applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 1, description: "Eigene", price: 2, taxPercent: 7 }])
|
||||
assert.equal(row.description, "Eigene")
|
||||
assert.equal(row.price, 2)
|
||||
assert.equal(row.taxPercent, 7)
|
||||
await assert.rejects(applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 9 }]), /Artikel 9 nicht gefunden/)
|
||||
})
|
||||
Reference in New Issue
Block a user