From 2bf52b35fe071189b0522bcf192d4499e394e1ab Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 11 May 2026 16:33:28 +0200 Subject: [PATCH] =?UTF-8?q?Ausgangsbelege=20im=20FEDEO=20MCP=20erg=C3=A4nz?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erweitert den Accounting-MCP um lesende und schreibende Tools für Ausgangsbelege. Belege können gelistet, geladen, als Entwurf erstellt, aktualisiert und archiviert werden; optional wird beim Schreiben eine Belegnummer aus dem passenden Nummernkreis gezogen. --- backend/src/mcp/tools/accounting.ts | 316 ++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 184a733..807f9e9 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -2,9 +2,11 @@ import { and, desc, eq, ilike, or } from "drizzle-orm" import { accounts, bankstatements, + createddocuments, incominginvoices, statementallocations, } from "../../../db/schema" +import { useNextNumberRangeNumber } from "../../utils/functions" import { McpTool } from "../types" const limitFromArgs = (args: Record, fallback = 25) => { @@ -23,7 +25,321 @@ const numberArg = (args: Record, key: string) => { return Number.isFinite(value) ? value : null } +const allowedOutgoingDocumentTypes = new Set([ + "quotes", + "costEstimates", + "confirmationOrders", + "deliveryNotes", + "packingSlips", + "invoices", + "advanceInvoices", + "cancellationInvoices", + "serialInvoices", +]) + +const documentTypeArg = (args: Record, key = "type") => { + const type = stringArg(args, key) || "invoices" + if (!allowedOutgoingDocumentTypes.has(type)) { + throw new Error(`Ungültige Belegart: ${type}`) + } + return type +} + +const optionalObjectArg = (args: Record, key: string) => { + const value = args[key] + return value && typeof value === "object" && !Array.isArray(value) ? value : null +} + +const optionalArrayArg = (args: Record, key: string) => { + const value = args[key] + return Array.isArray(value) ? value : null +} + +const buildOutgoingDocumentPayload = ( + args: Record, + userId: string, + tenantId: number, + includeCreateDefaults = false +) => { + const payload: Record = { + updatedAt: new Date(), + updatedBy: userId, + } + + if (includeCreateDefaults) { + payload.tenant = tenantId + payload.createdBy = userId + payload.created_by = userId + payload.archived = false + payload.state = stringArg(args, "state") || "Entwurf" + payload.type = documentTypeArg(args) + payload.rows = optionalArrayArg(args, "rows") || [] + } + + const stringFields = [ + "state", + "documentNumber", + "documentDate", + "deliveryDate", + "deliveryDateEnd", + "deliveryDateType", + "payment_type", + "title", + "description", + "startText", + "endText", + "taxType", + ] + + for (const field of stringFields) { + if (args[field] !== undefined) payload[field] = stringArg(args, field) + } + + for (const field of ["customer", "contact", "contract", "project", "plant", "letterhead", "createddocument"] as const) { + if (args[field] !== undefined) payload[field] = numberArg(args, field) + } + + for (const field of ["paymentDays"] as const) { + if (args[field] !== undefined) payload[field] = numberArg(args, field) + } + + if (args.type !== undefined) payload.type = documentTypeArg(args) + if (args.address !== undefined) payload.address = optionalObjectArg(args, "address") + if (args.info !== undefined) payload.info = optionalObjectArg(args, "info") + if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture") + if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {} + if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {} + if (args.rows !== undefined) payload.rows = optionalArrayArg(args, "rows") || [] + if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || [] + if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal + if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved + if (args.customSurchargePercentage !== undefined) payload.customSurchargePercentage = numberArg(args, "customSurchargePercentage") || 0 + + return payload +} + +const shouldAssignDocumentNumber = (args: Record, state?: unknown) => + args.assignDocumentNumber === true || (state && state !== "Entwurf" && !stringArg(args, "documentNumber")) + export const accountingTools: McpTool[] = [ + { + name: "accounting.outgoing_documents.list", + title: "Ausgangsbelege auflisten", + description: "Listet Ausgangsbelege des aktiven Mandanten mit optionalen Filtern.", + requiredPermissions: ["accounting.outgoing_documents.read"], + inputSchema: { + type: "object", + properties: { + type: { type: "string", description: "Belegart, z. B. invoices, quotes oder deliveryNotes." }, + state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." }, + customer: { type: "number" }, + project: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(createddocuments.tenant, context.tenantId)] + const type = stringArg(args, "type") + const state = stringArg(args, "state") + const customer = numberArg(args, "customer") + const project = numberArg(args, "project") + + if (type) { + if (!allowedOutgoingDocumentTypes.has(type)) throw new Error(`Ungültige Belegart: ${type}`) + conditions.push(eq(createddocuments.type, type)) + } + if (state) conditions.push(eq(createddocuments.state, state)) + if (customer) conditions.push(eq(createddocuments.customer, customer)) + if (project) conditions.push(eq(createddocuments.project, project)) + if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false)) + + const rows = await context.server.db + .select() + .from(createddocuments) + .where(and(...conditions)) + .orderBy(desc(createddocuments.createdAt)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "accounting.outgoing_documents.get", + title: "Ausgangsbeleg laden", + description: "Lädt einen Ausgangsbeleg des aktiven Mandanten anhand seiner ID.", + requiredPermissions: ["accounting.outgoing_documents.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(createddocuments) + .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden") + return { document: rows[0] } + }, + }, + { + name: "accounting.outgoing_documents.create", + title: "Ausgangsbeleg erstellen", + description: "Erstellt einen Ausgangsbeleg im aktiven Mandanten. Ohne assignDocumentNumber bleibt der Beleg als Entwurf ohne neue Nummer möglich.", + requiredPermissions: ["accounting.outgoing_documents.write"], + inputSchema: { + type: "object", + required: ["type"], + properties: { + type: { type: "string" }, + state: { type: "string", default: "Entwurf" }, + assignDocumentNumber: { type: "boolean", default: false }, + customer: { type: "number" }, + contact: { type: "number" }, + contract: { type: "number" }, + project: { type: "number" }, + plant: { type: "number" }, + documentNumber: { type: "string" }, + documentDate: { type: "string" }, + deliveryDate: { type: "string" }, + deliveryDateEnd: { type: "string" }, + deliveryDateType: { type: "string" }, + paymentDays: { type: "number" }, + payment_type: { type: "string" }, + taxType: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + startText: { type: "string" }, + endText: { type: "string" }, + address: { type: "object" }, + rows: { type: "array" }, + letterhead: { type: "number" }, + availableInPortal: { type: "boolean" }, + customSurchargePercentage: { type: "number" }, + report: { type: "object" }, + }, + }, + async handler(context, args) { + const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true) + + if (shouldAssignDocumentNumber(args, payload.state) && !payload.documentNumber) { + const result = await useNextNumberRangeNumber(context.server, context.tenantId, String(payload.type)) + payload.documentNumber = result.usedNumber + } + + const [created] = await context.server.db + .insert(createddocuments) + .values(payload as any) + .returning() + + return { document: created } + }, + }, + { + name: "accounting.outgoing_documents.update", + title: "Ausgangsbeleg aktualisieren", + description: "Aktualisiert einen Ausgangsbeleg im aktiven Mandanten.", + requiredPermissions: ["accounting.outgoing_documents.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + type: { type: "string" }, + state: { type: "string" }, + assignDocumentNumber: { type: "boolean", default: false }, + customer: { type: "number" }, + contact: { type: "number" }, + contract: { type: "number" }, + project: { type: "number" }, + plant: { type: "number" }, + documentNumber: { type: "string" }, + documentDate: { type: "string" }, + deliveryDate: { type: "string" }, + deliveryDateEnd: { type: "string" }, + deliveryDateType: { type: "string" }, + paymentDays: { type: "number" }, + payment_type: { type: "string" }, + taxType: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + startText: { type: "string" }, + endText: { type: "string" }, + address: { type: "object" }, + rows: { type: "array" }, + letterhead: { type: "number" }, + availableInPortal: { type: "boolean" }, + customSurchargePercentage: { type: "number" }, + report: { type: "object" }, + }, + }, + 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(createddocuments) + .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") + + const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) + const nextType = String(payload.type || existing.type) + + if (shouldAssignDocumentNumber(args, payload.state || existing.state) && !payload.documentNumber && !existing.documentNumber) { + const result = await useNextNumberRangeNumber(context.server, context.tenantId, nextType) + payload.documentNumber = result.usedNumber + } + + const [updated] = await context.server.db + .update(createddocuments) + .set(payload as any) + .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) + .returning() + + return { document: updated } + }, + }, + { + name: "accounting.outgoing_documents.archive", + title: "Ausgangsbeleg archivieren", + description: "Archiviert einen Ausgangsbeleg im aktiven Mandanten.", + requiredPermissions: ["accounting.outgoing_documents.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(createddocuments) + .set({ + archived: true, + updatedAt: new Date(), + updatedBy: context.userId, + }) + .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) + .returning() + + if (!updated) throw new Error("Ausgangsbeleg nicht gefunden") + return { document: updated } + }, + }, { name: "accounting.accounts.search", title: "Konten suchen",