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"])
|
||||
})
|
||||
Reference in New Issue
Block a user