5 Commits

Author SHA1 Message Date
9e003125c2 feat(mcp): Dateien ohne Base64 per HTTP hochladen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m19s
- POST /api/mcp/files nimmt multipart/form-data oder Rohdaten mit dem MCP-Token an, Ziel Dokumentenablage oder Eingangsrechnung
- documents.files.upload_url liefert Adresse, Parameter und curl-Beispiele
- Upload einer Eingangsrechnung bereitet nur noch die hochgeladene Datei vor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:50:34 +01:00
76e82eae4f feat(accounting): fehlende PDF von Ausgangsbelegen nacherzeugen
Auf der Belegansicht erscheint "PDF nacherzeugen", wenn ein
fertiggestellter Beleg kein angehängtes Dokument hat. Die PDF wird mit
derselben Logik wie beim Fertigstellen erzeugt und angehängt, ohne den
Datensatz zu speichern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:40:25 +01:00
f814689ddd fix(accounting): Belegvorbereitung im Hintergrund ausführen und Dubletten verhindern
- „Belege vorbereiten“ startet den Lauf im Hintergrund (202) und fragt den Status ab; ein laufender Vorgang wird nicht erneut gestartet und beim Wiederöffnen der Seite weiterverfolgt
- Eingangsbeleg und Dateiverknüpfung werden in einer Transaktion angelegt; ist die Datei bereits verknüpft, wird der neue Beleg verworfen
- Fehler einzelner Dateien brechen den Lauf nicht mehr ab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:38:45 +01:00
755f1b7e0d feat(accounting): Kostenstellen von Eingangsbelegen jederzeit nachträglich ändern
Eigener Endpunkt und MCP-Tool ändern ausschließlich die Positionskostenstellen,
auch bei gebuchten oder einer Bankbuchung zugewiesenen Eingangsbelegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:36 +01:00
c6fd53307c 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>
2026-10-25 16:03:36 +01:00
16 changed files with 1114 additions and 207 deletions

View File

@@ -0,0 +1,93 @@
import { assertToolPermission } from "./authz"
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
import { McpContext, McpTool } from "./types"
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
import { uploadIncomingInvoiceFile } from "./tools/accounting"
import { secrets } from "../utils/secrets"
export const MCP_UPLOAD_PATH = "/api/mcp/files"
export const uploadTargets = ["inbox", "incoming_invoice"] as const
export type UploadTarget = typeof uploadTargets[number]
export type McpFileUpload = {
target: UploadTarget
filename: string
body: Buffer
mimeType?: string | null
invoiceId?: number | null
prepare?: boolean
}
export function mcpUploadUrl() {
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
}
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
/** Reads the query options of the binary upload endpoint. */
export function parseUploadQuery(query: Record<string, unknown>) {
const target = (query.target ?? "inbox") as UploadTarget
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
let invoiceId: number | null = null
if (query.invoiceId !== undefined && query.invoiceId !== "") {
invoiceId = Number(query.invoiceId)
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
}
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
}
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
if (upload.target === "inbox") {
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
await assertInternalDocumentAccess(context)
const mimeType = validateScannerFile(upload.filename, upload.body)
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
}
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
return uploadIncomingInvoiceFile(context, {
filename: upload.filename,
buffer: upload.body,
mimeType: upload.mimeType || "application/octet-stream",
invoiceId: upload.invoiceId,
prepare: upload.prepare,
})
}
export const uploadUrlTool: McpTool = {
name: "documents.files.upload_url",
title: "Upload-Adresse für Dateien",
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
// Informational only; the endpoint checks the permission of the chosen target.
requiredPermissions: [],
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler() {
const url = mcpUploadUrl()
return {
url,
method: "POST",
authentication: "Authorization: Bearer <derselbe MCP-Token>",
bodies: {
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
},
query: {
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
},
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
examples: [
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
],
}
},
}

View File

@@ -4,6 +4,7 @@ import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki"
import { documentTools } from "./tools/documents"
import { archiveTools } from "./tools/archive"
import { uploadUrlTool } from "./fileUploads"
export const mcpTools = [
...accountingTools,
@@ -12,6 +13,7 @@ export const mcpTools = [
...wikiTools,
...documentTools,
...archiveTools,
uploadUrlTool,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -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 {
@@ -22,13 +22,14 @@ import { saveFile } from "../../utils/files"
import { insertHistoryItem } from "../../utils/history"
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service"
import { loadBankBalances } from "../../modules/bank-balances.service"
import { dateField } from "../masterdataFields"
import {
prepareStatementAllocationInput,
statementAllocationUuidArg,
} from "../statementAllocations"
import { McpTool } from "../types"
import { McpContext, McpTool } from "../types"
import { outgoingStatusTools } from "./outgoingStatus"
export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -311,6 +312,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
}
}
export type IncomingInvoiceFileUpload = {
filename: string
buffer: Buffer
mimeType: string
invoiceId?: number | null
invoiceArgs?: Record<string, unknown> | null
prepare?: boolean
}
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
const { filename, buffer, mimeType } = upload
const invoiceId = upload.invoiceId || null
const invoiceArgs = upload.invoiceArgs || null
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
if (!buffer.length) throw new Error("Datei ist leer")
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
}
const buildIncomingInvoicePayload = (
args: Record<string, unknown>,
userId: string,
@@ -1013,7 +1106,7 @@ export const accountingTools: McpTool[] = [
{
name: "accounting.incoming_invoices.files.upload",
title: "Datei für Eingangsbeleg hochladen",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
@@ -1049,83 +1142,14 @@ export const accountingTools: McpTool[] = [
const filename = stringArg(args, "filename")
if (!filename) throw new Error("filename ist erforderlich")
const buffer = base64BufferArg(args, "contentBase64")
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
const invoiceId = numberArg(args, "invoiceId")
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
return uploadIncomingInvoiceFile(context, {
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
buffer: base64BufferArg(args, "contentBase64"),
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
invoiceId: numberArg(args, "invoiceId"),
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
prepare: args.prepare !== false,
})
},
},
{
@@ -1154,6 +1178,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",
@@ -1231,6 +1311,44 @@ export const accountingTools: McpTool[] = [
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
},
},
{
name: "accounting.incoming_invoices.cost_centres.update",
title: "Kostenstellen eines Eingangsbelegs ändern",
description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id", "accountCostCentres"],
properties: {
id: { type: "number" },
accountCostCentres: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["index", "costcentre"],
properties: {
index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." },
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
},
},
},
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const invoice = await updateIncomingInvoiceCostCentres(
context.server,
context.tenantId,
context.userId,
id,
{ accountCostCentres: args.accountCostCentres as any },
)
return { invoice }
},
},
{
name: "accounting.incoming_invoices.book",
title: "Eingangsbeleg buchen",

View File

@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
const body = await readFile(file.path, MCP_FILE_LIMIT)
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
}),
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
["filename", "contentBase64"], async (context, args) => {
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)

View File

@@ -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,25 @@ 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 type IncomingInvoicePreparationStatus = {
running: boolean
startedAt: string | null
finishedAt: string | null
result: IncomingInvoicePreparationResult | null
}
class FileAlreadyPreparedError extends Error {}
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 +115,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 +141,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 +155,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
@@ -150,6 +171,23 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
// -------------------------------------------------------------
for (const file of filesRes) {
try {
await processFile(tenantId, file, result)
} catch (error) {
if (error instanceof FileAlreadyPreparedError) {
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
continue
}
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
}
}
}
return result
}
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
let fileData: Buffer
@@ -158,7 +196,8 @@ 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.`)
continue
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
return
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
@@ -186,7 +225,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" })
return
}
}
}
@@ -195,50 +237,86 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
return
}
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice erstellen
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
// ---------------------------------------------------------
const inserted = await server.db
const newInvoice = await server.db.transaction(async (tx) => {
const [invoice] = await tx
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0]
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
if (!newInvoice) {
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
continue
}
// ---------------------------------------------------------
// 5️⃣ Datei mit incominginvoice-ID verbinden
// ---------------------------------------------------------
await server.db
const linked = await tx
.update(files)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
.set({ incominginvoice: invoice.id })
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
.returning({ id: files.id })
if (!linked.length) throw new FileAlreadyPreparedError()
return invoice
})
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
}
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
const running = new Map<number, Promise<unknown>>()
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
return
}
return {
run: async (tenant:number) => {
await processInvoices(tenant)
const 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)
}
}
const status = (tenant: number): IncomingInvoicePreparationStatus =>
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
const start = (tenant: number): IncomingInvoicePreparationStatus => {
const current = status(tenant)
if (current.running) return current
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
backgroundRuns.set(tenant, next)
run(tenant)
.then((result) => {
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
})
.catch((error) => {
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
backgroundRuns.set(tenant, {
...next,
running: false,
finishedAt: new Date().toISOString(),
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
})
})
return next
}
}
return { run, start, status }
}

View File

@@ -0,0 +1,126 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, incominginvoices } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type AccountCostCentreAssignment = {
index: number
costcentre: string | null
}
export type IncomingInvoiceCostCentreUpdate = {
accountCostCentres?: AccountCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
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
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
const seen = new Set<number>()
return input.map((assignment) => {
const index = assignment?.index
const costcentre = assignment?.costcentre
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
seen.add(index)
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
}
return { index, costcentre }
})
}
export function applyAccountCostCentreAssignments(
accounts: unknown,
assignments: AccountCostCentreAssignment[] = [],
) {
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
for (const { index } of assignments) {
const account = accounts[index]
if (!account || typeof account !== "object" || Array.isArray(account)) {
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
}
}
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
return accounts.map((account, index) => {
if (!assignmentMap.has(index)) return account
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
})
}
export async function updateIncomingInvoiceCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
invoiceId: number,
input: IncomingInvoiceCostCentreUpdate,
) {
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
const [existing] = await server.db
.select()
.from(incominginvoices)
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
const referencedCostCentreIds = [...new Set(
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
)]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const [updated] = await server.db
.update(incominginvoices)
.set({
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.returning()
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "incominginvoices",
entityId: invoiceId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des Eingangsbelegs geändert",
})
return updated
}

View File

@@ -22,6 +22,7 @@ import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { updateIncomingInvoiceCostCentres } from "../modules/incoming-invoice-cost-centres.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
@@ -334,6 +335,26 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
try {
const { id } = req.params as { id: string }
const invoiceId = Number(id)
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
const invoice = await updateIncomingInvoiceCostCentres(
server,
req.user.tenant_id,
req.user.user_id,
invoiceId,
req.body as any,
)
return { invoice }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
}
})
server.post('/functions/services/bankstatementsync', async (req, reply) => {
const result = await server.services.bankStatements.run(req.user.tenant_id);
if (result.errors.length > 0) {
@@ -348,8 +369,11 @@ export default async function functionRoutes(server: FastifyInstance) {
})
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
})
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
server.get('/functions/services/prepareincominginvoices', async (req) => {
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {

View File

@@ -11,6 +11,11 @@ import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { sanitizeFilename } from "../utils/filename"
import multipart from "@fastify/multipart"
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
}
export default async function mcpRoutes(server: FastifyInstance) {
type McpRouteOptions = {
/** Replaceable in tests to avoid object storage. */
storeUpload?: typeof storeMcpFileUpload
}
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
const storeUpload = options.storeUpload || storeMcpFileUpload
server.get("/mcp/tokens", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
@@ -221,6 +232,37 @@ export default async function mcpRoutes(server: FastifyInstance) {
}
})
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
await server.register(async (uploads) => {
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
uploads.post("/mcp/files", async (req, reply) => {
try {
const context = await createMcpContext(server, req)
const query = parseUploadQuery(req.query as Record<string, unknown>)
let body: Buffer
let filename = query.filename
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
if (req.isMultipart()) {
const part = await req.file()
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
body = await part.toBuffer()
filename ||= part.filename
mimeType = part.mimetype
} else if (Buffer.isBuffer(req.body)) {
body = req.body
} else {
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
}
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
return await storeUpload(context, { ...query, filename, body, mimeType })
} catch (error) {
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
}
})
})
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body]

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
applyAccountCostCentreAssignments,
normalizeAccountCostCentreAssignments,
} from "../src/modules/incoming-invoice-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
test("changes only requested account cost centres", () => {
const accounts = [
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
]
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
{ index: 1, costcentre: CENTRE },
]), [
accounts[0],
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
])
})
test("rejects assignments for unknown invoice positions", () => {
assert.throws(
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
/Position 4.*nicht gefunden/,
)
})
test("validates cost centre assignments", () => {
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
assert.throws(
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
/mehrfach/,
)
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
})
test("registers a dedicated incoming invoice cost centre tool", () => {
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
})

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict"
import test from "node:test"
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
function server(tenantLookups: { count: number }) {
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const db: any = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => {
tenantLookups.count += 1
await gate
return []
},
}),
}),
}),
}
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
return { server: { db, log } as any, release: () => release() }
}
test("start runs the preparation in the background and does not start a second run", async () => {
const lookups = { count: 0 }
const { server: srv, release } = server(lookups)
const service = prepareIncomingInvoices(srv)
const first = service.start(7)
const second = service.start(7)
assert.equal(first.running, true)
assert.equal(second.startedAt, first.startedAt)
assert.equal(service.status(8).running, false)
release()
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
const status = service.status(7)
assert.equal(lookups.count, 1)
assert.equal(status.running, false)
assert.ok(status.finishedAt)
assert.equal(status.result?.error, "Mandant nicht gefunden")
})

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createHash } from "node:crypto"
import Fastify from "fastify"
import { drizzle } from "drizzle-orm/pg-proxy"
import mcpRoutes from "../src/routes/mcp"
import authPlugin from "../src/plugins/auth"
import { mcpToolMap } from "../src/mcp/registry"
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
import { secrets } from "../src/utils/secrets"
const id = "00000000-0000-4000-8000-000000000001"
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
const bearer = "fedeo_mcp_test_upload_token"
const headers = { authorization: `Bearer ${bearer}` }
async function app(t: any) {
const uploads: any[] = []
const db = drizzle(async (sql, params) => {
if (sql.includes('from "m2m_api_keys"')) {
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] }
}
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
return { rows: [] }
})
const server = Fastify()
t.after(() => server.close())
server.decorate("db", db as any)
await server.register(authPlugin)
await server.register(mcpRoutes, {
prefix: "/api",
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
})
return { server, uploads }
}
const multipartBody = (filename: string, content: Buffer, field = "file") => {
const boundary = "----fedeo-test"
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
content,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
}
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
const { server, uploads } = await app(t)
const multipart = multipartBody("rechnung.pdf", pdf)
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
assert.equal(first.statusCode, 200, first.body)
assert.deepEqual(first.json(), { stored: true })
assert.equal(uploads[0].tenantId, 7)
assert.equal(uploads[0].target, "incoming_invoice")
assert.equal(uploads[0].filename, "rechnung.pdf")
assert.equal(uploads[0].invoiceId, 5)
assert.equal(uploads[0].prepare, false)
assert.deepEqual(uploads[0].body, pdf)
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
assert.equal(raw.statusCode, 200, raw.body)
assert.equal(uploads[1].target, "inbox")
assert.equal(uploads[1].mimeType, "application/pdf")
assert.deepEqual(uploads[1].body, pdf)
})
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
const { server, uploads } = await app(t)
const raw = { "content-type": "application/pdf" }
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
assert.equal(uploads.length, 0)
})
test("query parsing keeps invoice options to the incoming invoice target", () => {
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
})
test("stored uploads require the permission of the chosen target", async () => {
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
})
test("upload_url tool returns the absolute endpoint with examples", async (t) => {
const apiBase = secrets.API_BASE_URL
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
t.after(() => { secrets.API_BASE_URL = apiBase })
const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
})

View 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"])
})

View File

@@ -730,6 +730,12 @@ const setupPage = async () => {
await router.replace('/createDocument')
return
}
if (route.query.regeneratePdf && route.params.id) {
await regenerateMissingPdf()
return
}
loaded.value = true
}
@@ -1980,8 +1986,24 @@ const closeDocument = async () => {
await saveDocument("Gebucht")
await uploadGeneratedDocument()
guard.value = false
await router.push(`/createDocument/show/${itemInfo.value.id}`)
}
}
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
const uploadGeneratedDocument = async () => {
await generateDocument()
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
let fileData = {}
fileData.project = itemInfo.value.project
@@ -2012,15 +2034,39 @@ const closeDocument = async () => {
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
await useFiles().uploadFiles(fileData, [file])
}
const regenerateMissingPdf = async () => {
guard.value = false
const showPath = `/createDocument/show/${route.params.id}`
await router.push(`/createDocument/show/${itemInfo.value.id}`)
try {
const document = await useEntities("createddocuments").selectSingle(route.params.id, "*,files(*)")
if (document?.state === "Entwurf" || document?.type === "serialInvoices") {
toast.add({ title: "PDF kann nur für fertiggestellte Belege nacherzeugt werden", color: "error" })
} else if ((document?.files || []).length > 0) {
toast.add({ title: "Am Beleg ist bereits ein Dokument angehängt", color: "warning" })
} else {
// Vertrags- und Mandats-Watcher überschreiben beim Laden die Zahlungsdaten, für die PDF gilt der gespeicherte Stand
const restoreStoredPaymentData = () => {
itemInfo.value.payment_type = document.payment_type
itemInfo.value.outgoingsepamandate = normalizeEntityId(document.outgoingsepamandate)
}
await nextTick()
restoreStoredPaymentData()
await nextTick()
restoreStoredPaymentData()
await uploadGeneratedDocument()
toast.add({ title: "PDF nacherzeugt", color: "success" })
}
} catch (error) {
console.error(error)
toast.add({ title: "PDF konnte nicht nacherzeugt werden", description: error?.data?.error || error?.message, color: "error" })
}
await router.replace(showPath)
}
const getTextTemplateByType = (type, pos) => {

View File

@@ -110,10 +110,18 @@ const setupPage = async () => {
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
}
if (itemInfo.value.files?.length > 0) {
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
}
}
const canRegeneratePdf = computed(() => itemInfo.value.id
&& itemInfo.value.state !== 'Entwurf'
&& itemInfo.value.type !== 'serialInvoices'
&& Array.isArray(itemInfo.value.files)
&& itemInfo.value.files.length === 0)
setupPage()
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
@@ -282,6 +290,12 @@ const togglePortalRelease = async () => {
:loading="costCentresLoading"
@click="openCostCentres"
>Kostenstellen bearbeiten</UButton>
<UButton
v-if="canRegeneratePdf"
icon="i-heroicons-document-arrow-down"
variant="outline"
@click="router.push(`/createDocument/edit/${itemInfo.id}?regeneratePdf=1`)"
>PDF nacherzeugen</UButton>
<!-- <UButton
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
target="_blank"

View File

@@ -200,6 +200,47 @@ const getAccountLabel = (item) => {
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
}
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
const canEditCostCentres = computed(() => mode.value === "show" && Boolean(itemInfo.value.id) && !itemInfo.value.archived)
const costCentresOpen = ref(false)
const costCentresSaving = ref(false)
const costCentreDraft = ref([])
const costCentreOptionLabel = (id) => {
const centre = costcentres.value.find((item) => item.id === id)
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
}
const openCostCentres = () => {
costCentreDraft.value = (itemInfo.value.accounts || []).map((account, index) => ({
index,
label: `${index + 1}. ${account.description || getAccountLabel(account)}`,
costcentre: account.costCentre || null,
}))
costCentresOpen.value = true
}
const saveCostCentres = async () => {
if (costCentresSaving.value) return
costCentresSaving.value = true
try {
const accountCostCentres = costCentreDraft.value
.filter((row) => row.costcentre !== (itemInfo.value.accounts[row.index]?.costCentre || null))
.map(({ index, costcentre }) => ({ index, costcentre }))
if (accountCostCentres.length) {
const { invoice } = await useNuxtApp().$api(`/api/functions/incoming-invoices/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: { accountCostCentres },
})
itemInfo.value.accounts = normalizeIncomingInvoiceAccounts(invoice.accounts || [], invoice.date)
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
}
costCentresOpen.value = false
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
} finally {
costCentresSaving.value = false
}
}
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
@@ -362,6 +403,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
>
Bearbeiten
</UButton>
<UButton
v-if="canEditCostCentres"
icon="i-heroicons-tag"
variant="outline"
@click="openCostCentres"
>
Kostenstellen bearbeiten
</UButton>
<ArchiveButton
v-if="canArchiveIncomingInvoice"
color="error"
@@ -1060,6 +1109,27 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</div>
</div>
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
<template #body>
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
<div class="space-y-4">
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
<InputGroup>
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
</InputGroup>
</UFormField>
</div>
</template>
<template #footer>
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
</template>
</UModal>
</template>
<style scoped>

View File

@@ -64,35 +64,89 @@ const setupPage = async () => {
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
}
// Funktion zum Vorbereiten der Belege
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
let preparationPollTimer = null
toast.add({
title: 'Erfolg',
description: 'Eingangsbelege wurden vorbereitet.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Liste neu laden
await setupPage()
} catch (error) {
console.error(error)
const showPreparationResult = (status) => {
const result = status?.result
if (result?.error && !result.created?.length) {
toast.add({
title: 'Fehler',
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
description: result.error,
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} finally {
return
}
const created = result?.created?.length || 0
const skipped = result?.skipped?.length || 0
toast.add({
title: 'Belegvorbereitung abgeschlossen',
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})
}
const pollPreparation = (notify) => {
clearTimeout(preparationPollTimer)
preparationPollTimer = setTimeout(async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
pollPreparation(notify)
return
}
isPreparing.value = false
if (notify) showPreparationResult(status)
await setupPage()
} catch (error) {
console.error(error)
pollPreparation(notify)
}
}, 3000)
}
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api(preparationEndpoint, { method: 'POST' })
toast.add({
title: 'Belegvorbereitung gestartet',
description: 'Die Belege werden im Hintergrund vorbereitet.',
icon: 'i-heroicons-sparkles'
})
pollPreparation(true)
} catch (error) {
console.error(error)
isPreparing.value = false
toast.add({
title: 'Fehler',
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
}
}
const resumePreparationStatus = async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
isPreparing.value = true
pollPreparation(true)
}
} catch (error) {
console.error(error)
}
}
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
setupPage()
resumePreparationStatus()
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {