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 { saveFile } from "../../utils/files"
|
||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
@@ -633,6 +634,72 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document: updated }
|
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",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
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 {citys, files} from "../../db/schema";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
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)
|
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) => {
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||||
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
||||||
if (result.errors.length > 0) {
|
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()
|
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 selectedTab = ref("0")
|
||||||
|
|
||||||
const closeDocument = async () => {
|
const closeDocument = async () => {
|
||||||
@@ -2077,7 +2102,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
<UButton
|
<UButton
|
||||||
icon="i-mdi-content-save"
|
icon="i-mdi-content-save"
|
||||||
@click="saveDocument('Entwurf',true)"
|
@click="saveDocument('Entwurf',true)"
|
||||||
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
|
||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
Speichern
|
Speichern
|
||||||
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="closeDocument"
|
@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"}}
|
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-tag"
|
||||||
|
@click="saveFinalizedCostCentres"
|
||||||
|
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
|
||||||
|
>
|
||||||
|
Kostenstellen speichern
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-mdi-content-save"
|
icon="i-mdi-content-save"
|
||||||
@click="saveSerialInvoice"
|
@click="saveSerialInvoice"
|
||||||
|
|||||||
Reference in New Issue
Block a user