feat(accounting): Kostenstellen von Eingangsbelegen jederzeit nachträglich ändern
Eigener Endpunkt und MCP-Tool ändern ausschließlich die Positionskostenstellen, auch bei gebuchten oder einer Bankbuchung zugewiesenen Eingangsbelegen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, any>) }
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
@@ -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<number>()
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
@@ -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
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="canEditCostCentres"
|
||||
icon="i-heroicons-tag"
|
||||
variant="outline"
|
||||
@click="openCostCentres"
|
||||
>
|
||||
Kostenstellen bearbeiten
|
||||
</UButton>
|
||||
<ArchiveButton
|
||||
v-if="canArchiveIncomingInvoice"
|
||||
color="error"
|
||||
@@ -1060,6 +1109,27 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
|
||||
<template #body>
|
||||
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
|
||||
<div class="space-y-4">
|
||||
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
|
||||
<InputGroup>
|
||||
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
|
||||
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
|
||||
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
|
||||
</USelectMenu>
|
||||
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
|
||||
</InputGroup>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
|
||||
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user