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",

View File

@@ -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/)
})
})

View File

@@ -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)