From f4fdf6fe30da913c4886a420df52c3aa733fd160 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sun, 11 Oct 2026 13:08:29 +0200 Subject: [PATCH] feat(mcp): add outgoing document status and calendar tools --- backend/src/mcp/tools/accounting.ts | 2 + backend/src/mcp/tools/calendar.ts | 166 +++++++++++++++++ backend/src/mcp/tools/organisation.ts | 49 +---- backend/src/mcp/tools/outgoingStatus.ts | 130 ++++++++++++++ backend/src/modules/bootstrap.service.ts | 1 + backend/tests/mcpCalendarStatus.test.ts | 219 +++++++++++++++++++++++ backend/tests/mcpPagination.test.ts | 7 +- docs/technik/mcp.md | 57 ++++++ 8 files changed, 582 insertions(+), 49 deletions(-) create mode 100644 backend/src/mcp/tools/calendar.ts create mode 100644 backend/src/mcp/tools/outgoingStatus.ts create mode 100644 backend/tests/mcpCalendarStatus.test.ts diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 76acc705..40f51baa 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -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", diff --git a/backend/src/mcp/tools/calendar.ts b/backend/src/mcp/tools/calendar.ts new file mode 100644 index 00000000..534095a0 --- /dev/null +++ b/backend/src/mcp/tools/calendar.ts @@ -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) { + 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) { + 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) { + 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) => mutateEvent(context, action, args), + })), +] diff --git a/backend/src/mcp/tools/organisation.ts b/backend/src/mcp/tools/organisation.ts index fed6865b..6f4b7eac 100644 --- a/backend/src/mcp/tools/organisation.ts +++ b/backend/src/mcp/tools/organisation.ts @@ -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, key: string) => { @@ -17,6 +18,7 @@ const numberArg = (args: Record, 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", diff --git a/backend/src/mcp/tools/outgoingStatus.ts b/backend/src/mcp/tools/outgoingStatus.ts new file mode 100644 index 00000000..8c3a265e --- /dev/null +++ b/backend/src/mcp/tools/outgoingStatus.ts @@ -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) { + 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> = [] + 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) + } + }, + }, +] diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index c0ace7ff..5ea42b84 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -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", diff --git a/backend/tests/mcpCalendarStatus.test.ts b/backend/tests/mcpCalendarStatus.test.ts new file mode 100644 index 00000000..2e986163 --- /dev/null +++ b/backend/tests/mcpCalendarStatus.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { randomUUID } from "node:crypto" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { Pool } from "pg" +import { drizzle } from "drizzle-orm/node-postgres" +import { eq, getTableColumns, getTableName } from "drizzle-orm" +import * as schema from "../db/schema" +import { mcpToolMap, mcpTools } from "../src/mcp/registry" +import { assertToolPermission } from "../src/mcp/authz" +import { asToolResult } from "../src/mcp/result" +import { eventDateTimeField, prepareEventInput, calendarTools } from "../src/mcp/tools/calendar" +import { outgoingDocumentStatus, outgoingStatusTools } from "../src/mcp/tools/outgoingStatus" + +const execute = promisify(execFile) +const userId = "00000000-0000-4000-8000-000000000001" +const invoice = { id: 1, state: "Gebucht", type: "invoices", rows: [{ quantity: 1, price: 100, taxPercent: 19 }], usedAdvanceInvoices: [] } + +test("registers read-only document status and permission-protected calendar tools", () => { + assert.equal(new Set(mcpTools.map(tool => tool.name)).size, mcpTools.length) + for (const tool of [...calendarTools, ...outgoingStatusTools]) { + assert.equal(mcpToolMap.get(tool.name), tool) + assert.throws(() => assertToolPermission({ isAdmin: false, permissions: [] } as any, tool), { statusCode: 403 }) + assert.doesNotThrow(() => assertToolPermission({ isAdmin: false, permissions: tool.requiredPermissions } as any, tool)) + } + for (const tool of outgoingStatusTools) assert.deepEqual(tool.requiredPermissions, ["accounting.outgoing_documents.read"]) + for (const action of ["create", "update", "archive"]) { + assert.deepEqual(mcpToolMap.get(`organisation.events.${action}`)!.requiredPermissions, ["organisation.events.write"]) + } +}) + +test("payment status handles drafts, partial payments, credits and advance invoices", () => { + const status = (document: any, paid: number, cancellations: any[] = [], advances: any[] = []) => outgoingDocumentStatus(document, paid, cancellations, advances) + assert.equal(status(invoice, 0).paymentStatus, "Offen") + assert.equal(status(invoice, 50).paymentStatus, "Teilbezahlt") + assert.equal(status(invoice, 119).paymentStatus, "Bezahlt") + assert.equal(status(invoice, 120).paymentStatus, "Überbezahlt") + assert.equal(status({ ...invoice, state: "Entwurf" }, 119).paymentStatus, null) + assert.equal(status({ ...invoice, type: "quotes" }, 0).paymentStatus, null) + const cancelled = status(invoice, 0, [{ id: 2, documentNumber: "ST-2" }]) + assert.equal(cancelled.cancelled, true) + assert.equal(cancelled.paymentStatus, null) + assert.deepEqual(cancelled.cancellationDocuments, [{ id: 2, documentNumber: "ST-2" }]) + const advance = { id: 3, rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] } + assert.equal(status({ ...invoice, usedAdvanceInvoices: [3] }, 95.2, [], [advance]).paymentStatus, "Bezahlt") + assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -50).paymentStatus, "Teilbezahlt") + assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -120).paymentStatus, "Überbezahlt") + assert.equal(status({ ...invoice, rows: [] }, 0).paymentStatus, "Bezahlt") + const mixedTax = { ...invoice, rows: [{ quantity: 1, price: 0.03, taxPercent: 7 }, { quantity: 1, price: 0.02, taxPercent: 19 }] } + assert.equal(status(mixedTax, 0.05).paymentStatus, "Bezahlt", "separate tax buckets must match the outgoing-document UI") + assert.throws(() => status({ ...invoice, rows: [{ quantity: 1, price: "invalid" }] }, 0), /Positionsbetrag/) + assert.throws(() => status(invoice, NaN), /Betrag kann nicht berechnet/) +}) + +test("calendar validates explicit timezones, dates, editable fields and typed resources", () => { + assert.equal(eventDateTimeField.parse("2026-10-15T09:00:00+02:00", "startDate").toISOString(), "2026-10-15T07:00:00.000Z") + for (const value of ["2026-10-15", "2026-10-15T09:00:00", "2026-02-30T09:00:00Z", "2026-10-15T24:00:00Z", "2026-10-15T09:60:00Z", "bad", null]) { + assert.throws(() => eventDateTimeField.parse(value, "startDate")) + } + for (const args of [ + { id: 1 }, { id: -1, name: "Test" }, { id: 1, tenant: 2 }, { id: 1, updatedBy: userId }, + { id: 1, state: "Bezahlt" }, { id: 1, repeatInterval: "irgendwann" }, + { id: 1, profiles: [1] }, { id: 1, vehicles: ["1"] }, { id: 1, name: " " }, { id: 1, resources: [] }, + ]) assert.throws(() => prepareEventInput("update", args)) + assert.throws(() => prepareEventInput("create", { name: "Test" }), /startDate/) + assert.deepEqual(prepareEventInput("update", { id: 1, endDate: null, profiles: [], vehicles: [1, 1] }), { id: 1, endDate: null, profiles: [], vehicles: [1] }) +}) + +// An isolated database and local fixtures only; no production configuration is loaded. +test("calendar and outgoing status integration", { skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE }, async t => { + const container = "fedeo-mcp-calendar-" + randomUUID() + const password = randomUUID() + await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${password}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!]) + let pool: Pool | undefined + t.after(async () => { await pool?.end(); await execute("docker", ["rm", "-f", container]) }) + const { stdout } = await execute("docker", ["port", container, "5432/tcp"]) + pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 }) + for (let attempt = 0; ; attempt++) { + try { await pool.query("SELECT 1"); break } catch (error) { + if (attempt >= 80) throw error + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + const tables = [schema.events, schema.createddocuments, schema.statementallocations, schema.historyitems, schema.tenants, + schema.projects, schema.customers, schema.vendors, schema.authProfiles, schema.vehicles, schema.inventoryitems, schema.inventoryitemgroups] + for (const table of tables) { + const columns = Object.entries(getTableColumns(table)).map(([key, column]) => { + let suffix = "" + if (key === "id") suffix = column.getSQLType() === "uuid" ? " PRIMARY KEY DEFAULT gen_random_uuid()" : " GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" + else if (key === "archived" || key === "quick") suffix = " DEFAULT false" + else if (key === "active") suffix = " DEFAULT true" + else if (key === "createdAt") suffix = " DEFAULT now()" + else if (["profiles", "vehicles", "inventoryitems", "inventoryitemgroups", "resources", "rows", "usedAdvanceInvoices"].includes(key)) { + suffix = column.getSQLType().endsWith("[]") ? ` DEFAULT '{}'::${column.getSQLType()}` : " DEFAULT '[]'::jsonb" + } + else if (table === schema.events && key === "state") suffix = " DEFAULT 'Final'" + else if (table === schema.events && key === "repeatInterval") suffix = " DEFAULT 'Keine Wiederholung'" + else if (table === schema.events && key === "eventtype") suffix = " DEFAULT 'Umsetzung'" + return `"${column.name}" ${column.columnType === "PgEnumColumn" ? "text" : column.getSQLType()}${suffix}` + }) + await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(", ")})`) + } + const db = drizzle(pool) as any + const call = async (name: string, args: any = {}, tenantId = 1) => { + const context = { server: { db }, tenantId, userId, isAdmin: false, + permissions: ["organisation.events.read", "organisation.events.write", "accounting.outgoing_documents.read"] } as any + const tool = mcpToolMap.get(name)! + assert.ok(tool, name) + assertToolPermission(context, tool) + return await tool.handler(context, args) as any + } + const calendar = (name: string, args: any = {}, tenant = 1) => call(`organisation.events.${name}`, args, tenant) + const status = (name: string, args: any, tenant = 1) => call(`accounting.outgoing_documents.status.${name}`, args, tenant) + await db.insert(schema.tenants).values([{ id: 1, calendarConfig: { eventTypes: [{ label: "Büro", color: "blue" }] } }, { id: 2 }]) + await db.insert(schema.projects).values([{ id: 1, tenant: 1, name: "Own" }, { id: 2, tenant: 2, name: "Foreign" }, { id: 3, tenant: 1, name: "Archived", archived: true }]) + await db.insert(schema.vehicles).values([{ id: 1, tenant: 1 }, { id: 2, tenant: 2 }]) + const ownProfile = randomUUID(), portalProfile = randomUUID(), group = randomUUID() + await db.insert(schema.authProfiles).values([{ id: ownProfile, tenant_id: 1 }, { id: portalProfile, tenant_id: 1, customer_for_portal: 5 }]) + await db.insert(schema.inventoryitemgroups).values({ id: group, tenant: 1 }) + + await t.test("create, update and archive preserve metadata, history and tenant isolation", async () => { + const options = await call("organisation.calendar.options") + assert.equal(options.calendarConfig.eventTypes[0].label, "Büro") + const { event } = await calendar("create", { name: "Besprechung", startDate: "2026-10-15T09:00:00+02:00", endDate: "2026-10-15T10:00:00+02:00", + project: 1, profiles: [ownProfile], vehicles: [1], inventoryitemgroups: [group], repeatInterval: "Wöchentlich" }) + assert.equal(event.state, "Final") + assert.equal(event.updatedBy, userId) + assert.equal(event.tenant, 1) + assert.equal(event.startDate.toISOString(), "2026-10-15T07:00:00.000Z") + await assert.rejects(calendar("get", { id: event.id }, 2), /nicht gefunden/) + await assert.rejects(calendar("update", { id: event.id, name: "wrong" }, 2), /nicht gefunden/) + await assert.rejects(calendar("archive", { id: event.id }, 2), /nicht gefunden/) + for (const patch of [{ project: 2 }, { project: 3 }, { profiles: [portalProfile] }, { vehicles: [2] }, { inventoryitemgroups: [randomUUID()] }]) { + await assert.rejects(calendar("update", { id: event.id, ...patch }), /nicht verfügbar/) + } + await assert.rejects(calendar("update", { id: event.id, startDate: "2026-10-15T11:00:00+02:00" }), /endDate/) + const changed = await calendar("update", { id: event.id, name: "Planung", endDate: null, profiles: [], project: null, state: "Entwurf" }) + assert.equal(changed.event.endDate, null) + assert.deepEqual(changed.event.profiles, []) + assert.equal(changed.event.repeatInterval, "Wöchentlich") + assert.equal(changed.event.project, null) + const history = await db.select().from(schema.historyitems).where(eq(schema.historyitems.event, event.id)) + assert.deepEqual(history.map(row => row.action), ["created", "updated"]) + assert.ok(history.every(row => row.tenant === 1 && row.createdBy === userId)) + await pool!.query("ALTER TABLE historyitems ADD CONSTRAINT reject_new_history CHECK (false) NOT VALID") + await assert.rejects(calendar("update", { id: event.id, name: "Must roll back" })) + assert.equal((await calendar("get", { id: event.id })).event.name, "Planung") + await pool!.query("ALTER TABLE historyitems DROP CONSTRAINT reject_new_history") + assert.deepEqual(asToolResult(await calendar("archive", { id: event.id })).structuredContent, { id: event.id, success: true, action: "archived" }) + await assert.rejects(calendar("get", { id: event.id }), /archiviert/) + assert.equal((await calendar("list", {})).rows.length, 0) + }) + + await t.test("calendar range and resource filters handle overlaps and older recurring series", async () => { + const create = async (name: string, startDate: string, endDate: string | null, extra: any = {}, tenant = 1) => + (await calendar("create", { name, startDate, endDate, ...extra }, tenant)).event + await create("Before", "2026-10-10T09:00:00Z", "2026-10-10T10:00:00Z") + await create("Ends at boundary", "2026-10-10T09:00:00Z", "2026-10-15T00:00:00Z") + const spanning = await create("Overlapping", "2026-10-14T23:00:00Z", "2026-10-15T01:00:00Z", { profiles: [ownProfile], vehicles: [1], state: "Entwurf", quick: true }) + const inside = await create("Inside", "2026-10-15T10:00:00Z", null) + await create("End excluded", "2026-10-16T00:00:00Z", null) + const recurring = await create("Older series", "2020-10-01T09:00:00Z", "2020-10-01T10:00:00Z", { repeatInterval: "Wöchentlich" }) + await create("Foreign", "2026-10-15T10:00:00Z", null, {}, 2) + const range = { from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z" } + const listed = await calendar("list", range) + assert.deepEqual(listed.rows.map(row => row.id), [inside.id, spanning.id, recurring.id]) + assert.deepEqual((await calendar("list", { ...range, includeRecurring: false })).rows.map(row => row.id), [inside.id, spanning.id]) + assert.deepEqual((await calendar("list", { ...range, profile: ownProfile, vehicle: 1, state: "Entwurf", quick: true })).rows.map(row => row.id), [spanning.id]) + await assert.rejects(calendar("list", { from: range.to, to: range.from }), /from/) + }) + + await t.test("status ignores foreign/archived payments and draft cancellations and applies advances", async () => { + await db.insert(schema.createddocuments).values([ + { ...invoice, id: 100, tenant: 1, documentNumber: "RE-100", usedAdvanceInvoices: [103] }, + { ...invoice, id: 101, tenant: 2 }, { ...invoice, id: 102, tenant: 1, archived: true }, + { ...invoice, id: 103, tenant: 1, type: "advanceInvoices", rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] }, + { ...invoice, id: 104, tenant: 1, type: "cancellationInvoices", state: "Entwurf", createddocument: 100 }, + { ...invoice, id: 105, tenant: 2, type: "cancellationInvoices", createddocument: 100 }, + { ...invoice, id: 106, tenant: 1, type: "cancellationInvoices", createddocument: 100, archived: true }, + ]) + await db.insert(schema.statementallocations).values([ + { tenant: 1, createddocument: 100, amount: 95.2 }, { tenant: 1, createddocument: 100, amount: 500, archived: true }, + { tenant: 2, createddocument: 100, amount: 700 }, + ]) + const result = (await status("get", { id: 100 })).status + assert.equal(result.paymentStatus, "Bezahlt") + assert.equal(result.amount, 95.2) + assert.equal(result.paidAmount, 95.2) + assert.equal(result.openAmount, 0) + assert.equal(result.cancelled, false) + await assert.rejects(status("get", { id: 101 }), /nicht gefunden/) + await assert.rejects(status("get", { id: 102 }), /archiviert/) + await db.insert(schema.createddocuments).values({ ...invoice, id: 107, tenant: 1, documentNumber: "ST-107", type: "cancellationInvoices", createddocument: 100 }) + const cancelled = (await status("get", { id: 100 })).status + assert.equal(cancelled.paymentStatus, null) + assert.deepEqual(cancelled.cancellationDocuments, [{ id: 107, documentNumber: "ST-107" }]) + assert.deepEqual((await status("list", { cancelled: true })).rows.map(row => row.id), [100]) + }) + + await t.test("computed status filters precede pagination across several database batches", async () => { + const documents = Array.from({ length: 410 }, (_, index) => ({ ...invoice, id: 1000 + index, tenant: 1, project: 999, + createdAt: new Date("2026-10-01T00:00:00Z") })) + await db.insert(schema.createddocuments).values(documents) + await db.insert(schema.statementallocations).values(documents.filter((_, i) => i % 2 === 0).map(document => ({ tenant: 1, createddocument: document.id, amount: 119 }))) + const ids: number[] = [] + for (const offset of [0, 100, 200]) { + const result = await status("list", { state: "Gebucht", paymentStatus: "Bezahlt", cancelled: false, project: 999, limit: 100, offset }) + assert.equal(result.rows.length, offset < 200 ? 100 : 5) + assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null) + ids.push(...result.rows.map(row => row.id)) + } + assert.deepEqual(ids, documents.filter((_, i) => i % 2 === 0).map(document => document.id)) + assert.equal((await status("list", { project: 999, paymentStatus: "Teilbezahlt" })).rows.length, 0) + assert.equal((await status("list", { project: 999, offset: 400, limit: 100 })).rows.length, 10) + await assert.rejects(status("list", { paymentStatus: "unknown" }), /Zahlungsstatus/) + await assert.rejects(status("get", { id: 100, state: "Entwurf" }), /unbekanntes Feld/) + }) +}) diff --git a/backend/tests/mcpPagination.test.ts b/backend/tests/mcpPagination.test.ts index 97645197..fb43766c 100644 --- a/backend/tests/mcpPagination.test.ts +++ b/backend/tests/mcpPagination.test.ts @@ -10,8 +10,9 @@ const tables = Object.values(schema).filter((value: any) => { try { return typeof getTableName(value) === "string" } catch { return false } }) -// Document tools also check portal profiles and join inbox receipts; covered in mcpDocuments.test.ts. -for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset && !tool.name.startsWith("documents."))) { +// Document/status tools load related records; their dedicated tests cover filtered pagination. +for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset && !tool.name.startsWith("documents.") + && tool.name !== "accounting.outgoing_documents.status.list")) { test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => { const db = drizzle(async (sql, params) => { if (sql.includes('from "tenants"')) return { rows: [["skr03"]] } @@ -43,7 +44,7 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any const ids: unknown[] = [] let offset = 0 for (let page = 0; page < 3; page++) { - const result: any = await tool.handler(context, { id: 1, limit: 100, offset }) + const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset }) assert.equal(result.rows.length, page < 2 ? 100 : 5) assert.equal(result.pagination.hasMore, page < 2) assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null) diff --git a/docs/technik/mcp.md b/docs/technik/mcp.md index 6472883a..efd2594f 100644 --- a/docs/technik/mcp.md +++ b/docs/technik/mcp.md @@ -1,5 +1,62 @@ # FEDEO MCP +## Status der Ausgangsbelege + +`accounting.outgoing_documents.status.get` lädt den Status eines Belegs anhand seiner numerischen `id`. `accounting.outgoing_documents.status.list` liefert dieselben Statusdaten mit kombinierbaren Filtern und Pagination: + +| Filter | Bedeutung | +| --- | --- | +| `state` | Gespeicherter Buchungsstatus, z. B. `Entwurf` oder `Gebucht` | +| `paymentStatus` | `Offen`, `Teilbezahlt`, `Bezahlt` oder `Überbezahlt` | +| `cancelled` | Ob ein aktiver, fertiggestellter Stornobeleg verknüpft ist | +| `type`, `customer`, `project` | Belegart, Kunden-ID und Projekt-ID | +| `limit`, `offset` | Seitengröße und Startposition innerhalb der gefilterten Ergebnisse | + +Beispiele für die Parameter von `tools/call`: + +```json +{"name":"accounting.outgoing_documents.status.get","arguments":{"id":42}} +``` + +```json +{"name":"accounting.outgoing_documents.status.list","arguments":{"state":"Gebucht","paymentStatus":"Teilbezahlt","cancelled":false,"limit":25,"offset":0}} +``` + +Das Ergebnis enthält `state`, `cancelled`, `cancellationDocuments`, `paymentStatus`, `amount`, `paidAmount` und `openAmount` sowie Belegnummer, Belegart, Datum, Kunden- und Projekt-ID. Der Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen. Bei Entwürfen, Angeboten, anderen Belegarten und stornierten Rechnungen ist `paymentStatus` gleich `null`. + +Die Beträge folgen der Ausgangsbeleganzeige: Positionsnetto auf drei Nachkommastellen, Nettosumme und Steuergruppen (19 % / 7 %) separat auf zwei Nachkommastellen, anschließend Abzug verknüpfter Abschlagsrechnungen. Zahlungen und Stornobelege müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Stornoentwürfe zählen nicht. `openAmount` ist Belegbetrag abzüglich zugeordneter Zahlungen, auch wenn der Beleg storniert wurde. Es ist kein um den Stornobeleg saldierter Kontostand. Bei negativen Rechnungsbeträgen werden Rückzahlungen in der passenden Zahlungsrichtung ausgewertet. + +Beide Werkzeuge benötigen `accounting.outgoing_documents.read` und verändern keine Belege oder Buchungen. Die berechneten Statusfilter werden vor `limit`/`offset` angewendet. Dafür liest das Backend bei Bedarf mehrere Stapel von jeweils 100 Belegen; große Filter-Offsets können entsprechend mehr Abfragen benötigen. + +## Kalender und Termine + +| Werkzeug | Funktion | +| --- | --- | +| `organisation.calendar.options` | Konfigurierte Terminarten, Status und Wiederholungsintervalle laden | +| `organisation.events.list` | Termine und Serien mit Such-, Zeitraum-, Status- und Ressourcenfiltern auflisten | +| `organisation.events.get` | Einen Termin anhand seiner numerischen `id` laden | +| `organisation.events.create` | Termin anlegen; `name` und `startDate` sind Pflichtfelder | +| `organisation.events.update` | Nur angegebene Felder eines Termins ändern | +| `organisation.events.archive` | Termin oder gesamte Wiederholungsserie archivieren | + +Lesende Werkzeuge benötigen `organisation.events.read`. Die Schreibwerkzeuge benötigen `organisation.events.write`; dieses Recht ist in den Bootstrap-Administratorrechten enthalten und muss anderen Rollen ausdrücklich zugewiesen werden. Schreibvorgänge protokollieren Benutzer, Zeitpunkt und Änderung in der Historie. Referenzen auf Projekte, Kunden, Lieferanten, Mitarbeiter, Fahrzeuge und Inventar werden auf Verfügbarkeit im aktiven Mandanten geprüft. + +```json +{"name":"organisation.events.create","arguments":{"name":"Projektbesprechung","startDate":"2026-10-15T09:00:00+02:00","endDate":"2026-10-15T10:00:00+02:00","state":"Final","repeatInterval":"Wöchentlich","project":42}} +``` + +Zeitpunkte erfordern ISO-8601 mit `Z` oder explizitem Offset. Das Ende darf nicht vor dem Start liegen; `endDate: null` ist erlaubt. Statuswerte sind `Entwurf` und `Final` (Standard). Wiederholungen sind `Keine Wiederholung` (Standard), `Täglich`, `Wöchentlich`, `2-wöchentlich`, `Monatlich` und `Jährlich`. Weitere bearbeitbare Felder sind `eventtype`, `quick`, `color`, `notes`, `link`, `customer`, `vendor`, `profiles`, `vehicles`, `inventoryitems` und `inventoryitemgroups`. + +`profiles` und `inventoryitemgroups` sind Listen von UUIDs; `vehicles` und `inventoryitems` enthalten numerische IDs. Beim Update ersetzen übergebene Listen die bisherigen Zuordnungen; `[]` leert eine Liste, `null` leert nullable Einzelfelder. Wiederholungsänderungen und Archivierung betreffen die gesamte Serie. + +```json +{"name":"organisation.events.list","arguments":{"from":"2026-10-12T00:00:00+02:00","to":"2026-10-19T00:00:00+02:00","state":"Final","limit":25}} +``` + +`from`/`to` filtern überlappende Termine, wobei `to` exklusiv ist. Mit `profile`, `vehicle`, `inventoryitem` oder `inventoryitemgroup` lässt sich nach einzelnen Ressourcenzuordnungen filtern. Weitere Filter sind `query`, `project`, `customer`, `vendor`, `eventtype`, `state`, `quick` und `repeatInterval`. + +Die Terminliste liefert gespeicherte Serien-Stammsätze, keine berechneten Einzelvorkommen. Bei gesetztem `from` liefert sie standardmäßig auch früher begonnene Wiederholungsserien als Kandidaten mit. `includeRecurring: false` beschränkt die Zeitraumprüfung auf den gespeicherten Start und das Ende. Auch bei Serien gelten `limit`/`offset` für Stammsätze. Abwesenheiten und Kalender-Abonnementlinks gehören nicht zu diesen Werkzeugen. + ## Dokumentenablage und Dateiübertragung Die Werkzeuge unter `documents.*` stellen die Dokumentenablage und den Zugriff auf gespeicherte Dateien bereit. Alle arbeiten im aktiven Mandanten und schließen archivierte Dateien aus. Portalbenutzer haben keinen Zugriff, auch wenn ihrer Rolle Dokumentenrechte zugewiesen wurden.