feat(mcp): Belegvorbereitung für Eingangsrechnungen anstoßen
- accounting.incoming_invoices.prepare startet die Vorbereitung für alle offenen oder ausgewählte Dateien und liefert angelegte Entwürfe samt Validierung sowie übersprungene Dateien - Vorbereitungslauf meldet sein Ergebnis und läuft je Mandant nacheinander, damit parallele Aufrufe keine doppelten Belege anlegen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, any>)
|
||||
},
|
||||
},
|
||||
{
|
||||
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<string, any>) : 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",
|
||||
|
||||
@@ -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<number, Promise<unknown>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
Reference in New Issue
Block a user