Eingangsbelege im FEDEO MCP bearbeitbar machen
Ergänzt MCP-Tools zum Prüfen, Bearbeiten, Erstellen, Buchen und Archivieren von Eingangsbelegen. Die Validierung spiegelt die Pflichtregeln aus der FEDEO-Oberfläche und verhindert das Buchen unvollständiger Belege.
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
accounts,
|
||||
bankstatements,
|
||||
createddocuments,
|
||||
files,
|
||||
incominginvoices,
|
||||
statementallocations,
|
||||
} from "../../../db/schema"
|
||||
@@ -25,6 +26,9 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||
|
||||
const allowedOutgoingDocumentTypes = new Set([
|
||||
"quotes",
|
||||
"costEstimates",
|
||||
@@ -123,6 +127,88 @@ const assertNoManualDocumentNumber = (args: Record<string, unknown>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const incomingInvoiceAccountsArg = (args: Record<string, unknown>) => {
|
||||
if (args.accounts === undefined) return undefined
|
||||
if (!Array.isArray(args.accounts)) throw new Error("accounts muss ein Array sein")
|
||||
return args.accounts
|
||||
}
|
||||
|
||||
const buildIncomingInvoicePayload = (
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
tenantId: number,
|
||||
includeCreateDefaults = false
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
}
|
||||
|
||||
if (includeCreateDefaults) {
|
||||
payload.tenant = tenantId
|
||||
payload.state = "Entwurf"
|
||||
payload.expense = args.expense !== undefined ? args.expense === true : true
|
||||
payload.paid = false
|
||||
payload.archived = false
|
||||
}
|
||||
|
||||
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
||||
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
||||
}
|
||||
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
||||
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
||||
if (args.expense !== undefined) payload.expense = args.expense === true
|
||||
if (args.paid !== undefined) payload.paid = args.paid === true
|
||||
|
||||
const accountsPayload = incomingInvoiceAccountsArg(args)
|
||||
if (accountsPayload !== undefined) payload.accounts = accountsPayload
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const isDepreciationBookingMode = (value: unknown) =>
|
||||
["depreciation", "depreciation_bundle"].includes(String(value || ""))
|
||||
|
||||
const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
|
||||
const errors: Array<{ message: string; type: "breaking" | "warning" }> = []
|
||||
|
||||
if (!invoice.vendor) errors.push({ message: "Es ist kein Lieferant ausgewählt", type: "breaking" })
|
||||
if (!String(invoice.reference || "").trim()) errors.push({ message: "Es ist keine Referenz angegeben", type: "breaking" })
|
||||
if (!invoice.date) errors.push({ message: "Es ist kein Datum ausgewählt", type: "breaking" })
|
||||
if (!Array.isArray(invoice.accounts) || invoice.accounts.length === 0) {
|
||||
errors.push({ message: "Es ist keine Position vorhanden", type: "breaking" })
|
||||
}
|
||||
|
||||
;(Array.isArray(invoice.accounts) ? invoice.accounts : []).forEach((account: any, idx: number) => {
|
||||
const pos = idx + 1
|
||||
|
||||
if (!account?.account) errors.push({ message: `Pos ${pos}: Keine Kategorie`, type: "breaking" })
|
||||
if (!hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountGross)) {
|
||||
errors.push({ message: `Pos ${pos}: Kein gültiger Betrag`, type: "breaking" })
|
||||
}
|
||||
if (!account?.taxType) errors.push({ message: `Pos ${pos}: Kein Steuerschlüssel`, type: "breaking" })
|
||||
if (hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountTax)) {
|
||||
errors.push({ message: `Pos ${pos}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning" })
|
||||
}
|
||||
if (isDepreciationBookingMode(account?.bookingMode) && !Number(account?.depreciationMonths)) {
|
||||
errors.push({ message: `Pos ${pos}: Abschreibungsdauer fehlt`, type: "breaking" })
|
||||
}
|
||||
if (account?.bookingMode === "depreciation_bundle" && !String(account?.depreciationGroup || "").trim()) {
|
||||
errors.push({ message: `Pos ${pos}: Sammelposten benötigt einen Gruppennamen`, type: "breaking" })
|
||||
}
|
||||
})
|
||||
|
||||
const order = { breaking: 0, warning: 1 }
|
||||
errors.sort((a, b) => order[a.type] - order[b.type])
|
||||
|
||||
return {
|
||||
valid: errors.every((error) => error.type !== "breaking"),
|
||||
errors,
|
||||
blockingErrors: errors.filter((error) => error.type === "breaking"),
|
||||
warnings: errors.filter((error) => error.type === "warning"),
|
||||
}
|
||||
}
|
||||
|
||||
export const accountingTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.outgoing_documents.list",
|
||||
@@ -484,6 +570,219 @@ export const accountingTools: McpTool[] = [
|
||||
return { invoice: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.files.list",
|
||||
title: "Dateien eines Eingangsbelegs auflisten",
|
||||
description: "Listet Dateien, die mit einem Eingangsbeleg im aktiven Mandanten verknüpft sind.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(
|
||||
eq(files.incominginvoice, id),
|
||||
eq(files.tenant, context.tenantId),
|
||||
eq(files.archived, false)
|
||||
))
|
||||
.orderBy(desc(files.createdAt))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.validate",
|
||||
title: "Eingangsbeleg validieren",
|
||||
description: "Prüft einen Eingangsbeleg mit denselben Pflichtregeln wie die FEDEO-Oberfläche vor dem Buchen.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.create",
|
||||
title: "Eingangsbeleg erstellen",
|
||||
description: "Erstellt einen Eingangsbeleg-Entwurf im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vendor: { type: "number" },
|
||||
reference: { type: "string" },
|
||||
date: { type: "string" },
|
||||
dueDate: { type: "string" },
|
||||
document: { type: "number" },
|
||||
description: { type: "string" },
|
||||
paymentType: { type: "string" },
|
||||
accounts: { type: "array" },
|
||||
expense: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(incominginvoices)
|
||||
.values(payload as any)
|
||||
.returning()
|
||||
|
||||
return { invoice: created, validation: validateIncomingInvoiceData(created as Record<string, any>) }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.update",
|
||||
title: "Eingangsbeleg bearbeiten",
|
||||
description: "Bearbeitet einen noch nicht gebuchten Eingangsbeleg im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
vendor: { type: "number" },
|
||||
reference: { type: "string" },
|
||||
date: { type: "string" },
|
||||
dueDate: { type: "string" },
|
||||
document: { type: "number" },
|
||||
description: { type: "string" },
|
||||
paymentType: { type: "string" },
|
||||
accounts: { type: "array" },
|
||||
expense: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
if (existing.state === "Gebucht") throw new Error("Gebuchte Eingangsbelege können nicht über update geändert werden")
|
||||
|
||||
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId)
|
||||
payload.state = existing.state || "Entwurf"
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set(payload as any)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.book",
|
||||
title: "Eingangsbeleg buchen",
|
||||
description: "Validiert und bucht einen vorbereiteten oder als Entwurf gespeicherten Eingangsbeleg.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
if (existing.state === "Gebucht") return { invoice: existing, validation: validateIncomingInvoiceData(existing as Record<string, any>) }
|
||||
|
||||
const validation = validateIncomingInvoiceData(existing as Record<string, any>)
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
booked: false,
|
||||
invoice: existing,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set({
|
||||
state: "Gebucht",
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
booked: true,
|
||||
invoice: updated,
|
||||
validation: validateIncomingInvoiceData(updated as Record<string, any>),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.archive",
|
||||
title: "Eingangsbeleg archivieren",
|
||||
description: "Archiviert einen Eingangsbeleg im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set({
|
||||
archived: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
return { invoice: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.bank_statements.list",
|
||||
title: "Bankumsätze auflisten",
|
||||
|
||||
Reference in New Issue
Block a user