feat(mcp): add outgoing document status and calendar tools

This commit is contained in:
2026-10-11 13:08:29 +02:00
parent 899e5bb339
commit f4fdf6fe30
8 changed files with 582 additions and 49 deletions

View File

@@ -27,6 +27,7 @@ import {
statementAllocationUuidArg,
} from "../statementAllocations"
import { McpTool } from "../types"
import { outgoingStatusTools } from "./outgoingStatus"
export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -423,6 +424,7 @@ const assertStatementAllocationReferences = async (context: any, payload: Record
}
export const accountingTools: McpTool[] = [
...outgoingStatusTools,
{
name: "accounting.outgoing_documents.tax_types.list",
title: "Steuertypen für Ausgangsbelege auflisten",

View File

@@ -0,0 +1,166 @@
import { and, desc, eq, ilike, inArray, isNull, lt, or, sql } from "drizzle-orm"
import { authProfiles, customers, events, inventoryitemgroups, inventoryitems, projects, tenants, vehicles, vendors } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
import { arrayField, booleanField, dateField, idField, InputField, nullable, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
export const eventStates = ["Entwurf", "Final"]
export const eventRepeatIntervals = ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"]
const enumField = (values: string[]): InputField => ({
schema: { type: "string", enum: values },
parse(value, path) {
if (typeof value !== "string" || !values.includes(value)) throw new Error(`${path}: ungültiger Wert`)
return value
},
})
export const eventDateTimeField: InputField = {
schema: { type: "string", format: "date-time", description: "ISO-Zeitpunkt mit Zeitzone, z. B. 2026-10-15T09:00:00+02:00." },
parse(value, path) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
throw new Error(`${path}: ISO-Zeitpunkt mit Zeitzone erwartet`)
}
dateField.parse(value.slice(0, 10), path)
if (Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59
|| !Number.isFinite(Date.parse(value))) throw new Error(`${path}: ungültiger Zeitpunkt`)
return new Date(value)
},
}
const idsField = (field: InputField): InputField => ({
schema: { ...arrayField(field).schema, maxItems: 1000, uniqueItems: true },
parse(value, path) {
if (!Array.isArray(value) || value.length > 1000) throw new Error(`${path}: maximal 1000 IDs erwartet`)
return [...new Set(arrayField(field).parse(value, path))]
},
})
const eventFields = {
name: textField(true), startDate: eventDateTimeField, endDate: nullable(eventDateTimeField),
eventtype: nullable(textField(true)), quick: booleanField, state: enumField(eventStates), color: nullable(textField()),
repeatInterval: enumField(eventRepeatIntervals), notes: nullable(textField()), link: nullable(textField()),
project: nullable(idField), customer: nullable(idField), vendor: nullable(idField),
profiles: idsField(uuidField), vehicles: idsField(idField), inventoryitems: idsField(idField), inventoryitemgroups: idsField(uuidField),
}
const listFields = {
query: textField(), project: idField, customer: idField, vendor: idField, eventtype: textField(true),
from: eventDateTimeField, to: eventDateTimeField, state: enumField(eventStates), quick: booleanField,
repeatInterval: enumField(eventRepeatIntervals), profile: uuidField, vehicle: idField, inventoryitem: idField, inventoryitemgroup: uuidField,
includeRecurring: { ...booleanField, schema: { type: "boolean", default: true, description: "Bei from auch früher begonnene Wiederholungsserien liefern. Das Ergebnis enthält Serien-Stammsätze, keine einzelnen Vorkommen." } },
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const eventScope = (tenantId: number, id: number) => and(eq(events.id, id), eq(events.tenant, tenantId), eq(events.archived, false))
export function prepareEventInput(action: "create" | "update" | "archive", args: Record<string, unknown>) {
const input = objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).parse(args, "arguments")
for (const key of action === "create" ? ["name", "startDate"] : ["id"]) {
if (input[key] === undefined) throw new Error(`${key} ist erforderlich`)
}
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
return input
}
async function validateEventReferences(db: any, tenantId: number, input: Record<string, any>) {
const references = [
["project", projects], ["customer", customers], ["vendor", vendors], ["profiles", authProfiles],
["vehicles", vehicles], ["inventoryitems", inventoryitems], ["inventoryitemgroups", inventoryitemgroups],
] as const
for (const [field, table] of references) {
const value = input[field]
const ids = value == null ? [] : Array.isArray(value) ? value : [value]
if (!ids.length) continue
const scope = table === authProfiles
? and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal))
: and(eq((table as any).tenant, tenantId), eq((table as any).archived, false))
const found = await db.select({ id: table.id }).from(table).where(and(inArray(table.id, ids), scope)).for("share")
if (found.length !== ids.length) throw new Error(`${field}: verknüpfter Datensatz im aktiven Mandanten nicht verfügbar`)
}
}
async function mutateEvent(context: McpContext, action: "create" | "update" | "archive", args: Record<string, unknown>) {
const { id, ...input } = prepareEventInput(action, args)
return context.server.db.transaction(async tx => {
let existing: typeof events.$inferSelect | undefined
if (action !== "create") {
;[existing] = await tx.select().from(events).where(eventScope(context.tenantId, id)).limit(1).for("update")
if (!existing) throw new Error("Termin nicht gefunden oder archiviert")
}
const merged = { ...existing, ...input }
if (action !== "archive") {
if (merged.endDate && new Date(merged.endDate).getTime() < new Date(merged.startDate).getTime()) {
throw new Error("endDate darf nicht vor startDate liegen")
}
await validateEventReferences(tx, context.tenantId, input)
}
const change = { ...(action === "archive" ? { archived: true } : input), updatedAt: new Date(), updatedBy: context.userId }
const [event] = action === "create"
? await tx.insert(events).values({ ...change, tenant: context.tenantId, name: input.name, startDate: input.startDate }).returning()
: await tx.update(events).set(change).where(eventScope(context.tenantId, id)).returning()
await insertHistoryItem({ ...context.server, db: tx } as any, { tenant_id: context.tenantId, created_by: context.userId,
entity: "events", entityId: event.id, action: action === "create" ? "created" : action === "archive" ? "archived" : "updated",
oldVal: existing, newVal: event })
return action === "archive" ? { id: event.id, success: true, action: "archived" } : { event }
})
}
export const calendarTools: McpTool[] = [
{
name: "organisation.calendar.options", title: "Kalenderoptionen laden",
description: "Lädt die konfigurierten Terminarten des Mandanten sowie erlaubte Status und Wiederholungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField({}).schema,
async handler(context, args) {
objectField({}).parse(args, "arguments")
const [tenant] = await context.server.db.select({ calendarConfig: tenants.calendarConfig }).from(tenants).where(eq(tenants.id, context.tenantId)).limit(1)
if (!tenant) throw new Error("Mandant nicht gefunden")
return { calendarConfig: tenant.calendarConfig, states: eventStates, repeatIntervals: eventRepeatIntervals }
},
},
{
name: "organisation.events.list", title: "Kalendertermine auflisten",
description: "Listet aktive Termine und Serien-Stammsätze mit Text-, Zeitraum-, Status- und Ressourcenfiltern. from/to begrenzen Überlappungen (to exklusiv); früher begonnene Serien werden standardmäßig mitgeliefert, ohne einzelne Wiederholungen auszurechnen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
if (input.from && input.to && input.from >= input.to) throw new Error("from muss vor to liegen")
const conditions = [eq(events.tenant, context.tenantId), eq(events.archived, false)]
if (input.query) {
const query = `%${input.query.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(ilike(events.name, query), ilike(events.notes, query), ilike(events.link, query)))
}
for (const key of ["project", "customer", "vendor", "eventtype", "state", "quick", "repeatInterval"]) {
if (input[key] !== undefined) conditions.push(eq(events[key], input[key]))
}
for (const [key, column] of [["profile", events.profiles], ["vehicle", events.vehicles], ["inventoryitem", events.inventoryitems], ["inventoryitemgroup", events.inventoryitemgroups]] as const) {
if (input[key] !== undefined) conditions.push(sql`${column} @> ${JSON.stringify([input[key]])}::jsonb`)
}
if (input.to) conditions.push(lt(events.startDate, input.to))
if (input.from) conditions.push(or(
sql`${events.endDate} > ${input.from.toISOString()}::timestamptz`,
sql`${events.startDate} >= ${input.from.toISOString()}::timestamptz`,
input.includeRecurring !== false ? inArray(events.repeatInterval, eventRepeatIntervals.slice(1)) : undefined,
))
const rows = await context.server.db.select().from(events).where(and(...conditions)).orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
return paginatedResult(rows, input)
},
},
{
name: "organisation.events.get", title: "Kalendertermin laden", description: "Lädt einen aktiven Termin einschließlich Wiederholung und Ressourcenzuordnungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const input = prepareEventInput("archive", args)
const [event] = await context.server.db.select().from(events).where(eventScope(context.tenantId, input.id)).limit(1)
if (!event) throw new Error("Termin nicht gefunden oder archiviert")
return { event }
},
},
...(["create", "update", "archive"] as const).map(action => ({
name: `organisation.events.${action}`, title: action === "create" ? "Kalendertermin erstellen" : action === "update" ? "Kalendertermin bearbeiten" : "Kalendertermin archivieren",
description: action === "create" ? "Erstellt einen Termin, standardmäßig Final und ohne Wiederholung. Zeitpunkte benötigen eine explizite Zeitzone."
: action === "update" ? "Ändert nur angegebene Felder. Ressourcenlisten werden ersetzt; null leert optionale Felder. Bei Wiederholungen wird die gesamte Serie geändert."
: "Archiviert einen Termin bzw. seine gesamte Wiederholungsserie.",
requiredPermissions: ["organisation.events.write"],
inputSchema: { ...objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).schema,
required: action === "create" ? ["name", "startDate"] : ["id"] },
handler: (context: McpContext, args: Record<string, unknown>) => mutateEvent(context, action, args),
})),
]

View File

@@ -1,9 +1,10 @@
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { customers, events, plants, projects, tasks } from "../../../db/schema"
import { customers, plants, projects, tasks } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
import { activateProjectPhase } from "../projectPhases"
import { McpTool } from "../types"
import { calendarTools } from "./calendar"
const stringArg = (args: Record<string, unknown>, key: string) => {
@@ -17,6 +18,7 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
}
export const organisationTools: McpTool[] = [
...calendarTools,
{
name: "organisation.customers.search",
title: "Kunden suchen",
@@ -238,51 +240,6 @@ export const organisationTools: McpTool[] = [
return paginatedResult(rows, args)
},
},
{
name: "organisation.events.list",
title: "Termine auflisten",
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
requiredPermissions: ["organisation.events.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
...paginationProperties,
},
},
async handler(context, args) {
const conditions = [eq(events.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
const eventtype = stringArg(args, "eventtype")
if (query) {
conditions.push(or(
ilike(events.name, `%${query}%`),
ilike(events.notes, `%${query}%`),
ilike(events.link, `%${query}%`)
))
}
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(args) + 1)
.offset(offsetFromArgs(args))
return paginatedResult(rows, args)
},
},
{
name: "organisation.tasks.list",
title: "Aufgaben auflisten",

View File

@@ -0,0 +1,130 @@
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { createddocuments, statementallocations } from "../../../db/schema"
import { booleanField, idField, InputField, objectField, textField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const paymentStates = ["Offen", "Teilbezahlt", "Bezahlt", "Überbezahlt"]
const paymentField: InputField = {
schema: { type: "string", enum: paymentStates },
parse(value) {
if (typeof value !== "string" || !paymentStates.includes(value)) throw new Error("Unbekannter Zahlungsstatus")
return value
},
}
const listFields = {
state: textField(true), type: textField(true), customer: idField, project: idField,
cancelled: booleanField, paymentStatus: paymentField,
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const money = (value: number) => Number(value.toFixed(2))
function documentAmount(document: any, advanceInvoices: any[]) {
let net = 0, tax19 = 0, tax7 = 0, advanceAmount = 0
for (const row of document.rows || []) {
if (["pagebreak", "title", "text"].includes(row.mode)) continue
const values = [row.quantity, row.price, row.discountPercent, row.taxPercent].map(value => Number(value ?? 0))
if (!values.every(Number.isFinite)) throw new Error(`Beleg ${document.id}: ungültiger Positionsbetrag`)
const [quantity, price, discount, tax] = values
const rowNet = Number((quantity * price * (1 - discount / 100)).toFixed(3))
net += rowNet
if (tax === 19) tax19 += rowNet * 0.19
if (tax === 7) tax7 += rowNet * 0.07
}
for (const id of document.usedAdvanceInvoices || []) {
const row = advanceInvoices.find(invoice => invoice.id === id)?.rows?.find(row => row.advanceInvoiceData)
if (row) advanceAmount += Number(row.price) * ((100 + Number(row.taxPercent)) / 100)
}
// Match the outgoing-document screen (useSum): round net, 19% and 7% separately.
// Aggregating both tax buckets before rounding can falsely report a one-cent debt.
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
}
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
const amount = documentAmount(document, advanceInvoices)
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount)
const cancelled = cancellations.length > 0
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
// Signed invoices and refunds use the same payment-direction comparison.
const direction = Math.sign(amount) || 1
const paymentStatus = !applicable ? null : openAmount === 0 ? "Bezahlt"
: openAmount * direction < 0 ? "Überbezahlt" : paidAmount * direction > 0 ? "Teilbezahlt" : "Offen"
return {
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
state: document.state, customer: document.customer, project: document.project,
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
}
}
async function statusesFor(context: McpContext, documents: Array<typeof createddocuments.$inferSelect>) {
if (!documents.length) return []
const ids = documents.map(document => document.id)
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
const [allocations, cancellations, advances] = await Promise.all([
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
ne(createddocuments.state, "Entwurf"), inArray(createddocuments.createddocument, ids))).orderBy(createddocuments.id),
advanceIds.length ? context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
])
return documents.map(document => outgoingDocumentStatus(document,
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
}
export const outgoingStatusTools: McpTool[] = [
{
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const { id } = objectField({ id: idField }).parse(args, "arguments")
if (id === undefined) throw new Error("id ist erforderlich")
const documents = 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 (!documents.length) throw new Error("Ausgangsbeleg nicht gefunden oder archiviert")
const [status] = await statusesFor(context, documents)
return { status }
},
},
{
name: "accounting.outgoing_documents.status.list", title: "Ausgangsbelege nach Status filtern",
description: "Listet Status und Beträge aktiver Ausgangsbelege. Kombinierbare Filter: state (z. B. Entwurf/Gebucht), paymentStatus, cancelled, type, customer und project. Statusfilter werden vor der Pagination angewendet. Archivierte Zahlungen und Stornos zählen nicht.",
requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
const conditions = [eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)]
for (const key of ["state", "type", "customer", "project"]) {
if (input[key] !== undefined) conditions.push(eq(createddocuments[key], input[key]))
}
const limit = limitFromArgs(input)
const offset = offsetFromArgs(input)
const query = (size: number, start: number) => context.server.db.select().from(createddocuments).where(and(...conditions))
.orderBy(desc(createddocuments.createdAt), createddocuments.id).limit(size).offset(start)
if (input.paymentStatus === undefined && input.cancelled === undefined) {
return paginatedResult(await statusesFor(context, await query(limit + 1, offset)), input)
}
// Derived statuses need related payments/credit documents. Scan bounded batches so filtering
// happens before pagination without loading an entire tenant's documents into memory.
const rows: Awaited<ReturnType<typeof statusesFor>> = []
let matched = 0
for (let start = 0; ; start += 100) {
const documents = await query(100, start)
for (const status of await statusesFor(context, documents)) {
if (input.paymentStatus !== undefined && status.paymentStatus !== input.paymentStatus) continue
if (input.cancelled !== undefined && status.cancelled !== input.cancelled) continue
if (matched++ >= offset) rows.push(status)
if (rows.length > limit) return paginatedResult(rows, input)
}
if (documents.length < 100) return paginatedResult(rows, input)
}
},
},
]

View File

@@ -57,6 +57,7 @@ const adminPermissions = [
"organisation.projects.write",
"organisation.plants.read",
"organisation.events.read",
"organisation.events.write",
"organisation.tasks.read",
"organisation.tasks.write",
"wiki.read",