feat(accounting): edit finalized document cost centres
This commit is contained in:
@@ -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",
|
||||
|
||||
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal 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)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user