From 9ba5f26efce6b46d9b93939265c97cdffe046a62 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 12 May 2026 19:04:37 +0200 Subject: [PATCH] =?UTF-8?q?MCP=20Upload=20f=C3=BCr=20Eingangsbelege=20erg?= =?UTF-8?q?=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/mcp/tools/accounting.ts | 165 ++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 0d44887..273083c 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -3,11 +3,14 @@ import { accounts, bankstatements, createddocuments, + filetags, files, + folders, incominginvoices, statementallocations, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" +import { saveFile } from "../../utils/files" import { McpTool } from "../types" const limitFromArgs = (args: Record, fallback = 25) => { @@ -28,6 +31,7 @@ const numberArg = (args: Record, key: string) => { const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value)) +const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024 const allowedOutgoingDocumentTypes = new Set([ "quotes", @@ -193,6 +197,49 @@ const incomingInvoiceAccountsArg = (args: Record) => { return args.accounts } +const base64BufferArg = (args: Record, key: string) => { + const value = stringArg(args, key) + if (!value) throw new Error(`${key} ist erforderlich`) + + const base64 = value.includes(",") ? value.split(",").pop() || "" : value + const buffer = Buffer.from(base64, "base64") + + if (!buffer.length) throw new Error(`${key} enthält keine Datei`) + if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB") + + return buffer +} + +const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: number }) => { + const currentYear = new Date().getFullYear() + + const [tag] = await context.server.db + .select({ id: filetags.id }) + .from(filetags) + .where(and( + eq(filetags.tenant, context.tenantId), + eq(filetags.incomingDocumentType, "invoices"), + eq(filetags.archived, false) + )) + .limit(1) + + const [folder] = await context.server.db + .select({ id: folders.id }) + .from(folders) + .where(and( + eq(folders.tenant, context.tenantId), + eq(folders.function, "incomingInvoices"), + eq(folders.year, currentYear), + eq(folders.archived, false) + )) + .limit(1) + + return { + folderId: folder?.id || null, + fileTagId: tag?.id || null, + } +} + const buildIncomingInvoicePayload = ( args: Record, userId: string, @@ -694,6 +741,124 @@ export const accountingTools: McpTool[] = [ return { rows } }, }, + { + name: "accounting.incoming_invoices.files.upload", + title: "Datei für Eingangsbeleg hochladen", + description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + required: ["filename", "contentBase64"], + properties: { + filename: { type: "string", description: "Dateiname, z. B. rechnung.pdf." }, + contentBase64: { type: "string", description: "Dateiinhalt als Base64 oder Data-URL." }, + mimeType: { type: "string", description: "MIME-Type, z. B. application/pdf oder image/jpeg." }, + invoiceId: { type: "number", description: "Optional: vorhandener Eingangsbeleg, mit dem die Datei verknüpft wird." }, + prepare: { + type: "boolean", + default: true, + description: "Wenn kein invoiceId und keine invoice-Daten übergeben werden, wird nach dem Upload die automatische Eingangsbeleg-Vorbereitung ausgeführt.", + }, + invoice: { + type: "object", + description: "Optional: Daten für einen neuen Eingangsbeleg-Entwurf, der direkt mit der Datei verknüpft wird.", + properties: { + vendor: { type: "number" }, + reference: { type: "string" }, + date: { type: "string" }, + dueDate: { type: "string" }, + document: { type: "number" }, + description: { type: "string" }, + paymentType: { type: "string" }, + accounts: { type: "array" }, + expense: { type: "boolean" }, + }, + }, + }, + }, + async handler(context, args) { + const filename = stringArg(args, "filename") + if (!filename) throw new Error("filename ist erforderlich") + + const buffer = base64BufferArg(args, "contentBase64") + const mimeType = stringArg(args, "mimeType") || "application/octet-stream" + const invoiceId = numberArg(args, "invoiceId") + const invoiceArgs = optionalObjectArg(args, "invoice") as Record | null + const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs + + let linkedInvoice: any = null + + if (invoiceId) { + const [existing] = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Eingangsbeleg nicht gefunden") + linkedInvoice = existing + } else if (invoiceArgs) { + const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true) + payload.state = "Entwurf" + + const [createdInvoice] = await context.server.db + .insert(incominginvoices) + .values(payload as any) + .returning() + + linkedInvoice = createdInvoice + } + + const defaults = await loadIncomingInvoiceFileDefaults(context) + const saved = await saveFile( + context.server, + context.tenantId, + null, + { + filename, + content: buffer, + contentType: mimeType, + }, + defaults.folderId, + defaults.fileTagId, + { + incominginvoice: linkedInvoice?.id || null, + createdBy: context.userId, + updatedBy: context.userId, + updatedAt: new Date(), + } + ) + + if (!saved) throw new Error("Datei konnte nicht gespeichert werden") + + if (shouldPrepare) { + await context.server.services.prepareIncomingInvoices.run(context.tenantId) + } + + const [file] = await context.server.db + .select() + .from(files) + .where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId))) + .limit(1) + + if (file?.incominginvoice && !linkedInvoice) { + const [preparedInvoice] = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + linkedInvoice = preparedInvoice || null + } + + return { + file, + invoice: linkedInvoice, + validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record) : null, + prepared: Boolean(shouldPrepare && linkedInvoice), + } + }, + }, { name: "accounting.incoming_invoices.validate", title: "Eingangsbeleg validieren",