diff --git a/backend/src/mcp/fileUploads.ts b/backend/src/mcp/fileUploads.ts new file mode 100644 index 00000000..01b46b1e --- /dev/null +++ b/backend/src/mcp/fileUploads.ts @@ -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) { + 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 ", + 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"`, + ], + } + }, +} diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index f4fdfbb5..181b8051 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -3,6 +3,8 @@ import { masterdataTools } from "./tools/masterdata" 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, @@ -10,6 +12,8 @@ export const mcpTools = [ ...organisationTools, ...wikiTools, ...documentTools, + ...archiveTools, + uploadUrlTool, ] export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 40f51baa..f220a6d1 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 { @@ -22,11 +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" @@ -309,6 +312,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: } } +export type IncomingInvoiceFileUpload = { + filename: string + buffer: Buffer + mimeType: string + invoiceId?: number | null + invoiceArgs?: Record | 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) : null, + prepared: Boolean(shouldPrepare && linkedInvoice), + } +} + const buildIncomingInvoicePayload = ( args: Record, userId: string, @@ -1011,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", @@ -1047,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 | 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, - { - 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) : null, - prepared: Boolean(shouldPrepare && linkedInvoice), - } + return uploadIncomingInvoiceFile(context, { + filename, + buffer: base64BufferArg(args, "contentBase64"), + mimeType: stringArg(args, "mimeType") || "application/octet-stream", + invoiceId: numberArg(args, "invoiceId"), + invoiceArgs: optionalObjectArg(args, "invoice") as Record | null, + prepare: args.prepare !== false, + }) }, }, { @@ -1152,6 +1178,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", @@ -1229,6 +1311,44 @@ export const accountingTools: McpTool[] = [ return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record) } }, }, + { + 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", @@ -1310,6 +1430,26 @@ export const accountingTools: McpTool[] = [ return { invoice: updated } }, }, + { + name: "accounting.bank_accounts.balances", + title: "Kontostände abfragen", + description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.", + requiredPermissions: ["accounting.bank.read"], + inputSchema: { + type: "object", + properties: { + account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." }, + asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." }, + }, + }, + async handler(context, args) { + const account = numberArg(args, "account") + const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf") + const result = await loadBankBalances(context.server, context.tenantId, { account, asOf }) + if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden") + return result + }, + }, { name: "accounting.bank_statements.list", title: "Bankumsätze auflisten", diff --git a/backend/src/mcp/tools/archive.ts b/backend/src/mcp/tools/archive.ts new file mode 100644 index 00000000..e2c0d9ac --- /dev/null +++ b/backend/src/mcp/tools/archive.ts @@ -0,0 +1,150 @@ +import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm" +import * as tables from "../../../db/schema" +import { assertToolPermission } from "../authz" +import { assertInternalDocumentAccess } from "../documentFiles" +import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields" +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" +import { McpContext, McpTool } from "../types" +import { insertHistoryItem } from "../../utils/history" +import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service" + +type ArchiveEntity = { + label: string + table: any + /** Key used by the history log and the generic resource routes. */ + resource: string + permission: string + searchColumns: string[] + uuid?: boolean + internalDocuments?: boolean +} + +const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity => + ({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid }) + +/** Every entity the MCP can archive; restore checks the matching write permission. */ +export const archiveEntities: Record = { + customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]), + vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]), + contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]), + products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]), + services: masterdata("Leistung", tables.services, "services", "services", ["name"]), + cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true), + branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]), + teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]), + vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]), + inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]), + projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] }, + events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] }, + tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] }, + outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] }, + incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] }, + files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true }, +} + +const entityField: InputField = { + schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." }, + parse(value, path) { + if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`) + return value + }, +} +const entityIdField: InputField = { + schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." }, + parse: (value) => value, +} +const searchField: InputField = { + schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." }, + parse(value, path) { + const text = textField().parse(value, path) + if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`) + return text + }, +} +const listInput = objectField({ + entity: entityField, + search: searchField, + limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit }, + offset: { ...numericField(true, 0), schema: paginationProperties.offset }, +}) +const restoreInput = objectField({ entity: entityField, id: entityIdField }) + +const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table)) + +/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */ +const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record) => row + +async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") { + assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] }) + if (entity.internalDocuments) await assertInternalDocumentAccess(context) +} + +export function prepareArchiveRestoreInput(args: Record) { + const input = restoreInput.parse(args, "arguments") + if (!input.entity) throw new Error("entity ist erforderlich") + if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich") + const entity = archiveEntities[input.entity] + return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") } +} + +const listTool: McpTool = { + name: "archive.entities.list", + title: "Archivierte Einträge auflisten", + description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.", + // Checked per entity in the handler. + requiredPermissions: [], + inputSchema: { ...listInput.schema, required: ["entity"] }, + async handler(context, args) { + const input = listInput.parse(args, "arguments") + if (!input.entity) throw new Error("entity ist erforderlich") + const entity = archiveEntities[input.entity] + await authorize(context, listTool, entity, "read") + const table = entity.table + const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)] + if (input.search) { + const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%` + conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern)))) + } + const updatedAt = columnKey(table, "updatedAt", "updated_at")! + const rows = await context.server.db.select().from(table).where(and(...conditions)) + .orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input)) + const result = paginatedResult(rows, input) + return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) } + }, +} + +const restoreTool: McpTool = { + name: "archive.entities.restore", + title: "Archivierten Eintrag wiederherstellen", + description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`, + requiredPermissions: [], + readOnly: false, + inputSchema: { ...restoreInput.schema, required: ["entity", "id"] }, + async handler(context, args) { + const { key, entity, id } = prepareArchiveRestoreInput(args) + await authorize(context, restoreTool, entity, "write") + const table = entity.table + return context.server.db.transaction(async (tx) => { + const server = { ...context.server, db: tx } as unknown as McpContext["server"] + const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId)) + const [existing] = await tx.select().from(table).where(scope).limit(1).for("update") + if (!existing) throw new Error(`${entity.label} nicht gefunden`) + if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`) + const [result] = await tx.update(table).set({ + archived: false, + [columnKey(table, "updatedAt", "updated_at")!]: new Date(), + [columnKey(table, "updatedBy", "updated_by")!]: context.userId, + }).where(scope).returning() + if (["products", "services"].includes(entity.resource)) { + await recalculateServicePricesForTenant(server, context.tenantId, context.userId) + } + await insertHistoryItem(server, { + tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id, + action: "restored", oldVal: existing, newVal: result, + }) + return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) } + }) + }, +} + +export const archiveTools: McpTool[] = [listTool, restoreTool] diff --git a/backend/src/mcp/tools/documents.ts b/backend/src/mcp/tools/documents.ts index 4204ab91..ba86e99b 100644 --- a/backend/src/mcp/tools/documents.ts +++ b/backend/src/mcp/tools/documents.ts @@ -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) diff --git a/backend/src/mcp/types.ts b/backend/src/mcp/types.ts index c4430de7..00738ccc 100644 --- a/backend/src/mcp/types.ts +++ b/backend/src/mcp/types.ts @@ -23,6 +23,8 @@ export type McpTool = { title: string description: string requiredPermissions: string[] + /** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */ + readOnly?: boolean inputSchema: Record handler: (context: McpContext, args: Record) => Promise } diff --git a/backend/src/modules/bank-balances.service.ts b/backend/src/modules/bank-balances.service.ts new file mode 100644 index 00000000..456b2247 --- /dev/null +++ b/backend/src/modules/bank-balances.service.ts @@ -0,0 +1,71 @@ +import { FastifyInstance } from "fastify" +import { and, eq, sql } from "drizzle-orm" +import { bankaccounts, bankstatements } from "../../db/schema" + +export const CASHBOOK_BANK_ID = "fedeo-cashbook" + +type BalanceAccount = Pick + +const roundMoney = (value: number) => Math.round(value * 100) / 100 + +/** + * Bank accounts store the last synced booked balance; cashbooks store their opening balance + * and derive the current balance from their bookings. For a past date, bank balances are + * reconstructed by reverting the statements booked after that date. + */ +export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) { + const cashbook = account.bankId === CASHBOOK_BANK_ID + const stored = Number(account.balance || 0) + const balance = cashbook ? stored + statementSum : stored - statementSum + return { + id: account.id, + type: cashbook ? "cashbook" : "bank", + name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"), + iban: cashbook ? null : account.iban, + datevNumber: account.datevNumber, + expired: account.expired, + balance: roundMoney(balance), + currency: "EUR", + ...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }), + source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync", + } +} + +export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) { + const asOf = options.asOf || null + const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)] + if (options.account) conditions.push(eq(bankaccounts.id, options.account)) + const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id) + + const sums = async (dateCondition: any) => { + const rows = await server.db + .select({ account: bankstatements.account, total: sql`coalesce(sum(${bankstatements.amount}), 0)` }) + .from(bankstatements) + .where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : []))) + .groupBy(bankstatements.account) + return new Map(rows.map((row) => [row.account, Number(row.total)])) + } + // Synced statements may carry a full timestamp; compare the calendar day only. + const bookingDay = sql`left(${bankstatements.date}, 10)` + // Cashbooks need bookings up to the date, bank accounts only those after it. + const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null) + const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map() + + const balances = accounts.map((account) => calculateAccountBalance( + account, + (account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0, + asOf, + )) + const active = balances.filter((account) => !account.expired) + return { + asOf, + accounts: balances, + totals: { + balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)), + bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)), + cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)), + currency: "EUR", + note: "Summen ohne abgelaufene Bankverbindungen.", + }, + } +} diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index bb1ef671..493b3a0b 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,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,95 +171,152 @@ export function prepareIncomingInvoices(server: FastifyInstance) { // 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden // ------------------------------------------------------------- for (const file of filesRes) { - console.log(`Processing file ${file.id} for tenant ${tenantId}`) - - let fileData: Buffer - try { - fileData = await loadFileBuffer(file.path!) + await processFile(tenantId, file, result) } catch (error) { - server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) - continue - } - - const electronicInvoice = await detectElectronicInvoice(fileData, file) - - let itemInfo: any = null - - if (electronicInvoice) { - server.log.info({ - fileId: file.id, - container: electronicInvoice.container, - syntax: electronicInvoice.syntax, - attachmentName: electronicInvoice.attachmentName, - }, "Strukturierte E-Rechnung erkannt.") - - try { - const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax) - const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice) - itemInfo = mapElectronicInvoiceToIncomingInvoice( - parsedInvoice, - tenantId, - vendor?.id || null, - electronicInvoice.container, - electronicInvoice.attachmentName, - ) - } catch (error) { - server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) - - if (electronicInvoice.container === "xml") continue - } - } - - if (!itemInfo) { - const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) - - if (!data) { - server.log.warn(`GPT returned no data for file ${file.id}`) + if (error instanceof FileAlreadyPreparedError) { + result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" }) continue } - - itemInfo = mapGptDataToIncomingInvoice(data, tenantId) + server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`) + result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" }) } + } + } - // --------------------------------------------------------- - // 4️⃣ IncomingInvoice erstellen - // --------------------------------------------------------- - const inserted = await server.db - .insert(incominginvoices) - .values(itemInfo) - .returning() + return result + } - const newInvoice = inserted?.[0] + const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => { + console.log(`Processing file ${file.id} for tenant ${tenantId}`) - if (!newInvoice) { - server.log.error(`Failed to insert incoming invoice for file ${file.id}`) - continue + let fileData: Buffer + + try { + 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" }) + return + } + + const electronicInvoice = await detectElectronicInvoice(fileData, file) + + let itemInfo: any = null + + if (electronicInvoice) { + server.log.info({ + fileId: file.id, + container: electronicInvoice.container, + syntax: electronicInvoice.syntax, + attachmentName: electronicInvoice.attachmentName, + }, "Strukturierte E-Rechnung erkannt.") + + try { + const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax) + const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice) + itemInfo = mapElectronicInvoiceToIncomingInvoice( + parsedInvoice, + tenantId, + vendor?.id || null, + electronicInvoice.container, + electronicInvoice.attachmentName, + ) + } catch (error) { + server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) + + if (electronicInvoice.container === "xml") { + result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" }) + return } + } + } - // --------------------------------------------------------- - // 5️⃣ Datei mit incominginvoice-ID verbinden - // --------------------------------------------------------- - await server.db - .update(files) - .set({ incominginvoice: newInvoice.id }) - .where(eq(files.id, file.id)) + if (!itemInfo) { + const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) - console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`) + if (!data) { + server.log.warn(`GPT returned no data for file ${file.id}`) + result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" }) + return } + itemInfo = mapGptDataToIncomingInvoice(data, tenantId) } + // --------------------------------------------------------- + // 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 newInvoice = await server.db.transaction(async (tx) => { + const [invoice] = await tx + .insert(incominginvoices) + .values(itemInfo) + .returning() - return + if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden") + + const linked = await tx + .update(files) + .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}`) } - return { - run: async (tenant:number) => { - await processInvoices(tenant) + // Serialize runs per tenant so concurrent triggers cannot prepare the same file twice. + const running = new Map>() + const backgroundRuns = new Map() + + 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 } + } diff --git a/backend/src/modules/incoming-invoice-cost-centres.service.ts b/backend/src/modules/incoming-invoice-cost-centres.service.ts new file mode 100644 index 00000000..ea49c8ee --- /dev/null +++ b/backend/src/modules/incoming-invoice-cost-centres.service.ts @@ -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() + 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 + 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 +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 92349ebd..8de6a5bb 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { centralServicesClient } from "../modules/push-server.client" import { findBankInstitutionByBic } from "../modules/banking-institution" import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" +import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service" import { bankrequisitions, @@ -33,9 +34,7 @@ import { desc, } from "drizzle-orm" - export default async function bankingRoutes(server: FastifyInstance) { - const CASHBOOK_BANK_ID = "fedeo-cashbook" const ContraAccounts = aliasedTable(accounts, "contra_accounts") const ContraCustomers = aliasedTable(customers, "contra_customers") const ContraVendors = aliasedTable(vendors, "contra_vendors") diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 1944ee20..8f6d96b3 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -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) => { diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index ac55fa38..21f3dc5c 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -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) + 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] @@ -270,7 +312,7 @@ export default async function mcpRoutes(server: FastifyInstance) { description: tool.description, inputSchema: tool.inputSchema, annotations: { - readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), + readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), }, })), })) diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index a8c6d6ce..19b7e3d3 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -50,7 +50,7 @@ export async function insertHistoryItem( created_by: string | null entity: string entityId: string | number - action: "created" | "updated" | "unchanged" | "deleted" | "archived" + action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored" oldVal?: any newVal?: any text?: string @@ -62,6 +62,7 @@ export async function insertHistoryItem( updated: `Eintrag in ${entityLabel} geändert`, unchanged: `Eintrag in ${entityLabel} unverändert`, archived: `Eintrag in ${entityLabel} archiviert`, + restored: `Eintrag in ${entityLabel} wiederhergestellt`, deleted: `Eintrag in ${entityLabel} gelöscht` } diff --git a/backend/tests/bankBalances.test.ts b/backend/tests/bankBalances.test.ts new file mode 100644 index 00000000..7ce5dd54 --- /dev/null +++ b/backend/tests/bankBalances.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service" + +const syncedAt = new Date("2026-10-20T08:00:00Z") +const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt } +const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 } + +test("bank accounts report the synced balance and revert later statements for a past date", () => { + assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1) + assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync") + const past = calculateAccountBalance(bank, -200.05, "2026-10-01") + assert.equal(past.balance, 1200.15) + assert.equal(past.syncedBalance, 1000.1) + assert.equal(past.source, "bank_sync_reconstructed") +}) + +test("cashbooks add their bookings to the opening balance", () => { + const result = calculateAccountBalance(cashbook, 12.345, null) + assert.equal(result.type, "cashbook") + assert.equal(result.balance, 62.35) + assert.equal(result.openingBalance, 50) + assert.equal(result.iban, null) +}) diff --git a/backend/tests/incomingInvoiceCostCentres.test.ts b/backend/tests/incomingInvoiceCostCentres.test.ts new file mode 100644 index 00000000..124547e4 --- /dev/null +++ b/backend/tests/incomingInvoiceCostCentres.test.ts @@ -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"]) +}) diff --git a/backend/tests/incomingInvoicePreparation.test.ts b/backend/tests/incomingInvoicePreparation.test.ts new file mode 100644 index 00000000..a5aa217e --- /dev/null +++ b/backend/tests/incomingInvoicePreparation.test.ts @@ -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((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") +}) diff --git a/backend/tests/mcpArchiveRestore.test.ts b/backend/tests/mcpArchiveRestore.test.ts new file mode 100644 index 00000000..79f9f859 --- /dev/null +++ b/backend/tests/mcpArchiveRestore.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getTableColumns, getTableName } from "drizzle-orm" +import { PgDialect } from "drizzle-orm/pg-core" +import * as schema from "../db/schema" +import { mcpToolMap } from "../src/mcp/registry" +import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive" +import { asToolResult } from "../src/mcp/result" + +const tenantId = 7 +const userId = "00000000-0000-4000-8000-000000000001" +const uuid = "00000000-0000-4000-8000-000000000002" +const dialect = new PgDialect() + +/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */ +function fixture(initial: Record = {}, listRows: any[] = []) { + const state = structuredClone(initial) + const rowsFor = (table: any) => state[getTableName(table)] ||= [] + const matches = (table: any, where: any, row: any) => { + const query = dialect.sqlToQuery(where) + const columns = getTableColumns(table) as Record + const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)! + return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)] + .every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1]) + } + const listed: string[] = [] + const db: any = { + transaction: async (callback: any) => callback(db), + select: () => ({ from: (table: any) => { + let condition: any + const query: any = { + where: (where: any) => { condition = where; return query }, + orderBy: () => query, + offset: () => query, + limit: () => query, + for: () => query, + then: (resolve: any, reject: any) => Promise.resolve().then(() => { + if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows } + return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row })) + }).then(resolve, reject), + } + return query + } }), + insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }), + update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ + returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })), + }) }) }), + } + const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] } + return { context, rows: rowsFor, listed } +} +const call = async (context: any, name: string, args: Record) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any + +for (const [key, entity] of Object.entries(archiveEntities)) { + test(`${key}: restore unarchives within the tenant and records history`, async () => { + const id = entity.uuid ? uuid : 1 + const f = fixture({ + [getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }], + // Restoring documents requires an internal (non-portal) profile. + auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }], + }) + const result = await call(f.context, "restore", { entity: key, id }) + assert.deepEqual(asToolResult(result).structuredContent?.success, true) + assert.equal(result.record.archived, undefined) + const [row] = f.rows(entity.table) + assert.equal(row.archived, false) + assert.equal(row.updatedBy ?? row.updated_by, userId) + await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/) + }) +} + +test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => { + const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] }) + await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/) + assert.equal(f.rows(schema.customers)[0].archived, true) + await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/) + assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/) + assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/) + f.context.isAdmin = false + f.context.permissions = ["organisation.tasks.read"] + await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/) + f.context.permissions.push("organisation.tasks.write") + await call(f.context, "restore", { entity: "tasks", id: 2 }) + assert.equal(f.rows(schema.tasks)[0].archived, false) +}) + +test("list returns archived rows visibly and requires the read permission", async () => { + const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }]) + const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" })) + assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }]) + assert.match(f.listed[0], /"customers"\."archived" = \$2/) + f.context.isAdmin = false + f.context.permissions = [] + await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/) +}) + +test("restore is advertised as a write tool", () => { + assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false) +}) diff --git a/backend/tests/mcpFileUploads.test.ts b/backend/tests/mcpFileUploads.test.ts new file mode 100644 index 00000000..eb1c1250 --- /dev/null +++ b/backend/tests/mcpFileUploads.test.ts @@ -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: "

" })).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"/) +}) 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"]) +}) diff --git a/backend/tests/mcpPagination.test.ts b/backend/tests/mcpPagination.test.ts index fb43766c..98d94b74 100644 --- a/backend/tests/mcpPagination.test.ts +++ b/backend/tests/mcpPagination.test.ts @@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any return values }) } }, { schema }) - const context = { server: { db }, tenantId: 7, userId: "test" } as any + const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any + const properties = tool.inputSchema.properties as any const ids: unknown[] = [] let offset = 0 for (let page = 0; page < 3; page++) { - const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset }) + const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset }) assert.equal(result.rows.length, page < 2 ? 100 : 5) assert.equal(result.pagination.hasMore, page < 2) assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 70d2576b..d852c043 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -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,38 +1986,7 @@ const closeDocument = async () => { await saveDocument("Gebucht") - await generateDocument() - - let fileData = {} - - fileData.project = itemInfo.value.project - fileData.createddocument = itemInfo.value.id - - let mappedType = documentStorageFallbackTypes[itemInfo.value.type] || itemInfo.value.type - - const folders = await useEntities("folders").select() - console.log(folders) - fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id - - const tags = await useEntities("filetags").select() - console.log(tags) - fileData.type = tags.find(i => i.createdDocumentType === mappedType).id - - function dataURLtoFile(dataurl, filename) { - var arr = dataurl.split(","), - mime = arr[0].match(/:(.*?);/)[1], - bstr = atob(arr[arr.length - 1]), - n = bstr.length, - u8arr = new Uint8Array(n); - while (n--) { - u8arr[n] = bstr.charCodeAt(n); - } - return new File([u8arr], filename, {type: mime}); - } - - let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`) - - await useFiles().uploadFiles(fileData, [file]) + await uploadGeneratedDocument() guard.value = false @@ -2021,6 +1996,77 @@ const closeDocument = async () => { +} + +// 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 + fileData.createddocument = itemInfo.value.id + + let mappedType = documentStorageFallbackTypes[itemInfo.value.type] || itemInfo.value.type + + const folders = await useEntities("folders").select() + console.log(folders) + fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id + + const tags = await useEntities("filetags").select() + console.log(tags) + fileData.type = tags.find(i => i.createdDocumentType === mappedType).id + + function dataURLtoFile(dataurl, filename) { + var arr = dataurl.split(","), + mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[arr.length - 1]), + n = bstr.length, + u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, {type: mime}); + } + + 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}` + + 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) => { diff --git a/frontend/pages/createDocument/show/[id].vue b/frontend/pages/createDocument/show/[id].vue index 5f6af822..338b5bf3 100644 --- a/frontend/pages/createDocument/show/[id].vue +++ b/frontend/pages/createDocument/show/[id].vue @@ -110,10 +110,18 @@ const setupPage = async () => { links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id) } - linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].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 + PDF nacherzeugen