feat(accounting): edit finalized document cost centres

This commit is contained in:
2026-09-09 14:37:22 +02:00
parent 2ec162c0e7
commit ebbd1e0cd0
5 changed files with 301 additions and 2 deletions

View File

@@ -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",

View File

@@ -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<string, unknown>).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<string, unknown>
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<string, unknown> = {
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
}

View File

@@ -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) {

View File

@@ -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)
})

View File

@@ -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 = {
<UButton
icon="i-mdi-content-save"
@click="saveDocument('Entwurf',true)"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
:disabled="!itemInfo.customer"
>
Speichern
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</UButton>
<UButton
@click="closeDocument"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
>
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
</UButton>
<UButton
icon="i-heroicons-tag"
@click="saveFinalizedCostCentres"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
>
Kostenstellen speichern
</UButton>
<UButton
icon="i-mdi-content-save"
@click="saveSerialInvoice"