From 0777315d1dbe33ae0db8cd6720be841ba104d278 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 14:28:05 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat(mcp):=20Archivierte=20Eintr=C3=A4ge=20?= =?UTF-8?q?wiederherstellen=20und=20Kontost=C3=A4nde=20abfragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag - accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag Co-Authored-By: Claude Opus 5.5 --- backend/src/mcp/registry.ts | 2 + backend/src/mcp/tools/accounting.ts | 22 +++ backend/src/mcp/tools/archive.ts | 150 +++++++++++++++++++ backend/src/mcp/types.ts | 2 + backend/src/modules/bank-balances.service.ts | 71 +++++++++ backend/src/routes/banking.ts | 3 +- backend/src/routes/mcp.ts | 2 +- backend/src/utils/history.ts | 3 +- backend/tests/bankBalances.test.ts | 24 +++ backend/tests/mcpArchiveRestore.test.ts | 99 ++++++++++++ backend/tests/mcpPagination.test.ts | 5 +- 11 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 backend/src/mcp/tools/archive.ts create mode 100644 backend/src/modules/bank-balances.service.ts create mode 100644 backend/tests/bankBalances.test.ts create mode 100644 backend/tests/mcpArchiveRestore.test.ts diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index f4fdfbb5..fabd6f95 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -3,6 +3,7 @@ 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" export const mcpTools = [ ...accountingTools, @@ -10,6 +11,7 @@ export const mcpTools = [ ...organisationTools, ...wikiTools, ...documentTools, + ...archiveTools, ] 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..308520d8 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -22,6 +22,8 @@ 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 { loadBankBalances } from "../../modules/bank-balances.service" +import { dateField } from "../masterdataFields" import { prepareStatementAllocationInput, statementAllocationUuidArg, @@ -1310,6 +1312,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/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/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/mcp.ts b/backend/src/routes/mcp.ts index ac55fa38..8fcf6a99 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -270,7 +270,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/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/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) From c6fd53307c8e0e7ed497bc1a4ae5d23e42e8606a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 16:03:36 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(mcp):=20Belegvorbereitung=20f=C3=BCr?= =?UTF-8?q?=20Eingangsrechnungen=20ansto=C3=9Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/mcp/tools/accounting.ts | 58 ++++++++++++++++++- .../modules/cron/prepareIncomingInvoices.ts | 49 ++++++++++++---- .../mcpIncomingInvoicePreparation.test.ts | 41 +++++++++++++ 3 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 backend/tests/mcpIncomingInvoicePreparation.test.ts diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 308520d8..2eb116f1 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -1,6 +1,6 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" -import { and, desc, eq, ilike, or } from "drizzle-orm" +import { and, desc, eq, ilike, inArray, or } from "drizzle-orm" import { randomUUID } from "node:crypto" import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { @@ -1154,6 +1154,62 @@ export const accountingTools: McpTool[] = [ return validateIncomingInvoiceData(rows[0] as Record) }, }, + { + name: "accounting.incoming_invoices.prepare", + title: "Belegvorbereitung starten", + description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.", + requiredPermissions: ["accounting.incoming_invoices.write"], + inputSchema: { + type: "object", + properties: { + fileIds: { + type: "array", + items: { type: "string", format: "uuid" }, + minItems: 1, + maxItems: 50, + description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.", + }, + }, + additionalProperties: false, + }, + async handler(context, args) { + let fileIds: string[] | undefined + if (args.fileIds !== undefined) { + if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) { + throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten") + } + if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) { + throw new Error("fileIds darf nur gültige UUIDs enthalten") + } + fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))] + } + + const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds }) + if (result.error && !result.filesFound) throw new Error(result.error) + + const invoiceIds = result.created.map((entry) => entry.invoiceId) + const invoices = invoiceIds.length + ? await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds))) + : [] + const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId)) + + return { + filesFound: result.filesFound, + created: result.created.map((entry) => { + const invoice = invoices.find((row) => row.id === entry.invoiceId) || null + return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record) : null } + }), + skipped: [ + ...result.skipped, + ...(fileIds || []).filter((id) => !handled.has(id)) + .map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })), + ], + } + }, + }, { name: "accounting.incoming_invoices.create", title: "Eingangsbeleg erstellen", diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index bb1ef671..76212242 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -17,7 +17,7 @@ import { incominginvoices, } from "../../../db/schema" -import { eq, and, isNull, not } from "drizzle-orm" +import { eq, and, inArray, isNull, not } from "drizzle-orm" const formatInvoiceItemDescription = (item: any) => { const parts = [ @@ -86,8 +86,16 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { return itemInfo } +export type IncomingInvoicePreparationResult = { + filesFound: number + created: Array<{ fileId: string, invoiceId: number, source: string | null }> + skipped: Array<{ fileId: string, reason: string }> + error?: string +} + export function prepareIncomingInvoices(server: FastifyInstance) { - const processInvoices = async (tenantId:number) => { + const processInvoices = async (tenantId:number, fileIds?: string[]) => { + const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] } console.log("▶ Starting Incoming Invoice Preparation") const tenantsRes = await server.db @@ -98,7 +106,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!tenantsRes.length) { console.log("No tenants with autoPrepareIncomingInvoices = true") - return + return { ...result, error: "Mandant nicht gefunden" } } console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`) @@ -124,6 +132,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { const invoiceFileTag = tagRes?.[0]?.id if (!invoiceFileTag) { server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`) + result.error = "Dateityp für Eingangsrechnungen fehlt" continue } @@ -137,10 +146,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) { eq(files.type, invoiceFileTag), isNull(files.incominginvoice), eq(files.archived, false), - not(isNull(files.path)) + not(isNull(files.path)), + ...(fileIds ? [inArray(files.id, fileIds)] : []) ) ) + result.filesFound += filesRes.length + if (!filesRes.length) { console.log(`No invoice files for tenant ${tenantId}`) continue @@ -158,6 +170,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { fileData = await loadFileBuffer(file.path!) } catch (error) { server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) + result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" }) continue } @@ -186,7 +199,10 @@ export function prepareIncomingInvoices(server: FastifyInstance) { } catch (error) { server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) - if (electronicInvoice.container === "xml") continue + if (electronicInvoice.container === "xml") { + result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" }) + continue + } } } @@ -195,6 +211,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!data) { server.log.warn(`GPT returned no data for file ${file.id}`) + result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" }) continue } @@ -213,6 +230,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { if (!newInvoice) { server.log.error(`Failed to insert incoming invoice for file ${file.id}`) + result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" }) continue } @@ -224,20 +242,31 @@ export function prepareIncomingInvoices(server: FastifyInstance) { .set({ incominginvoice: newInvoice.id }) .where(eq(files.id, file.id)) + result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null }) console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`) } } - return + return result } - return { - run: async (tenant:number) => { - await processInvoices(tenant) - console.log("Incoming Invoice Preparation Completed.") + // Serialize runs per tenant so concurrent triggers cannot prepare the same file twice. + const running = new Map>() + return { + run: async (tenant:number, options: { fileIds?: string[] } = {}) => { + const previous = running.get(tenant) || Promise.resolve() + const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds)) + running.set(tenant, current) + try { + const result = await current + console.log("Incoming Invoice Preparation Completed.") + return result + } finally { + if (running.get(tenant) === current) running.delete(tenant) + } } } diff --git a/backend/tests/mcpIncomingInvoicePreparation.test.ts b/backend/tests/mcpIncomingInvoicePreparation.test.ts new file mode 100644 index 00000000..adefd480 --- /dev/null +++ b/backend/tests/mcpIncomingInvoicePreparation.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mcpToolMap } from "../src/mcp/registry" + +const fileA = "00000000-0000-4000-8000-00000000000a" +const fileB = "00000000-0000-4000-8000-00000000000b" +const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")! + +function context(result: any, invoices: any[] = []) { + const calls: any[] = [] + const db: any = { + select: () => ({ from: () => ({ where: async () => invoices }) }), + } + const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } } + return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any } +} + +test("prepare runs for the active tenant and returns created drafts with validation", async () => { + const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false } + const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice]) + const result: any = await tool.handler(ctx, {}) + assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }]) + assert.equal(result.created[0].invoice.id, 11) + assert.ok(result.created[0].validation) + assert.deepEqual(result.skipped, []) +}) + +test("prepare limits to given files and reports files it could not handle", async () => { + const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] }) + const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] }) + assert.deepEqual(calls[0].options.fileIds, [fileA, fileB]) + assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB]) +}) + +test("prepare validates input and surfaces tenant configuration errors", async () => { + const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" }) + await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/) + await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/) + await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/) + assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"]) +}) From 755f1b7e0d24d5ed3c7bc3b00e0b85622c141d33 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 18:50:36 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(accounting):=20Kostenstellen=20von=20E?= =?UTF-8?q?ingangsbelegen=20jederzeit=20nachtr=C3=A4glich=20=C3=A4ndern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/mcp/tools/accounting.ts | 39 ++++++ .../incoming-invoice-cost-centres.service.ts | 126 ++++++++++++++++++ backend/src/routes/functions.ts | 21 +++ .../tests/incomingInvoiceCostCentres.test.ts | 48 +++++++ .../pages/incomingInvoices/[mode]/[id].vue | 70 ++++++++++ 5 files changed, 304 insertions(+) create mode 100644 backend/src/modules/incoming-invoice-cost-centres.service.ts create mode 100644 backend/tests/incomingInvoiceCostCentres.test.ts diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 2eb116f1..3435f95e 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -22,6 +22,7 @@ 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 { @@ -1287,6 +1288,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", 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/functions.ts b/backend/src/routes/functions.ts index 1944ee20..f2beffe9 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) { 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/frontend/pages/incomingInvoices/[mode]/[id].vue b/frontend/pages/incomingInvoices/[mode]/[id].vue index 38ef5931..ffffe820 100644 --- a/frontend/pages/incomingInvoices/[mode]/[id].vue +++ b/frontend/pages/incomingInvoices/[mode]/[id].vue @@ -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 + + Kostenstellen bearbeiten + blockingIncomingInvoiceE + + + + +