diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 308520d8..2eb116f1 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -1,6 +1,6 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" -import { and, desc, eq, ilike, or } from "drizzle-orm" +import { and, desc, eq, ilike, inArray, or } from "drizzle-orm" import { randomUUID } from "node:crypto" import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { @@ -1154,6 +1154,62 @@ export const accountingTools: McpTool[] = [ return validateIncomingInvoiceData(rows[0] as Record) }, }, + { + name: "accounting.incoming_invoices.prepare", + title: "Belegvorbereitung starten", + description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + properties: { + fileIds: { + type: "array", + items: { type: "string", format: "uuid" }, + minItems: 1, + maxItems: 50, + description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.", + }, + }, + additionalProperties: false, + }, + async handler(context, args) { + let fileIds: string[] | undefined + if (args.fileIds !== undefined) { + if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) { + throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten") + } + if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) { + throw new Error("fileIds darf nur gültige UUIDs enthalten") + } + fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))] + } + + const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds }) + if (result.error && !result.filesFound) throw new Error(result.error) + + const invoiceIds = result.created.map((entry) => entry.invoiceId) + const invoices = invoiceIds.length + ? await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds))) + : [] + const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId)) + + return { + filesFound: result.filesFound, + created: result.created.map((entry) => { + const invoice = invoices.find((row) => row.id === entry.invoiceId) || null + return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record) : null } + }), + skipped: [ + ...result.skipped, + ...(fileIds || []).filter((id) => !handled.has(id)) + .map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })), + ], + } + }, + }, { name: "accounting.incoming_invoices.create", title: "Eingangsbeleg erstellen", diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index bb1ef671..76212242 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -17,7 +17,7 @@ import { incominginvoices, } from "../../../db/schema" -import { eq, and, isNull, not } from "drizzle-orm" +import { eq, and, inArray, isNull, not } from "drizzle-orm" const formatInvoiceItemDescription = (item: any) => { const parts = [ @@ -86,8 +86,16 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { return itemInfo } +export type IncomingInvoicePreparationResult = { + filesFound: number + created: Array<{ fileId: string, invoiceId: number, source: string | null }> + skipped: Array<{ fileId: string, reason: string }> + error?: string +} + export function prepareIncomingInvoices(server: FastifyInstance) { - const processInvoices = async (tenantId:number) => { + const processInvoices = async (tenantId:number, fileIds?: string[]) => { + const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] } console.log("▶ Starting Incoming Invoice Preparation") const tenantsRes = await server.db @@ -98,7 +106,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!tenantsRes.length) { console.log("No tenants with autoPrepareIncomingInvoices = true") - return + return { ...result, error: "Mandant nicht gefunden" } } console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`) @@ -124,6 +132,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { const invoiceFileTag = tagRes?.[0]?.id if (!invoiceFileTag) { server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`) + result.error = "Dateityp für Eingangsrechnungen fehlt" continue } @@ -137,10 +146,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) { eq(files.type, invoiceFileTag), isNull(files.incominginvoice), eq(files.archived, false), - not(isNull(files.path)) + not(isNull(files.path)), + ...(fileIds ? [inArray(files.id, fileIds)] : []) ) ) + result.filesFound += filesRes.length + if (!filesRes.length) { console.log(`No invoice files for tenant ${tenantId}`) continue @@ -158,6 +170,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { fileData = await loadFileBuffer(file.path!) } catch (error) { server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) + result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" }) continue } @@ -186,7 +199,10 @@ export function prepareIncomingInvoices(server: FastifyInstance) { } catch (error) { server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) - if (electronicInvoice.container === "xml") continue + if (electronicInvoice.container === "xml") { + result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" }) + continue + } } } @@ -195,6 +211,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!data) { server.log.warn(`GPT returned no data for file ${file.id}`) + result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" }) continue } @@ -213,6 +230,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!newInvoice) { server.log.error(`Failed to insert incoming invoice for file ${file.id}`) + result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" }) continue } @@ -224,20 +242,31 @@ export function prepareIncomingInvoices(server: FastifyInstance) { .set({ incominginvoice: newInvoice.id }) .where(eq(files.id, file.id)) + result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null }) console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`) } } - return + return result } - return { - run: async (tenant:number) => { - await processInvoices(tenant) - console.log("Incoming Invoice Preparation Completed.") + // Serialize runs per tenant so concurrent triggers cannot prepare the same file twice. + const running = new Map>() + return { + run: async (tenant:number, options: { fileIds?: string[] } = {}) => { + const previous = running.get(tenant) || Promise.resolve() + const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds)) + running.set(tenant, current) + try { + const result = await current + console.log("Incoming Invoice Preparation Completed.") + return result + } finally { + if (running.get(tenant) === current) running.delete(tenant) + } } } diff --git a/backend/tests/mcpIncomingInvoicePreparation.test.ts b/backend/tests/mcpIncomingInvoicePreparation.test.ts new file mode 100644 index 00000000..adefd480 --- /dev/null +++ b/backend/tests/mcpIncomingInvoicePreparation.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mcpToolMap } from "../src/mcp/registry" + +const fileA = "00000000-0000-4000-8000-00000000000a" +const fileB = "00000000-0000-4000-8000-00000000000b" +const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")! + +function context(result: any, invoices: any[] = []) { + const calls: any[] = [] + const db: any = { + select: () => ({ from: () => ({ where: async () => invoices }) }), + } + const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } } + return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any } +} + +test("prepare runs for the active tenant and returns created drafts with validation", async () => { + const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false } + const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice]) + const result: any = await tool.handler(ctx, {}) + assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }]) + assert.equal(result.created[0].invoice.id, 11) + assert.ok(result.created[0].validation) + assert.deepEqual(result.skipped, []) +}) + +test("prepare limits to given files and reports files it could not handle", async () => { + const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] }) + const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] }) + assert.deepEqual(calls[0].options.fileIds, [fileA, fileB]) + assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB]) +}) + +test("prepare validates input and surfaces tenant configuration errors", async () => { + const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" }) + await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/) + await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/) + await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/) + assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"]) +})