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 + + + + +