MCP-Bankzuweisungen ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 50s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 22s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 50s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 22s
This commit is contained in:
141
backend/src/mcp/statementAllocations.ts
Normal file
141
backend/src/mcp/statementAllocations.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const statementAllocationTargetFields = [
|
||||
"createddocument",
|
||||
"incominginvoice",
|
||||
"account",
|
||||
"ownaccount",
|
||||
"customer",
|
||||
"vendor",
|
||||
] as const
|
||||
|
||||
const manualDebitFields = ["account", "customer", "vendor", "ownaccount"] as const
|
||||
const manualCreditFields = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] as const
|
||||
const bookingModes = new Set(["expense", "depreciation_single", "depreciation_bundle"])
|
||||
const depreciationMethods = new Set(["linear", "degressive"])
|
||||
|
||||
const requiredNumber = (args: Record<string, unknown>, key: string) => {
|
||||
const value = Number(args[key])
|
||||
if (!hasValue(args[key]) || !Number.isFinite(value)) throw new Error(`${key} muss eine gültige Zahl sein`)
|
||||
return value
|
||||
}
|
||||
|
||||
const optionalNumericId = (args: Record<string, unknown>, key: string) => {
|
||||
if (!hasValue(args[key])) return undefined
|
||||
const value = Number(args[key])
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} muss eine gültige ID sein`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const statementAllocationUuidArg = (args: Record<string, unknown>, key: string) => {
|
||||
if (!hasValue(args[key])) return undefined
|
||||
const value = String(args[key]).trim()
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new Error(`${key} muss eine gültige UUID sein`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const validDate = (value: string | null) =>
|
||||
Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)))
|
||||
|
||||
export const prepareStatementAllocationInput = (args: Record<string, unknown>) => {
|
||||
const payload: Record<string, any> = {}
|
||||
const amount = requiredNumber(args, "amount")
|
||||
const bankstatement = optionalNumericId(args, "bankstatement")
|
||||
const bookingMode = stringArg(args, "bookingMode") || "expense"
|
||||
|
||||
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||
|
||||
payload.amount = amount
|
||||
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||
payload.bookingMode = bookingMode
|
||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||
|
||||
for (const field of ["createddocument", "incominginvoice", "account", "customer", "vendor"] as const) {
|
||||
const value = optionalNumericId(args, field)
|
||||
if (value !== undefined) payload[field] = value
|
||||
}
|
||||
for (const field of ["ownaccount", "contraOwnaccount"] as const) {
|
||||
const value = statementAllocationUuidArg(args, field)
|
||||
if (value !== undefined) payload[field] = value
|
||||
}
|
||||
for (const field of ["contraAccount", "contraCustomer", "contraVendor"] as const) {
|
||||
const value = optionalNumericId(args, field)
|
||||
if (value !== undefined) payload[field] = value
|
||||
}
|
||||
|
||||
if (bankstatement !== undefined) {
|
||||
const selectedTargets = statementAllocationTargetFields.filter((field) => hasValue(payload[field]))
|
||||
if (selectedTargets.length !== 1) {
|
||||
throw new Error("Für eine Bankzuweisung muss genau ein Ziel ausgewählt werden")
|
||||
}
|
||||
if (amount === 0) throw new Error("Der Zuweisungsbetrag darf nicht 0 sein")
|
||||
if (manualCreditFields.some((field) => hasValue(payload[field]))) {
|
||||
throw new Error("Haben-Konten sind nur bei manuellen Buchungen zulässig")
|
||||
}
|
||||
|
||||
payload.bankstatement = bankstatement
|
||||
} else {
|
||||
const manualBookingDate = stringArg(args, "manualBookingDate")
|
||||
if (!validDate(manualBookingDate)) {
|
||||
throw new Error("Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich")
|
||||
}
|
||||
if (amount <= 0) throw new Error("Für manuelle Buchungen muss der Betrag größer als 0 sein")
|
||||
|
||||
const manualInvoiceSide = stringArg(args, "manualInvoiceSide")
|
||||
const hasInvoice = hasValue(payload.incominginvoice)
|
||||
if (hasInvoice && manualInvoiceSide !== "debit" && manualInvoiceSide !== "credit") {
|
||||
throw new Error("Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein")
|
||||
}
|
||||
if (!hasInvoice && manualInvoiceSide) {
|
||||
throw new Error("manualInvoiceSide ist nur zusammen mit incominginvoice zulässig")
|
||||
}
|
||||
|
||||
const debitCount = manualDebitFields.filter((field) => hasValue(payload[field])).length
|
||||
+ (hasInvoice && manualInvoiceSide === "debit" ? 1 : 0)
|
||||
const creditCount = manualCreditFields.filter((field) => hasValue(payload[field])).length
|
||||
+ (hasInvoice && manualInvoiceSide === "credit" ? 1 : 0)
|
||||
if (debitCount !== 1 || creditCount !== 1) {
|
||||
throw new Error("Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden")
|
||||
}
|
||||
|
||||
payload.bankstatement = null
|
||||
payload.manualBookingDate = manualBookingDate
|
||||
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
|
||||
}
|
||||
|
||||
if (bookingMode === "expense") return payload
|
||||
|
||||
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||
const depreciationStartDate = stringArg(args, "depreciationStartDate")
|
||||
const depreciationMethod = stringArg(args, "depreciationMethod") || "linear"
|
||||
const residualValue = args.residualValue === undefined ? 0 : requiredNumber(args, "residualValue")
|
||||
|
||||
if (!Number.isInteger(depreciationMonths) || depreciationMonths <= 0) {
|
||||
throw new Error("Die Abschreibungsdauer muss eine positive Anzahl Monate sein")
|
||||
}
|
||||
if (!validDate(depreciationStartDate)) throw new Error("Ein gültiger Abschreibungsbeginn ist erforderlich")
|
||||
if (!depreciationMethods.has(depreciationMethod)) {
|
||||
throw new Error(`Ungültige Abschreibungsmethode: ${depreciationMethod}`)
|
||||
}
|
||||
if (residualValue < 0) throw new Error("Der Restwert darf nicht negativ sein")
|
||||
|
||||
payload.depreciationMonths = depreciationMonths
|
||||
payload.depreciationStartDate = depreciationStartDate
|
||||
payload.depreciationMethod = depreciationMethod
|
||||
if (args.depreciationLabel !== undefined) payload.depreciationLabel = stringArg(args, "depreciationLabel")
|
||||
payload.depreciationGroup = bookingMode === "depreciation_bundle" ? stringArg(args, "depreciationGroup") : null
|
||||
payload.residualValue = residualValue
|
||||
|
||||
if (bookingMode === "depreciation_bundle" && !payload.depreciationGroup) {
|
||||
throw new Error("Für eine Sammelabschreibung ist eine Abschreibungsgruppe erforderlich")
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -4,16 +4,26 @@ import {
|
||||
accounts,
|
||||
bankstatements,
|
||||
createddocuments,
|
||||
customers,
|
||||
filetags,
|
||||
files,
|
||||
folders,
|
||||
incominginvoices,
|
||||
ownaccounts,
|
||||
statementallocations,
|
||||
vendors,
|
||||
} from "../../../db/schema"
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||
import { saveFile } from "../../utils/files"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import {
|
||||
prepareStatementAllocationInput,
|
||||
statementAllocationUuidArg,
|
||||
} from "../statementAllocations"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
@@ -357,6 +367,43 @@ const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const assertTenantEntityExists = async (
|
||||
context: any,
|
||||
table: any,
|
||||
idColumn: any,
|
||||
tenantColumn: any | null,
|
||||
id: unknown,
|
||||
label: string,
|
||||
) => {
|
||||
if (!hasValue(id)) return
|
||||
|
||||
const [row] = await context.server.db
|
||||
.select({ id: idColumn })
|
||||
.from(table)
|
||||
.where(tenantColumn
|
||||
? and(eq(idColumn, id as any), eq(tenantColumn, context.tenantId))
|
||||
: eq(idColumn, id as any))
|
||||
.limit(1)
|
||||
|
||||
if (!row) throw new Error(`${label} nicht gefunden`)
|
||||
}
|
||||
|
||||
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||
await Promise.all([
|
||||
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
||||
assertTenantEntityExists(context, accounts, accounts.id, null, payload.account, "Sachkonto"),
|
||||
assertTenantEntityExists(context, accounts, accounts.id, null, payload.contraAccount, "Haben-Sachkonto"),
|
||||
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.ownaccount, "Zusätzliches Konto"),
|
||||
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.contraOwnaccount, "Zusätzliches Haben-Konto"),
|
||||
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.customer, "Debitor"),
|
||||
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.contraCustomer, "Haben-Debitor"),
|
||||
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.vendor, "Kreditor"),
|
||||
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.contraVendor, "Haben-Kreditor"),
|
||||
])
|
||||
}
|
||||
|
||||
export const accountingTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.outgoing_documents.tax_types.list",
|
||||
@@ -1175,4 +1222,113 @@ export const accountingTools: McpTool[] = [
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.statement_allocations.create",
|
||||
title: "Bankzuweisung erstellen",
|
||||
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["amount"],
|
||||
properties: {
|
||||
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||
ownaccount: { type: "string", description: "UUID eines zusätzlichen Kontos beziehungsweise Soll-Kontos." },
|
||||
customer: { type: "number", description: "Debitor-ID beziehungsweise Soll-Debitor." },
|
||||
vendor: { type: "number", description: "Kreditor-ID beziehungsweise Soll-Kreditor." },
|
||||
contraAccount: { type: "number", description: "Haben-Sachkonto für manuelle Buchungen." },
|
||||
contraOwnaccount: { type: "string", description: "UUID eines zusätzlichen Haben-Kontos." },
|
||||
contraCustomer: { type: "number", description: "Haben-Debitor für manuelle Buchungen." },
|
||||
contraVendor: { type: "number", description: "Haben-Kreditor für manuelle Buchungen." },
|
||||
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
|
||||
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
||||
description: { type: "string" },
|
||||
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." },
|
||||
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
|
||||
depreciationMonths: { type: "number", minimum: 1 },
|
||||
depreciationStartDate: { type: "string", format: "date" },
|
||||
depreciationMethod: { type: "string", enum: ["linear", "degressive"], default: "linear" },
|
||||
depreciationLabel: { type: "string" },
|
||||
depreciationGroup: { type: "string" },
|
||||
residualValue: { type: "number", minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const payload = prepareStatementAllocationInput(args)
|
||||
await assertStatementAllocationReferences(context, payload)
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(statementallocations)
|
||||
.values({
|
||||
...payload,
|
||||
tenant: context.tenantId,
|
||||
updated_at: new Date(),
|
||||
updated_by: context.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (created?.bankstatement) {
|
||||
await insertHistoryItem(context.server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(created.bankstatement),
|
||||
action: "created",
|
||||
created_by: context.userId,
|
||||
tenant_id: context.tenantId,
|
||||
oldVal: null,
|
||||
newVal: created,
|
||||
text: "Buchung über MCP erstellt",
|
||||
})
|
||||
}
|
||||
|
||||
return { allocation: created }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.statement_allocations.delete",
|
||||
title: "Bankzuweisung löschen",
|
||||
description: "Löscht eine Bankzuweisung oder manuelle Soll/Haben-Buchung im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string", description: "UUID der Bankzuweisung." },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = statementAllocationUuidArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Bankzuweisung nicht gefunden")
|
||||
|
||||
const [deleted] = await context.server.db
|
||||
.delete(statementallocations)
|
||||
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (existing.bankstatement) {
|
||||
await insertHistoryItem(context.server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(existing.bankstatement),
|
||||
action: "deleted",
|
||||
created_by: context.userId,
|
||||
tenant_id: context.tenantId,
|
||||
oldVal: existing,
|
||||
newVal: null,
|
||||
text: "Buchung über MCP gelöscht",
|
||||
})
|
||||
}
|
||||
|
||||
return { deleted: true, allocation: deleted }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ const adminPermissions = [
|
||||
"accounting.incoming_invoices.write",
|
||||
"accounting.bank.read",
|
||||
"accounting.statement_allocations.read",
|
||||
"accounting.statement_allocations.write",
|
||||
"organisation.customers.read",
|
||||
"organisation.projects.read",
|
||||
"organisation.plants.read",
|
||||
|
||||
114
backend/tests/mcpStatementAllocations.test.ts
Normal file
114
backend/tests/mcpStatementAllocations.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
accountingTools,
|
||||
prepareStatementAllocationInput,
|
||||
} from "../src/mcp/tools/accounting"
|
||||
|
||||
const createTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.create")
|
||||
const deleteTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.delete")
|
||||
|
||||
test("registers write tools for creating and deleting statement allocations", () => {
|
||||
assert.deepEqual(createTool()?.requiredPermissions, ["accounting.statement_allocations.write"])
|
||||
assert.deepEqual(deleteTool()?.requiredPermissions, ["accounting.statement_allocations.write"])
|
||||
})
|
||||
|
||||
test("prepares a direct bank allocation to every target type exposed by the UI", () => {
|
||||
const base = {
|
||||
bankstatement: 42,
|
||||
amount: -125.5,
|
||||
description: "Teilzahlung",
|
||||
}
|
||||
|
||||
const targets = [
|
||||
["createddocument", 11],
|
||||
["incominginvoice", 12],
|
||||
["account", 13],
|
||||
["ownaccount", "57d31d62-11a2-47c5-b074-69f9c5ab8bba"],
|
||||
["customer", 14],
|
||||
["vendor", 15],
|
||||
] as const
|
||||
|
||||
for (const [field, value] of targets) {
|
||||
assert.deepEqual(prepareStatementAllocationInput({ ...base, [field]: value }), {
|
||||
bankstatement: 42,
|
||||
amount: -125.5,
|
||||
description: "Teilzahlung",
|
||||
bookingMode: "expense",
|
||||
[field]: value,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("prepares depreciation metadata for direct account allocations", () => {
|
||||
assert.deepEqual(prepareStatementAllocationInput({
|
||||
bankstatement: 42,
|
||||
amount: -1200,
|
||||
account: 13,
|
||||
bookingMode: "depreciation_bundle",
|
||||
depreciationMonths: 60,
|
||||
depreciationStartDate: "2026-08-01",
|
||||
depreciationMethod: "degressive",
|
||||
depreciationLabel: "Werkzeug",
|
||||
depreciationGroup: "BGA 2026",
|
||||
residualValue: 100,
|
||||
}), {
|
||||
bankstatement: 42,
|
||||
amount: -1200,
|
||||
account: 13,
|
||||
bookingMode: "depreciation_bundle",
|
||||
depreciationMonths: 60,
|
||||
depreciationStartDate: "2026-08-01",
|
||||
depreciationMethod: "degressive",
|
||||
depreciationLabel: "Werkzeug",
|
||||
depreciationGroup: "BGA 2026",
|
||||
residualValue: 100,
|
||||
})
|
||||
})
|
||||
|
||||
test("prepares manual Soll/Haben bookings including incoming invoices", () => {
|
||||
assert.deepEqual(prepareStatementAllocationInput({
|
||||
manualBookingDate: "2026-08-31",
|
||||
amount: 99.95,
|
||||
incominginvoice: 7,
|
||||
manualInvoiceSide: "debit",
|
||||
contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba",
|
||||
datevTaxKey: "9",
|
||||
description: "Manuelle Buchung",
|
||||
}), {
|
||||
bankstatement: null,
|
||||
manualBookingDate: "2026-08-31",
|
||||
amount: 99.95,
|
||||
incominginvoice: 7,
|
||||
manualInvoiceSide: "debit",
|
||||
contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba",
|
||||
datevTaxKey: "9",
|
||||
description: "Manuelle Buchung",
|
||||
bookingMode: "expense",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects ambiguous bank targets and invalid manual booking sides", () => {
|
||||
assert.throws(
|
||||
() => prepareStatementAllocationInput({ bankstatement: 42, amount: 10, account: 1, vendor: 2 }),
|
||||
/genau ein Ziel/,
|
||||
)
|
||||
assert.throws(
|
||||
() => prepareStatementAllocationInput({ manualBookingDate: "2026-08-31", amount: 10, account: 1 }),
|
||||
/Soll- und ein Haben-Konto/,
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects incomplete depreciation settings", () => {
|
||||
assert.throws(
|
||||
() => prepareStatementAllocationInput({
|
||||
bankstatement: 42,
|
||||
amount: -1200,
|
||||
account: 13,
|
||||
bookingMode: "depreciation_bundle",
|
||||
depreciationMonths: 0,
|
||||
depreciationStartDate: "2026-08-01",
|
||||
}),
|
||||
/Abschreibungsdauer/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user