From ebbd1e0cd09c445fd95c3cf459340a3f089cdaec Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 9 Sep 2026 14:37:22 +0200 Subject: [PATCH] feat(accounting): edit finalized document cost centres --- backend/src/mcp/tools/accounting.ts | 67 ++++++++ .../outgoing-document-cost-centres.service.ts | 146 ++++++++++++++++++ backend/src/routes/functions.ts | 21 +++ .../tests/outgoingDocumentCostCentres.test.ts | 33 ++++ frontend/pages/createDocument/edit/[[id]].vue | 36 ++++- 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 backend/src/modules/outgoing-document-cost-centres.service.ts create mode 100644 backend/tests/outgoingDocumentCostCentres.test.ts diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 7067b9c..272507a 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -18,6 +18,7 @@ import { useNextNumberRangeNumber } from "../../utils/functions" 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 { prepareStatementAllocationInput, statementAllocationUuidArg, @@ -633,6 +634,72 @@ export const accountingTools: McpTool[] = [ return { document: updated } }, }, + { + name: "accounting.outgoing_documents.cost_centres.update", + title: "Kostenstellen eines Ausgangsbelegs ändern", + description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.", + requiredPermissions: ["accounting.outgoing_documents.write"], + inputSchema: { + type: "object", + required: ["id"], + anyOf: [ + { required: ["costcentre"] }, + { required: ["rowCostCentres"] }, + ], + properties: { + id: { type: "number" }, + costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." }, + rowCostCentres: { + type: "array", + items: { + type: "object", + required: ["rowId", "costcentre"], + properties: { + rowId: { type: "string", description: "ID der Belegposition." }, + 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 input: { + costcentre?: string | null + rowCostCentres?: Array<{ rowId: string, costcentre: string | null }> + } = {} + if (Object.prototype.hasOwnProperty.call(args, "costcentre")) { + const costcentre = args.costcentre + if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) { + throw new Error("costcentre muss eine gültige UUID oder null sein") + } + input.costcentre = costcentre as string | null + } + if (args.rowCostCentres !== undefined) { + if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein") + input.rowCostCentres = args.rowCostCentres.map((assignment: any) => { + const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : "" + const costcentre = assignment?.costcentre + if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId") + if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) { + throw new Error(`Ungültige Kostenstelle für Position ${rowId}`) + } + return { rowId, costcentre } + }) + } + + const document = await updateOutgoingDocumentCostCentres( + context.server, + context.tenantId, + context.userId, + id, + input, + ) + return { document } + }, + }, { name: "accounting.outgoing_documents.finalize", title: "Ausgangsbeleg finalisieren", diff --git a/backend/src/modules/outgoing-document-cost-centres.service.ts b/backend/src/modules/outgoing-document-cost-centres.service.ts new file mode 100644 index 0000000..cbda832 --- /dev/null +++ b/backend/src/modules/outgoing-document-cost-centres.service.ts @@ -0,0 +1,146 @@ +import { and, eq, inArray } from "drizzle-orm" +import { FastifyInstance } from "fastify" +import { costcentres, createddocuments } from "../../db/schema" +import { insertHistoryItem } from "../utils/history" + +export type RowCostCentreAssignment = { + rowId: string + costcentre: string | null +} + +export type OutgoingDocumentCostCentreUpdate = { + costcentre?: string | null + rowCostCentres?: RowCostCentreAssignment[] +} + +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 applyRowCostCentreAssignments( + rows: unknown, + assignments: RowCostCentreAssignment[] = [], +) { + if (!Array.isArray(rows)) return [] + if (!assignments.length) return rows + + const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre])) + const existingIds = new Set(rows + .filter((row) => row && typeof row === "object" && !Array.isArray(row)) + .map((row) => (row as Record).id) + .filter((id) => id !== null && id !== undefined && id !== "") + .map(String)) + + for (const rowId of assignmentMap.keys()) { + if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`) + } + + return rows.map((row) => { + if (!row || typeof row !== "object" || Array.isArray(row)) return row + const rowRecord = row as Record + if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row + const rowId = String(rowRecord.id) + if (!assignmentMap.has(rowId)) return row + + const { costcentre: _legacyCostCentre, ...rest } = rowRecord + return { ...rest, costCentre: assignmentMap.get(rowId) ?? null } + }) +} + +export async function updateOutgoingDocumentCostCentres( + server: FastifyInstance, + tenantId: number, + userId: string, + documentId: number, + input: OutgoingDocumentCostCentreUpdate, +) { + const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre") + if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) { + throw serviceError("costcentre muss eine gültige UUID oder null sein") + } + if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) { + throw serviceError("rowCostCentres muss ein Array sein") + } + + const rowAssignments = (input.rowCostCentres || []).map((assignment) => { + const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : "" + const costcentre = assignment?.costcentre + if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId") + if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) { + throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`) + } + return { rowId, costcentre } + }) + if (!hasDocumentCostCentre && !rowAssignments.length) { + throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich") + } + + const [existing] = await server.db + .select() + .from(createddocuments) + .where(and( + eq(createddocuments.id, documentId), + eq(createddocuments.tenant, tenantId), + eq(createddocuments.archived, false), + )) + .limit(1) + + if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404) + if (existing.state !== "Gebucht") { + throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden") + } + + const referencedCostCentreIds = [...new Set([ + ...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []), + ...rowAssignments.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 updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments) + const update: Record = { + rows: updatedRows, + updatedAt: new Date(), + updatedBy: userId, + } + if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null + + const [updated] = await server.db + .update(createddocuments) + .set(update) + .where(and( + eq(createddocuments.id, documentId), + eq(createddocuments.tenant, tenantId), + eq(createddocuments.archived, false), + eq(createddocuments.state, "Gebucht"), + )) + .returning() + + if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404) + + await insertHistoryItem(server, { + tenant_id: tenantId, + created_by: userId, + entity: "createddocuments", + entityId: documentId, + action: "updated", + oldVal: existing, + newVal: updated, + text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert", + }) + + return updated +} diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 4d65184..49e38bb 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {citys, files} from "../../db/schema"; import {and, eq, isNull, not} from "drizzle-orm"; import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; +import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; import { storeExtractedTextForFile } from "../utils/documentText"; @@ -306,6 +307,26 @@ export default async function functionRoutes(server: FastifyInstance) { return finishManualGeneration(server, execution_id, req.user.tenant_id) }) + server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => { + try { + const { id } = req.params as { id: string } + const documentId = Number(id) + if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" }) + + const document = await updateOutgoingDocumentCostCentres( + server, + req.user.tenant_id, + req.user.user_id, + documentId, + req.body as any, + ) + return { document } + } 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/outgoingDocumentCostCentres.test.ts b/backend/tests/outgoingDocumentCostCentres.test.ts new file mode 100644 index 0000000..de3cd27 --- /dev/null +++ b/backend/tests/outgoingDocumentCostCentres.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service" +import { mcpToolMap } from "../src/mcp/registry" + +test("changes only requested position cost centres", () => { + const rows = [ + { id: "row-1", description: "Montage", price: 100, costCentre: "old-1" }, + { id: "row-2", description: "Material", price: 50, costCentre: "old-2" }, + ] + + assert.deepEqual(applyRowCostCentreAssignments(rows, [ + { rowId: "row-1", costcentre: "new-1" }, + ]), [ + { id: "row-1", description: "Montage", price: 100, costCentre: "new-1" }, + rows[1], + ]) +}) + +test("rejects assignments for unknown document positions", () => { + assert.throws( + () => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]), + /Position missing.*nicht gefunden/, + ) +}) + +test("registers a dedicated finalized-document cost centre tool", () => { + const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update") + + assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"]) + assert.deepEqual(tool?.inputSchema.required, ["id"]) + assert.ok(tool?.inputSchema.anyOf) +}) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 7dccaf2..a9c20f3 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -1896,6 +1896,31 @@ const saveDocument = async (state, resetup = false) => { if (resetup) await setupPage() } +const saveFinalizedCostCentres = async () => { + const rowCostCentres = itemInfo.value.rows + .filter(row => row?.id) + .map(row => ({ + rowId: String(row.id), + costcentre: row.costCentre || null, + })) + + const result = await $api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, { + method: "PUT", + body: { + costcentre: itemInfo.value.costcentre || null, + rowCostCentres, + }, + }) + + itemInfo.value.costcentre = result.document.costcentre + itemInfo.value.rows = normalizeCreatedDocumentRows(result.document.rows) + toast.add({ + title: "Kostenstellen gespeichert", + description: "Die Kostenstellen des fertiggestellten Belegs wurden aktualisiert.", + color: "success", + }) +} + const selectedTab = ref("0") const closeDocument = async () => { @@ -2077,7 +2102,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = { Speichern @@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = { {{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}} + + Kostenstellen speichern +