From ca4f1ba1c011cf4b3d77bc11eae2b758ec39c066 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 11 May 2026 17:17:09 +0200 Subject: [PATCH] Eingangsbelege im FEDEO MCP bearbeitbar machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ergänzt MCP-Tools zum Prüfen, Bearbeiten, Erstellen, Buchen und Archivieren von Eingangsbelegen. Die Validierung spiegelt die Pflichtregeln aus der FEDEO-Oberfläche und verhindert das Buchen unvollständiger Belege. --- backend/src/mcp/tools/accounting.ts | 299 ++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 22a6b43..2462d47 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -3,6 +3,7 @@ import { accounts, bankstatements, createddocuments, + files, incominginvoices, statementallocations, } from "../../../db/schema" @@ -25,6 +26,9 @@ const numberArg = (args: Record, key: string) => { return Number.isFinite(value) ? value : null } +const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" +const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value)) + const allowedOutgoingDocumentTypes = new Set([ "quotes", "costEstimates", @@ -123,6 +127,88 @@ const assertNoManualDocumentNumber = (args: Record) => { } } +const incomingInvoiceAccountsArg = (args: Record) => { + if (args.accounts === undefined) return undefined + if (!Array.isArray(args.accounts)) throw new Error("accounts muss ein Array sein") + return args.accounts +} + +const buildIncomingInvoicePayload = ( + args: Record, + userId: string, + tenantId: number, + includeCreateDefaults = false +) => { + const payload: Record = { + updatedAt: new Date(), + updatedBy: userId, + } + + if (includeCreateDefaults) { + payload.tenant = tenantId + payload.state = "Entwurf" + payload.expense = args.expense !== undefined ? args.expense === true : true + payload.paid = false + payload.archived = false + } + + for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) { + if (args[field] !== undefined) payload[field] = stringArg(args, field) + } + if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor") + if (args.document !== undefined) payload.document = numberArg(args, "document") + if (args.expense !== undefined) payload.expense = args.expense === true + if (args.paid !== undefined) payload.paid = args.paid === true + + const accountsPayload = incomingInvoiceAccountsArg(args) + if (accountsPayload !== undefined) payload.accounts = accountsPayload + + return payload +} + +const isDepreciationBookingMode = (value: unknown) => + ["depreciation", "depreciation_bundle"].includes(String(value || "")) + +const validateIncomingInvoiceData = (invoice: Record) => { + const errors: Array<{ message: string; type: "breaking" | "warning" }> = [] + + if (!invoice.vendor) errors.push({ message: "Es ist kein Lieferant ausgewählt", type: "breaking" }) + if (!String(invoice.reference || "").trim()) errors.push({ message: "Es ist keine Referenz angegeben", type: "breaking" }) + if (!invoice.date) errors.push({ message: "Es ist kein Datum ausgewählt", type: "breaking" }) + if (!Array.isArray(invoice.accounts) || invoice.accounts.length === 0) { + errors.push({ message: "Es ist keine Position vorhanden", type: "breaking" }) + } + + ;(Array.isArray(invoice.accounts) ? invoice.accounts : []).forEach((account: any, idx: number) => { + const pos = idx + 1 + + if (!account?.account) errors.push({ message: `Pos ${pos}: Keine Kategorie`, type: "breaking" }) + if (!hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountGross)) { + errors.push({ message: `Pos ${pos}: Kein gültiger Betrag`, type: "breaking" }) + } + if (!account?.taxType) errors.push({ message: `Pos ${pos}: Kein Steuerschlüssel`, type: "breaking" }) + if (hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountTax)) { + errors.push({ message: `Pos ${pos}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning" }) + } + if (isDepreciationBookingMode(account?.bookingMode) && !Number(account?.depreciationMonths)) { + errors.push({ message: `Pos ${pos}: Abschreibungsdauer fehlt`, type: "breaking" }) + } + if (account?.bookingMode === "depreciation_bundle" && !String(account?.depreciationGroup || "").trim()) { + errors.push({ message: `Pos ${pos}: Sammelposten benötigt einen Gruppennamen`, type: "breaking" }) + } + }) + + const order = { breaking: 0, warning: 1 } + errors.sort((a, b) => order[a.type] - order[b.type]) + + return { + valid: errors.every((error) => error.type !== "breaking"), + errors, + blockingErrors: errors.filter((error) => error.type === "breaking"), + warnings: errors.filter((error) => error.type === "warning"), + } +} + export const accountingTools: McpTool[] = [ { name: "accounting.outgoing_documents.list", @@ -484,6 +570,219 @@ export const accountingTools: McpTool[] = [ return { invoice: rows[0] } }, }, + { + name: "accounting.incoming_invoices.files.list", + title: "Dateien eines Eingangsbelegs auflisten", + description: "Listet Dateien, die mit einem Eingangsbeleg im aktiven Mandanten verknüpft sind.", + requiredPermissions: ["accounting.incoming_invoices.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(files) + .where(and( + eq(files.incominginvoice, id), + eq(files.tenant, context.tenantId), + eq(files.archived, false) + )) + .orderBy(desc(files.createdAt)) + + return { rows } + }, + }, + { + name: "accounting.incoming_invoices.validate", + title: "Eingangsbeleg validieren", + description: "Prüft einen Eingangsbeleg mit denselben Pflichtregeln wie die FEDEO-Oberfläche vor dem Buchen.", + requiredPermissions: ["accounting.incoming_invoices.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Eingangsbeleg nicht gefunden") + return validateIncomingInvoiceData(rows[0] as Record) + }, + }, + { + name: "accounting.incoming_invoices.create", + title: "Eingangsbeleg erstellen", + description: "Erstellt einen Eingangsbeleg-Entwurf im aktiven Mandanten.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + 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 payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId, true) + payload.state = "Entwurf" + + const [created] = await context.server.db + .insert(incominginvoices) + .values(payload as any) + .returning() + + return { invoice: created, validation: validateIncomingInvoiceData(created as Record) } + }, + }, + { + name: "accounting.incoming_invoices.update", + title: "Eingangsbeleg bearbeiten", + description: "Bearbeitet einen noch nicht gebuchten Eingangsbeleg im aktiven Mandanten.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + 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 id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [existing] = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Eingangsbeleg nicht gefunden") + if (existing.state === "Gebucht") throw new Error("Gebuchte Eingangsbelege können nicht über update geändert werden") + + const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId) + payload.state = existing.state || "Entwurf" + + const [updated] = await context.server.db + .update(incominginvoices) + .set(payload as any) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .returning() + + return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record) } + }, + }, + { + name: "accounting.incoming_invoices.book", + title: "Eingangsbeleg buchen", + description: "Validiert und bucht einen vorbereiteten oder als Entwurf gespeicherten Eingangsbeleg.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [existing] = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Eingangsbeleg nicht gefunden") + if (existing.state === "Gebucht") return { invoice: existing, validation: validateIncomingInvoiceData(existing as Record) } + + const validation = validateIncomingInvoiceData(existing as Record) + if (!validation.valid) { + return { + booked: false, + invoice: existing, + validation, + } + } + + const [updated] = await context.server.db + .update(incominginvoices) + .set({ + state: "Gebucht", + updatedAt: new Date(), + updatedBy: context.userId, + }) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .returning() + + return { + booked: true, + invoice: updated, + validation: validateIncomingInvoiceData(updated as Record), + } + }, + }, + { + name: "accounting.incoming_invoices.archive", + title: "Eingangsbeleg archivieren", + description: "Archiviert einen Eingangsbeleg im aktiven Mandanten.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [updated] = await context.server.db + .update(incominginvoices) + .set({ + archived: true, + updatedAt: new Date(), + updatedBy: context.userId, + }) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .returning() + + if (!updated) throw new Error("Eingangsbeleg nicht gefunden") + return { invoice: updated } + }, + }, { name: "accounting.bank_statements.list", title: "Bankumsätze auflisten",