- accounting.outgoing_documents.preview rendert den Beleg wie der Editor und liefert Seiten als PNG-Bilder im MCP-Ergebnis - Layout je Seite in mm mit Warnungen zu übervollen Seiten, allein stehenden Überschriften, leeren Seiten und vom Überweisungs-QR-Code überdeckten Beträgen samt Vorschlag für den Seitenumbruch - rows testet geänderte Positionen samt Seitenumbrüchen, ohne zu speichern - Belegdaten für das PDF als Backend-Port von getDocumentData (Titelsummen, Überträge, Abschläge, Optional-/Alternativpositionen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1724 lines
75 KiB
TypeScript
1724 lines
75 KiB
TypeScript
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
|
import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
|
|
import { randomUUID } from "node:crypto"
|
|
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
|
import {
|
|
accounts,
|
|
bankstatements,
|
|
createddocuments,
|
|
customers,
|
|
filetags,
|
|
files,
|
|
folders,
|
|
incominginvoices,
|
|
ownaccounts,
|
|
serialExecutions,
|
|
statementallocations,
|
|
vendors,
|
|
} from "../../../db/schema"
|
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
|
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 {
|
|
prepareStatementAllocationInput,
|
|
statementAllocationUuidArg,
|
|
} from "../statementAllocations"
|
|
import { McpContext, McpTool } from "../types"
|
|
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
|
|
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
|
|
import { withImages } from "../result"
|
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
|
import { outgoingStatusTools } from "./outgoingStatus"
|
|
|
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
|
|
|
|
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = args[key]
|
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
|
}
|
|
|
|
const numberArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = Number(args[key])
|
|
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 MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
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
|
|
|
|
const serialConfigSchema = {
|
|
type: "object",
|
|
description: "Ausführungsplan einer Serienrechnungsvorlage.",
|
|
properties: {
|
|
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
|
|
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
|
|
intervall: {
|
|
type: "string",
|
|
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
|
|
},
|
|
active: { type: "boolean" },
|
|
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
|
|
},
|
|
}
|
|
|
|
const allowedOutgoingDocumentTypes = new Set([
|
|
"quotes",
|
|
"costEstimates",
|
|
"confirmationOrders",
|
|
"deliveryNotes",
|
|
"packingSlips",
|
|
"invoices",
|
|
"advanceInvoices",
|
|
"cancellationInvoices",
|
|
"serialInvoices",
|
|
])
|
|
|
|
const allowedOutgoingDocumentTaxTypes = new Set([
|
|
"Standard",
|
|
"13b UStG",
|
|
"19 UStG",
|
|
"12.3 UStG",
|
|
])
|
|
|
|
const outgoingDocumentTaxableTypes = new Set([
|
|
"invoices",
|
|
"cancellationInvoices",
|
|
"advanceInvoices",
|
|
"serialInvoices",
|
|
"confirmationOrders",
|
|
"quotes",
|
|
"costEstimates",
|
|
])
|
|
|
|
const documentTypeArg = (args: Record<string, unknown>, key = "type") => {
|
|
const type = stringArg(args, key) || "invoices"
|
|
if (!allowedOutgoingDocumentTypes.has(type)) {
|
|
throw new Error(`Ungültige Belegart: ${type}`)
|
|
}
|
|
return type
|
|
}
|
|
|
|
const normalizeOutgoingDocumentTaxType = (value: unknown) => {
|
|
const taxType = typeof value === "string" && value.trim() ? value.trim() : "Standard"
|
|
if (!allowedOutgoingDocumentTaxTypes.has(taxType)) {
|
|
throw new Error(`Ungültiger Dokument-Steuertyp: ${taxType}`)
|
|
}
|
|
return taxType
|
|
}
|
|
|
|
const applyOutgoingDocumentTaxType = (
|
|
payload: Record<string, unknown>,
|
|
args: Record<string, unknown>,
|
|
documentType: string,
|
|
existingTaxType?: unknown,
|
|
existingRows?: unknown
|
|
) => {
|
|
if (!outgoingDocumentTaxableTypes.has(documentType)) {
|
|
payload.taxType = null
|
|
return
|
|
}
|
|
|
|
const taxType = args.taxType !== undefined
|
|
? normalizeOutgoingDocumentTaxType(args.taxType)
|
|
: existingTaxType
|
|
? normalizeOutgoingDocumentTaxType(existingTaxType)
|
|
: "Standard"
|
|
|
|
payload.taxType = taxType
|
|
|
|
if (["13b UStG", "19 UStG", "12.3 UStG"].includes(taxType)) {
|
|
const rows = Array.isArray(payload.rows)
|
|
? payload.rows
|
|
: Array.isArray(existingRows)
|
|
? normalizeOutgoingDocumentRows(existingRows)
|
|
: null
|
|
|
|
if (!rows) return
|
|
|
|
payload.rows = rows.map((row: any) => ({
|
|
...row,
|
|
taxPercent: 0,
|
|
}))
|
|
}
|
|
}
|
|
|
|
const optionalObjectArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = args[key]
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null
|
|
}
|
|
|
|
const optionalArrayArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = args[key]
|
|
return Array.isArray(value) ? value : null
|
|
}
|
|
|
|
const normalizeOutgoingDocumentRow = (row: unknown, index: number) => {
|
|
let normalizedRow = row
|
|
|
|
if (typeof normalizedRow === "string") {
|
|
try {
|
|
normalizedRow = JSON.parse(normalizedRow)
|
|
} catch {
|
|
throw new Error(`Position ${index + 1} ist kein gültiges JSON-Objekt`)
|
|
}
|
|
}
|
|
|
|
if (!normalizedRow || typeof normalizedRow !== "object" || Array.isArray(normalizedRow)) {
|
|
throw new Error(`Position ${index + 1} muss ein Objekt sein`)
|
|
}
|
|
|
|
const rowPayload = { ...(normalizedRow as Record<string, any>) }
|
|
|
|
rowPayload.id = rowPayload.id || randomUUID()
|
|
rowPayload.pos = rowPayload.pos || String(index + 1)
|
|
rowPayload.mode = rowPayload.mode || "free"
|
|
rowPayload.inputPrice = hasValidNumber(rowPayload.inputPrice)
|
|
? Number(rowPayload.inputPrice)
|
|
: hasValidNumber(rowPayload.price)
|
|
? Number(rowPayload.price)
|
|
: 0
|
|
rowPayload.price = hasValidNumber(rowPayload.price) ? Number(rowPayload.price) : rowPayload.inputPrice
|
|
rowPayload.quantity = hasValidNumber(rowPayload.quantity) ? Number(rowPayload.quantity) : 1
|
|
rowPayload.discountPercent = hasValidNumber(rowPayload.discountPercent) ? Number(rowPayload.discountPercent) : 0
|
|
rowPayload.linkedEntitys = Array.isArray(rowPayload.linkedEntitys) ? rowPayload.linkedEntitys : []
|
|
|
|
return rowPayload
|
|
}
|
|
|
|
const normalizeOutgoingDocumentRows = (rows: unknown) => {
|
|
if (!Array.isArray(rows)) return []
|
|
return rows.map((row, index) => normalizeOutgoingDocumentRow(row, index))
|
|
}
|
|
|
|
const buildOutgoingDocumentPayload = (
|
|
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.createdBy = userId
|
|
payload.created_by = userId
|
|
payload.archived = false
|
|
payload.state = stringArg(args, "state") || "Entwurf"
|
|
payload.type = documentTypeArg(args)
|
|
payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
|
|
}
|
|
|
|
const stringFields = [
|
|
"state",
|
|
"documentDate",
|
|
"deliveryDate",
|
|
"deliveryDateEnd",
|
|
"deliveryDateType",
|
|
"payment_type",
|
|
"title",
|
|
"description",
|
|
"startText",
|
|
"endText",
|
|
]
|
|
|
|
for (const field of stringFields) {
|
|
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
|
}
|
|
|
|
for (const field of ["customer", "contact", "contract", "project", "plant", "letterhead", "createddocument"] as const) {
|
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
|
}
|
|
|
|
if (args.costcentre !== undefined) payload.costcentre = stringArg(args, "costcentre")
|
|
|
|
for (const field of ["paymentDays"] as const) {
|
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
|
}
|
|
|
|
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
|
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
|
if (args.info !== undefined) payload.info = optionalObjectArg(args, "info")
|
|
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
|
|
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
|
|
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
|
|
if (args.rows !== undefined) payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
|
|
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
|
|
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
|
|
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved
|
|
if (args.customSurchargePercentage !== undefined) payload.customSurchargePercentage = numberArg(args, "customSurchargePercentage") || 0
|
|
|
|
return payload
|
|
}
|
|
|
|
const assertNoManualDocumentNumber = (args: Record<string, unknown>) => {
|
|
if (args.documentNumber !== undefined || args.assignDocumentNumber !== undefined) {
|
|
throw new Error("Belegnummern werden nur beim Finalisieren vergeben")
|
|
}
|
|
}
|
|
|
|
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 base64BufferArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = stringArg(args, key)
|
|
if (!value) throw new Error(`${key} ist erforderlich`)
|
|
|
|
const base64 = value.includes(",") ? value.split(",").pop() || "" : value
|
|
const buffer = Buffer.from(base64, "base64")
|
|
|
|
if (!buffer.length) throw new Error(`${key} enthält keine Datei`)
|
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
|
|
|
return buffer
|
|
}
|
|
|
|
const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: number }) => {
|
|
const currentYear = new Date().getFullYear()
|
|
|
|
const [tag] = await context.server.db
|
|
.select({ id: filetags.id })
|
|
.from(filetags)
|
|
.where(and(
|
|
eq(filetags.tenant, context.tenantId),
|
|
eq(filetags.incomingDocumentType, "invoices"),
|
|
eq(filetags.archived, false)
|
|
))
|
|
.limit(1)
|
|
|
|
const [folder] = await context.server.db
|
|
.select({ id: folders.id })
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.tenant, context.tenantId),
|
|
eq(folders.function, "incomingInvoices"),
|
|
eq(folders.year, currentYear),
|
|
eq(folders.archived, false)
|
|
))
|
|
.limit(1)
|
|
|
|
return {
|
|
folderId: folder?.id || null,
|
|
fileTagId: tag?.id || null,
|
|
}
|
|
}
|
|
|
|
export type IncomingInvoiceFileUpload = {
|
|
filename: string
|
|
buffer: Buffer
|
|
mimeType: string
|
|
invoiceId?: number | null
|
|
invoiceArgs?: Record<string, unknown> | null
|
|
prepare?: boolean
|
|
}
|
|
|
|
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
|
|
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
|
|
const { filename, buffer, mimeType } = upload
|
|
const invoiceId = upload.invoiceId || null
|
|
const invoiceArgs = upload.invoiceArgs || null
|
|
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
|
|
if (!buffer.length) throw new Error("Datei ist leer")
|
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
|
|
|
let linkedInvoice: any = null
|
|
|
|
if (invoiceId) {
|
|
const [existing] = await context.server.db
|
|
.select()
|
|
.from(incominginvoices)
|
|
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
|
linkedInvoice = existing
|
|
} else if (invoiceArgs) {
|
|
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
|
payload.state = "Entwurf"
|
|
|
|
const [createdInvoice] = await context.server.db
|
|
.insert(incominginvoices)
|
|
.values(payload as any)
|
|
.returning()
|
|
|
|
linkedInvoice = createdInvoice
|
|
}
|
|
|
|
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
|
const saved = await saveFile(
|
|
context.server,
|
|
context.tenantId,
|
|
null,
|
|
{
|
|
filename,
|
|
content: buffer,
|
|
contentType: mimeType,
|
|
},
|
|
defaults.folderId,
|
|
defaults.fileTagId,
|
|
{
|
|
incominginvoice: linkedInvoice?.id || null,
|
|
createdBy: context.userId,
|
|
updatedBy: context.userId,
|
|
updatedAt: new Date(),
|
|
}
|
|
)
|
|
|
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
|
|
|
if (shouldPrepare) {
|
|
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
|
|
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
|
|
}
|
|
|
|
const [file] = await context.server.db
|
|
.select()
|
|
.from(files)
|
|
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (file?.incominginvoice && !linkedInvoice) {
|
|
const [preparedInvoice] = await context.server.db
|
|
.select()
|
|
.from(incominginvoices)
|
|
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
linkedInvoice = preparedInvoice || null
|
|
}
|
|
|
|
return {
|
|
file,
|
|
invoice: linkedInvoice,
|
|
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
|
prepared: Boolean(shouldPrepare && linkedInvoice),
|
|
}
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
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([
|
|
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
|
|
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[] = [
|
|
...outgoingStatusTools,
|
|
{
|
|
name: "accounting.outgoing_documents.tax_types.list",
|
|
title: "Steuertypen für Ausgangsbelege auflisten",
|
|
description: "Listet die unterstützten Dokument-Steuertypen für Ausgangsbelege.",
|
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
},
|
|
async handler() {
|
|
return {
|
|
rows: [
|
|
{ key: "Standard", label: "Standard", forcesZeroTaxPercent: false },
|
|
{ key: "13b UStG", label: "13b UStG", forcesZeroTaxPercent: true },
|
|
{ key: "19 UStG", label: "19 UStG Kleinunternehmer", forcesZeroTaxPercent: true },
|
|
{ key: "12.3 UStG", label: "12.3 UStG", forcesZeroTaxPercent: true },
|
|
],
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.list",
|
|
title: "Ausgangsbelege auflisten",
|
|
description: "Listet Ausgangsbelege des aktiven Mandanten mit optionalen Filtern.",
|
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
type: { type: "string", description: "Belegart, z. B. invoices, quotes oder deliveryNotes." },
|
|
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
|
customer: { type: "number" },
|
|
project: { type: "number" },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(createddocuments.tenant, context.tenantId)]
|
|
const type = stringArg(args, "type")
|
|
const state = stringArg(args, "state")
|
|
const customer = numberArg(args, "customer")
|
|
const project = numberArg(args, "project")
|
|
|
|
if (type) {
|
|
if (!allowedOutgoingDocumentTypes.has(type)) throw new Error(`Ungültige Belegart: ${type}`)
|
|
conditions.push(eq(createddocuments.type, type))
|
|
}
|
|
if (state) conditions.push(eq(createddocuments.state, state))
|
|
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
|
if (project) conditions.push(eq(createddocuments.project, project))
|
|
conditions.push(eq(createddocuments.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(createddocuments)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(createddocuments.createdAt), createddocuments.id)
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.get",
|
|
title: "Ausgangsbeleg laden",
|
|
description: "Lädt einen Ausgangsbeleg des aktiven Mandanten anhand seiner ID.",
|
|
requiredPermissions: ["accounting.outgoing_documents.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(createddocuments)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
|
return { document: rows[0] }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.create",
|
|
title: "Ausgangsbeleg erstellen",
|
|
description: "Erstellt einen Ausgangsbeleg-Entwurf im aktiven Mandanten. Belegnummern werden erst beim Finalisieren vergeben.",
|
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["type"],
|
|
properties: {
|
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
|
customer: { type: "number" },
|
|
contact: { type: "number" },
|
|
contract: { type: "number" },
|
|
project: { type: "number" },
|
|
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
|
plant: { type: "number" },
|
|
documentDate: { type: "string" },
|
|
deliveryDate: { type: "string" },
|
|
deliveryDateEnd: { type: "string" },
|
|
deliveryDateType: { type: "string" },
|
|
paymentDays: { type: "number" },
|
|
payment_type: { type: "string" },
|
|
taxType: {
|
|
type: "string",
|
|
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
|
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
|
},
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
startText: { type: "string" },
|
|
endText: { type: "string" },
|
|
address: outgoingAddressSchema,
|
|
rows: outgoingRowsSchema,
|
|
letterhead: { type: "number" },
|
|
availableInPortal: { type: "boolean" },
|
|
customSurchargePercentage: { type: "number" },
|
|
report: outgoingReportSchema,
|
|
serialConfig: serialConfigSchema,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
assertNoManualDocumentNumber(args)
|
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
|
payload.state = "Entwurf"
|
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
|
|
|
const [created] = await context.server.db
|
|
.insert(createddocuments)
|
|
.values(payload as any)
|
|
.returning()
|
|
|
|
return { document: created }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.update",
|
|
title: "Ausgangsbeleg aktualisieren",
|
|
description: "Aktualisiert einen Ausgangsbeleg im aktiven Mandanten, solange er noch nicht finalisiert ist.",
|
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
|
state: { type: "string" },
|
|
customer: { type: "number" },
|
|
contact: { type: "number" },
|
|
contract: { type: "number" },
|
|
project: { type: "number" },
|
|
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
|
plant: { type: "number" },
|
|
documentDate: { type: "string" },
|
|
deliveryDate: { type: "string" },
|
|
deliveryDateEnd: { type: "string" },
|
|
deliveryDateType: { type: "string" },
|
|
paymentDays: { type: "number" },
|
|
payment_type: { type: "string" },
|
|
taxType: {
|
|
type: "string",
|
|
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
|
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
|
},
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
startText: { type: "string" },
|
|
endText: { type: "string" },
|
|
address: outgoingAddressSchema,
|
|
rows: outgoingRowsSchema,
|
|
letterhead: { type: "number" },
|
|
availableInPortal: { type: "boolean" },
|
|
customSurchargePercentage: { type: "number" },
|
|
report: outgoingReportSchema,
|
|
serialConfig: serialConfigSchema,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
assertNoManualDocumentNumber(args)
|
|
|
|
const [existing] = await context.server.db
|
|
.select()
|
|
.from(createddocuments)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
|
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
|
|
|
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
|
payload.state = "Entwurf"
|
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
|
|
|
const [updated] = await context.server.db
|
|
.update(createddocuments)
|
|
.set(payload as any)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
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.preview",
|
|
title: "Ausgangsbeleg-Vorschau",
|
|
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
|
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
|
|
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
|
|
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
|
|
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
|
|
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
|
|
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
|
|
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
|
|
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
|
|
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
|
|
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|
|
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
|
|
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
|
|
}
|
|
|
|
const [existing] = await context.server.db
|
|
.select()
|
|
.from(createddocuments)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.limit(1)
|
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
|
|
|
const document: Record<string, any> = { ...existing }
|
|
if (args.rows !== undefined) {
|
|
const payload: Record<string, unknown> = {
|
|
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
|
|
}
|
|
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
|
|
document.rows = payload.rows
|
|
}
|
|
|
|
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
|
|
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
|
|
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
|
|
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
|
|
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
|
|
|
|
return withImages({
|
|
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
|
|
previewOnly: args.rows !== undefined,
|
|
pageCount: layout.pageCount,
|
|
imagePages: images.map((image) => image.page),
|
|
...analysis,
|
|
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
|
|
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.finalize",
|
|
title: "Ausgangsbeleg finalisieren",
|
|
description: "Finalisiert einen Ausgangsbeleg, setzt den Status auf Gebucht und vergibt dabei genau einmal eine Belegnummer.",
|
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "number" },
|
|
documentDate: { type: "string" },
|
|
deliveryDate: { type: "string" },
|
|
deliveryDateEnd: { type: "string" },
|
|
paymentDays: { type: "number" },
|
|
payment_type: { type: "string" },
|
|
taxType: {
|
|
type: "string",
|
|
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
|
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
|
},
|
|
rows: outgoingRowsSchema,
|
|
report: outgoingReportSchema,
|
|
availableInPortal: { type: "boolean" },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = numberArg(args, "id")
|
|
if (!id) throw new Error("id ist erforderlich")
|
|
assertNoManualDocumentNumber(args)
|
|
|
|
const [existing] = await context.server.db
|
|
.select()
|
|
.from(createddocuments)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.limit(1)
|
|
|
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
|
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
|
|
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
|
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
|
|
|
payload.state = "Gebucht"
|
|
payload.documentNumber = result.usedNumber
|
|
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
|
|
|
const [updated] = await context.server.db
|
|
.update(createddocuments)
|
|
.set(payload as any)
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
return { document: updated }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.serial_invoices.execute",
|
|
title: "Serienrechnungslauf starten",
|
|
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
|
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["executionDate", "templateIds"],
|
|
properties: {
|
|
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
|
|
templateIds: {
|
|
type: "array",
|
|
minItems: 1,
|
|
uniqueItems: true,
|
|
items: { type: "number" },
|
|
},
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const executionDate = stringArg(args, "executionDate")
|
|
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
|
|
const templateIds = Array.isArray(args.templateIds)
|
|
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
|
|
: []
|
|
|
|
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
|
|
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
|
|
}
|
|
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
|
|
|
|
return executeManualGeneration(
|
|
context.server,
|
|
parsedExecutionDate,
|
|
templateIds,
|
|
context.tenantId,
|
|
context.userId,
|
|
)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.serial_invoice_executions.list",
|
|
title: "Serienrechnungsläufe auflisten",
|
|
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
|
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
|
|
const status = stringArg(args, "status")
|
|
if (status) conditions.push(eq(serialExecutions.status, status))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(serialExecutions)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(serialExecutions.createdAt), serialExecutions.id)
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.serial_invoice_executions.finish",
|
|
title: "Serienrechnungslauf abschließen",
|
|
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
|
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const id = stringArg(args, "id")
|
|
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
|
|
return finishManualGeneration(context.server, id, context.tenantId)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.outgoing_documents.archive",
|
|
title: "Ausgangsbeleg archivieren",
|
|
description: "Archiviert einen Ausgangsbeleg im aktiven Mandanten.",
|
|
requiredPermissions: ["accounting.outgoing_documents.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(createddocuments)
|
|
.set({
|
|
archived: true,
|
|
updatedAt: new Date(),
|
|
updatedBy: context.userId,
|
|
})
|
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
|
.returning()
|
|
|
|
if (!updated) throw new Error("Ausgangsbeleg nicht gefunden")
|
|
return { document: updated }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.accounts.search",
|
|
title: "Konten suchen",
|
|
description: "Sucht Sachkonten im aktiven Kontenrahmen des Mandanten.",
|
|
requiredPermissions: ["accounting.accounts.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const tenantRows = await context.server.db.query.tenants.findMany({
|
|
where: (tenant, { eq }) => eq(tenant.id, context.tenantId),
|
|
columns: {
|
|
accountChart: true,
|
|
},
|
|
limit: 1,
|
|
})
|
|
const accountChart = tenantRows[0]?.accountChart || "skr03"
|
|
const query = stringArg(args, "query")
|
|
const limit = limitFromArgs(args)
|
|
|
|
const whereCond = query
|
|
? and(
|
|
eq(accounts.accountChart, accountChart),
|
|
or(
|
|
ilike(accounts.number, `%${query}%`),
|
|
ilike(accounts.label, `%${query}%`),
|
|
ilike(accounts.description, `%${query}%`)
|
|
)
|
|
)
|
|
: eq(accounts.accountChart, accountChart)
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(accounts)
|
|
.where(whereCond)
|
|
.orderBy(accounts.number, accounts.id)
|
|
.limit(limit + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return { accountChart, ...paginatedResult(rows, args) }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.incoming_invoices.list",
|
|
title: "Eingangsrechnungen auflisten",
|
|
description: "Listet Eingangsrechnungen des aktiven Mandanten.",
|
|
requiredPermissions: ["accounting.incoming_invoices.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
state: { type: "string", description: "Optionaler Statusfilter." },
|
|
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(incominginvoices.tenant, context.tenantId)]
|
|
const state = stringArg(args, "state")
|
|
|
|
if (state) conditions.push(eq(incominginvoices.state, state))
|
|
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
|
conditions.push(eq(incominginvoices.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(incominginvoices)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(incominginvoices.createdAt), incominginvoices.id)
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.incoming_invoices.get",
|
|
title: "Eingangsrechnung laden",
|
|
description: "Lädt eine Eingangsrechnung des aktiven Mandanten anhand ihrer ID.",
|
|
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), eq(incominginvoices.archived, false)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
|
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" },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
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), files.id)
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.incoming_invoices.files.upload",
|
|
title: "Datei für Eingangsbeleg hochladen",
|
|
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
|
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["filename", "contentBase64"],
|
|
properties: {
|
|
filename: { type: "string", description: "Dateiname, z. B. rechnung.pdf." },
|
|
contentBase64: { type: "string", description: "Dateiinhalt als Base64 oder Data-URL." },
|
|
mimeType: { type: "string", description: "MIME-Type, z. B. application/pdf oder image/jpeg." },
|
|
invoiceId: { type: "number", description: "Optional: vorhandener Eingangsbeleg, mit dem die Datei verknüpft wird." },
|
|
prepare: {
|
|
type: "boolean",
|
|
default: true,
|
|
description: "Wenn kein invoiceId und keine invoice-Daten übergeben werden, wird nach dem Upload die automatische Eingangsbeleg-Vorbereitung ausgeführt.",
|
|
},
|
|
invoice: {
|
|
type: "object",
|
|
description: "Optional: Daten für einen neuen Eingangsbeleg-Entwurf, der direkt mit der Datei verknüpft wird.",
|
|
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 filename = stringArg(args, "filename")
|
|
if (!filename) throw new Error("filename ist erforderlich")
|
|
|
|
return uploadIncomingInvoiceFile(context, {
|
|
filename,
|
|
buffer: base64BufferArg(args, "contentBase64"),
|
|
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
|
|
invoiceId: numberArg(args, "invoiceId"),
|
|
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
|
|
prepare: args.prepare !== false,
|
|
})
|
|
},
|
|
},
|
|
{
|
|
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.prepare",
|
|
title: "Belegvorbereitung starten",
|
|
description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.",
|
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
fileIds: {
|
|
type: "array",
|
|
items: { type: "string", format: "uuid" },
|
|
minItems: 1,
|
|
maxItems: 50,
|
|
description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.",
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
async handler(context, args) {
|
|
let fileIds: string[] | undefined
|
|
if (args.fileIds !== undefined) {
|
|
if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) {
|
|
throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten")
|
|
}
|
|
if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) {
|
|
throw new Error("fileIds darf nur gültige UUIDs enthalten")
|
|
}
|
|
fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))]
|
|
}
|
|
|
|
const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds })
|
|
if (result.error && !result.filesFound) throw new Error(result.error)
|
|
|
|
const invoiceIds = result.created.map((entry) => entry.invoiceId)
|
|
const invoices = invoiceIds.length
|
|
? await context.server.db
|
|
.select()
|
|
.from(incominginvoices)
|
|
.where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds)))
|
|
: []
|
|
const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId))
|
|
|
|
return {
|
|
filesFound: result.filesFound,
|
|
created: result.created.map((entry) => {
|
|
const invoice = invoices.find((row) => row.id === entry.invoiceId) || null
|
|
return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record<string, any>) : null }
|
|
}),
|
|
skipped: [
|
|
...result.skipped,
|
|
...(fileIds || []).filter((id) => !handled.has(id))
|
|
.map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })),
|
|
],
|
|
}
|
|
},
|
|
},
|
|
{
|
|
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.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",
|
|
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_accounts.balances",
|
|
title: "Kontostände abfragen",
|
|
description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.",
|
|
requiredPermissions: ["accounting.bank.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." },
|
|
asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." },
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const account = numberArg(args, "account")
|
|
const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf")
|
|
const result = await loadBankBalances(context.server, context.tenantId, { account, asOf })
|
|
if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden")
|
|
return result
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.bank_statements.list",
|
|
title: "Bankumsätze auflisten",
|
|
description: "Listet Bankumsätze des aktiven Mandanten.",
|
|
requiredPermissions: ["accounting.bank.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(bankstatements.tenant, context.tenantId)]
|
|
const account = numberArg(args, "account")
|
|
|
|
if (account) conditions.push(eq(bankstatements.account, account))
|
|
conditions.push(eq(bankstatements.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(bankstatements)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.bank_statements.get",
|
|
title: "Bankumsatz laden",
|
|
description: "Lädt einen Bankumsatz des aktiven Mandanten anhand seiner ID.",
|
|
requiredPermissions: ["accounting.bank.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(bankstatements)
|
|
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
|
return { bankStatement: rows[0] }
|
|
},
|
|
},
|
|
{
|
|
name: "accounting.statement_allocations.list",
|
|
title: "Buchungszuordnungen auflisten",
|
|
description: "Listet Buchungszuordnungen des aktiven Mandanten.",
|
|
requiredPermissions: ["accounting.statement_allocations.read"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
bankstatement: { type: "number" },
|
|
incominginvoice: { type: "number" },
|
|
...paginationProperties,
|
|
},
|
|
},
|
|
async handler(context, args) {
|
|
const conditions = [eq(statementallocations.tenant, context.tenantId)]
|
|
const bankstatement = numberArg(args, "bankstatement")
|
|
const incominginvoice = numberArg(args, "incominginvoice")
|
|
|
|
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
|
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
|
conditions.push(eq(statementallocations.archived, false))
|
|
|
|
const rows = await context.server.db
|
|
.select()
|
|
.from(statementallocations)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(statementallocations.created_at), statementallocations.id)
|
|
.limit(limitFromArgs(args) + 1)
|
|
.offset(offsetFromArgs(args))
|
|
|
|
return paginatedResult(rows, args)
|
|
},
|
|
},
|
|
{
|
|
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: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." },
|
|
costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." },
|
|
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 }
|
|
},
|
|
},
|
|
]
|