From 2ec162c0e7e0fb4bbfc707cd6a72798250f433ad Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 9 Sep 2026 14:30:42 +0200 Subject: [PATCH] feat(mcp): support serial invoice workflows --- backend/src/mcp/tools/accounting.ts | 108 +++++++++++++++++- .../src/modules/serialexecution.service.ts | 43 ++++--- backend/src/routes/functions.ts | 7 +- backend/tests/mcpSerialInvoices.test.ts | 34 ++++++ .../pages/createDocument/serialInvoice.vue | 2 +- 5 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 backend/tests/mcpSerialInvoices.test.ts diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 89e8c2e..7067b9c 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -10,12 +10,14 @@ import { folders, incominginvoices, ownaccounts, + serialExecutions, statementallocations, vendors, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" import { saveFile } from "../../utils/files" import { insertHistoryItem } from "../../utils/history" +import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service" import { prepareStatementAllocationInput, statementAllocationUuidArg, @@ -43,6 +45,22 @@ 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 UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const serialConfigSchema = { + type: "object", + description: "Ausführungsplan einer Serienrechnungsvorlage.", + properties: { + firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." }, + executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." }, + intervall: { + type: "string", + enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"], + }, + active: { type: "boolean" }, + dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] }, + }, +} const allowedOutgoingDocumentTypes = new Set([ "quotes", @@ -501,7 +519,7 @@ export const accountingTools: McpTool[] = [ type: "object", required: ["type"], properties: { - type: { type: "string" }, + type: { type: "string", enum: [...allowedOutgoingDocumentTypes] }, customer: { type: "number" }, contact: { type: "number" }, contract: { type: "number" }, @@ -529,6 +547,7 @@ export const accountingTools: McpTool[] = [ availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: { type: "object" }, + serialConfig: serialConfigSchema, }, }, async handler(context, args) { @@ -555,7 +574,7 @@ export const accountingTools: McpTool[] = [ required: ["id"], properties: { id: { type: "number" }, - type: { type: "string" }, + type: { type: "string", enum: [...allowedOutgoingDocumentTypes] }, state: { type: "string" }, customer: { type: "number" }, contact: { type: "number" }, @@ -584,6 +603,7 @@ export const accountingTools: McpTool[] = [ availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: { type: "object" }, + serialConfig: serialConfigSchema, }, }, async handler(context, args) { @@ -668,6 +688,90 @@ export const accountingTools: McpTool[] = [ return { document: updated } }, }, + { + name: "accounting.serial_invoices.execute", + title: "Serienrechnungslauf starten", + description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.", + requiredPermissions: ["accounting.outgoing_documents.write"], + inputSchema: { + type: "object", + required: ["executionDate", "templateIds"], + properties: { + executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." }, + templateIds: { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "number" }, + }, + }, + }, + async handler(context, args) { + const executionDate = stringArg(args, "executionDate") + const parsedExecutionDate = executionDate ? new Date(executionDate) : null + const templateIds = Array.isArray(args.templateIds) + ? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))] + : [] + + if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) { + throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein") + } + if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten") + + return executeManualGeneration( + context.server, + parsedExecutionDate, + templateIds, + context.tenantId, + context.userId, + ) + }, + }, + { + name: "accounting.serial_invoice_executions.list", + title: "Serienrechnungsläufe auflisten", + description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.", + requiredPermissions: ["accounting.outgoing_documents.read"], + inputSchema: { + type: "object", + properties: { + status: { type: "string", enum: ["draft", "completed", "error"] }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(serialExecutions.tenant, context.tenantId)] + const status = stringArg(args, "status") + if (status) conditions.push(eq(serialExecutions.status, status)) + + const rows = await context.server.db + .select() + .from(serialExecutions) + .where(and(...conditions)) + .orderBy(desc(serialExecutions.createdAt)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "accounting.serial_invoice_executions.finish", + title: "Serienrechnungslauf abschließen", + description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.", + requiredPermissions: ["accounting.outgoing_documents.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "UUID des Serienrechnungslaufs." }, + }, + }, + async handler(context, args) { + const id = stringArg(args, "id") + if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein") + return finishManualGeneration(context.server, id, context.tenantId) + }, + }, { name: "accounting.outgoing_documents.archive", title: "Ausgangsbeleg archivieren", diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index bf8bb72..70e4e1c 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars"; dayjs.extend(quarterOfYear); -export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => { - try { - console.log(executedBy) +export const executeManualGeneration = async ( + server: FastifyInstance, + executionDate: string | Date, + templateIds: number[], + tenantId: number, + executedBy: string, +) => { const executionDayjs = dayjs(executionDate); @@ -40,13 +44,14 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa and( eq(schema.createddocuments.tenant, tenantId), eq(schema.createddocuments.type, "serialInvoices"), + eq(schema.createddocuments.archived, false), inArray(schema.createddocuments.id, templateIds) ) ); if (templates.length === 0) { console.warn("Keine passenden Vorlagen gefunden."); - return []; + throw new Error("Keine passenden Serienrechnungsvorlagen gefunden."); } // 3. Folder & FileType IDs holen (Hilfsfunktionen unten) @@ -88,13 +93,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa } } - return results; - } catch (error) { - console.log(error); - } + return { execution: executionRecord, results }; } -export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => { +export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => { try { console.log(`Beende Ausführung ${executionId}...`); @@ -103,15 +105,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI const [executionRecord] = await server.db .select() .from(schema.serialExecutions)// @ts-ignore - .where(eq(schema.serialExecutions.id, executionId)) + .where(and( + eq(schema.serialExecutions.id, executionId), + eq(schema.serialExecutions.tenant, tenantId), + )) .limit(1); if (!executionRecord) throw new Error("Execution nicht gefunden"); console.log(executionRecord); - const tenantId = executionRecord.tenant; - console.log(tenantId) // Tenant laden (für Settings etc.) @@ -132,7 +135,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI const documents = await server.db .select() .from(schema.createddocuments) - .where(eq(schema.createddocuments.serialexecution, executionId)); + .where(and( + eq(schema.createddocuments.serialexecution, executionId), + eq(schema.createddocuments.tenant, tenantId), + eq(schema.createddocuments.archived, false), + )); console.log(`${documents.length} Dokumente werden finalisiert...`); @@ -228,7 +235,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI status: finalStatus, summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.` })// @ts-ignore - .where(eq(schema.serialExecutions.id, executionId)); + .where(and( + eq(schema.serialExecutions.id, executionId), + eq(schema.serialExecutions.tenant, tenantId), + )); return { success: true, processed: successCount, errors: errorCount }; @@ -240,7 +250,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI .update(schema.serialExecutions) .set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." }) //@ts-ignore - .where(eq(schema.serialExecutions.id, executionId)); + .where(and( + eq(schema.serialExecutions.id, executionId), + eq(schema.serialExecutions.tenant, tenantId), + )); throw error; } } diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 2cf1573..4d65184 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -296,15 +296,14 @@ export default async function functionRoutes(server: FastifyInstance) { }) server.post('/functions/serial/start', async (req, reply) => { - console.log(req.body) - const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number} - await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id) + const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]} + return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id) }) server.post('/functions/serial/finish/:execution_id', async (req, reply) => { const {execution_id} = req.params as { execution_id: string } //@ts-ignore - await finishManualGeneration(server,execution_id) + return finishManualGeneration(server, execution_id, req.user.tenant_id) }) server.post('/functions/services/bankstatementsync', async (req, reply) => { diff --git a/backend/tests/mcpSerialInvoices.test.ts b/backend/tests/mcpSerialInvoices.test.ts new file mode 100644 index 0000000..8db4683 --- /dev/null +++ b/backend/tests/mcpSerialInvoices.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mcpToolMap } from "../src/mcp/registry" + +test("publishes the complete serial invoice configuration on create and update", () => { + for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) { + const tool = mcpToolMap.get(name) + const properties = tool?.inputSchema.properties as Record + + assert.ok(properties.serialConfig, name) + assert.deepEqual(properties.serialConfig.properties.intervall.enum, [ + "wöchentlich", + "2 - wöchentlich", + "monatlich", + "vierteljährlich", + "halbjährlich", + "jährlich", + ]) + assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"]) + assert.ok(properties.type.enum.includes("serialInvoices")) + } +}) + +test("registers tenant-scoped serial invoice execution tools", () => { + const execute = mcpToolMap.get("accounting.serial_invoices.execute") + const list = mcpToolMap.get("accounting.serial_invoice_executions.list") + const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish") + + assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"]) + assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"]) + assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"]) + assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"]) + assert.deepEqual(finish?.inputSchema.required, ["id"]) +}) diff --git a/frontend/pages/createDocument/serialInvoice.vue b/frontend/pages/createDocument/serialInvoice.vue index ec300ce..5154fc1 100644 --- a/frontend/pages/createDocument/serialInvoice.vue +++ b/frontend/pages/createDocument/serialInvoice.vue @@ -652,7 +652,7 @@ const executeSerialInvoices = async () => { toast.add({ title: 'Ausführung gestartet', - description: `${res.length} Rechnungen werden im Hintergrund generiert.`, + description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`, icon: 'i-heroicons-check-circle', color: 'green' })