feat(mcp): support serial invoice workflows

This commit is contained in:
2026-09-09 14:30:42 +02:00
parent d0bc8a5e0f
commit 2ec162c0e7
5 changed files with 172 additions and 22 deletions

View File

@@ -10,12 +10,14 @@ import {
folders, folders,
incominginvoices, incominginvoices,
ownaccounts, ownaccounts,
serialExecutions,
statementallocations, statementallocations,
vendors, vendors,
} from "../../../db/schema" } from "../../../db/schema"
import { useNextNumberRangeNumber } from "../../utils/functions" import { useNextNumberRangeNumber } from "../../utils/functions"
import { saveFile } from "../../utils/files" import { saveFile } from "../../utils/files"
import { insertHistoryItem } from "../../utils/history" import { insertHistoryItem } from "../../utils/history"
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
import { import {
prepareStatementAllocationInput, prepareStatementAllocationInput,
statementAllocationUuidArg, statementAllocationUuidArg,
@@ -43,6 +45,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value)) const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024 const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const serialConfigSchema = {
type: "object",
description: "Ausführungsplan einer Serienrechnungsvorlage.",
properties: {
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
intervall: {
type: "string",
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
},
active: { type: "boolean" },
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
},
}
const allowedOutgoingDocumentTypes = new Set([ const allowedOutgoingDocumentTypes = new Set([
"quotes", "quotes",
@@ -501,7 +519,7 @@ export const accountingTools: McpTool[] = [
type: "object", type: "object",
required: ["type"], required: ["type"],
properties: { properties: {
type: { type: "string" }, type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
customer: { type: "number" }, customer: { type: "number" },
contact: { type: "number" }, contact: { type: "number" },
contract: { type: "number" }, contract: { type: "number" },
@@ -529,6 +547,7 @@ export const accountingTools: McpTool[] = [
availableInPortal: { type: "boolean" }, availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" }, customSurchargePercentage: { type: "number" },
report: { type: "object" }, report: { type: "object" },
serialConfig: serialConfigSchema,
}, },
}, },
async handler(context, args) { async handler(context, args) {
@@ -555,7 +574,7 @@ export const accountingTools: McpTool[] = [
required: ["id"], required: ["id"],
properties: { properties: {
id: { type: "number" }, id: { type: "number" },
type: { type: "string" }, type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
state: { type: "string" }, state: { type: "string" },
customer: { type: "number" }, customer: { type: "number" },
contact: { type: "number" }, contact: { type: "number" },
@@ -584,6 +603,7 @@ export const accountingTools: McpTool[] = [
availableInPortal: { type: "boolean" }, availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" }, customSurchargePercentage: { type: "number" },
report: { type: "object" }, report: { type: "object" },
serialConfig: serialConfigSchema,
}, },
}, },
async handler(context, args) { async handler(context, args) {
@@ -668,6 +688,90 @@ export const accountingTools: McpTool[] = [
return { document: updated } return { document: updated }
}, },
}, },
{
name: "accounting.serial_invoices.execute",
title: "Serienrechnungslauf starten",
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["executionDate", "templateIds"],
properties: {
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
templateIds: {
type: "array",
minItems: 1,
uniqueItems: true,
items: { type: "number" },
},
},
},
async handler(context, args) {
const executionDate = stringArg(args, "executionDate")
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
const templateIds = Array.isArray(args.templateIds)
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
: []
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
}
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
return executeManualGeneration(
context.server,
parsedExecutionDate,
templateIds,
context.tenantId,
context.userId,
)
},
},
{
name: "accounting.serial_invoice_executions.list",
title: "Serienrechnungsläufe auflisten",
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["draft", "completed", "error"] },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
const status = stringArg(args, "status")
if (status) conditions.push(eq(serialExecutions.status, status))
const rows = await context.server.db
.select()
.from(serialExecutions)
.where(and(...conditions))
.orderBy(desc(serialExecutions.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "accounting.serial_invoice_executions.finish",
title: "Serienrechnungslauf abschließen",
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
},
},
async handler(context, args) {
const id = stringArg(args, "id")
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
return finishManualGeneration(context.server, id, context.tenantId)
},
},
{ {
name: "accounting.outgoing_documents.archive", name: "accounting.outgoing_documents.archive",
title: "Ausgangsbeleg archivieren", title: "Ausgangsbeleg archivieren",

View File

@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => { export const executeManualGeneration = async (
try { server: FastifyInstance,
console.log(executedBy) executionDate: string | Date,
templateIds: number[],
tenantId: number,
executedBy: string,
) => {
const executionDayjs = dayjs(executionDate); const executionDayjs = dayjs(executionDate);
@@ -40,13 +44,14 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
and( and(
eq(schema.createddocuments.tenant, tenantId), eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.type, "serialInvoices"), eq(schema.createddocuments.type, "serialInvoices"),
eq(schema.createddocuments.archived, false),
inArray(schema.createddocuments.id, templateIds) inArray(schema.createddocuments.id, templateIds)
) )
); );
if (templates.length === 0) { if (templates.length === 0) {
console.warn("Keine passenden Vorlagen gefunden."); console.warn("Keine passenden Vorlagen gefunden.");
return []; throw new Error("Keine passenden Serienrechnungsvorlagen gefunden.");
} }
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten) // 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
@@ -88,13 +93,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
} }
} }
return results; return { execution: executionRecord, results };
} catch (error) {
console.log(error);
}
} }
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => { export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
try { try {
console.log(`Beende Ausführung ${executionId}...`); console.log(`Beende Ausführung ${executionId}...`);
@@ -103,15 +105,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const [executionRecord] = await server.db const [executionRecord] = await server.db
.select() .select()
.from(schema.serialExecutions)// @ts-ignore .from(schema.serialExecutions)// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId)) .where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
))
.limit(1); .limit(1);
if (!executionRecord) throw new Error("Execution nicht gefunden"); if (!executionRecord) throw new Error("Execution nicht gefunden");
console.log(executionRecord); console.log(executionRecord);
const tenantId = executionRecord.tenant;
console.log(tenantId) console.log(tenantId)
// Tenant laden (für Settings etc.) // Tenant laden (für Settings etc.)
@@ -132,7 +135,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const documents = await server.db const documents = await server.db
.select() .select()
.from(schema.createddocuments) .from(schema.createddocuments)
.where(eq(schema.createddocuments.serialexecution, executionId)); .where(and(
eq(schema.createddocuments.serialexecution, executionId),
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.archived, false),
));
console.log(`${documents.length} Dokumente werden finalisiert...`); console.log(`${documents.length} Dokumente werden finalisiert...`);
@@ -228,7 +235,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
status: finalStatus, status: finalStatus,
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.` summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
})// @ts-ignore })// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId)); .where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
return { success: true, processed: successCount, errors: errorCount }; return { success: true, processed: successCount, errors: errorCount };
@@ -240,7 +250,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
.update(schema.serialExecutions) .update(schema.serialExecutions)
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." }) .set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
//@ts-ignore //@ts-ignore
.where(eq(schema.serialExecutions.id, executionId)); .where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
throw error; throw error;
} }
} }

View File

@@ -296,15 +296,14 @@ export default async function functionRoutes(server: FastifyInstance) {
}) })
server.post('/functions/serial/start', async (req, reply) => { server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body) const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number} return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
}) })
server.post('/functions/serial/finish/:execution_id', async (req, reply) => { server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string } const {execution_id} = req.params as { execution_id: string }
//@ts-ignore //@ts-ignore
await finishManualGeneration(server,execution_id) return finishManualGeneration(server, execution_id, req.user.tenant_id)
}) })
server.post('/functions/services/bankstatementsync', async (req, reply) => { server.post('/functions/services/bankstatementsync', async (req, reply) => {

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mcpToolMap } from "../src/mcp/registry"
test("publishes the complete serial invoice configuration on create and update", () => {
for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) {
const tool = mcpToolMap.get(name)
const properties = tool?.inputSchema.properties as Record<string, any>
assert.ok(properties.serialConfig, name)
assert.deepEqual(properties.serialConfig.properties.intervall.enum, [
"wöchentlich",
"2 - wöchentlich",
"monatlich",
"vierteljährlich",
"halbjährlich",
"jährlich",
])
assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"])
assert.ok(properties.type.enum.includes("serialInvoices"))
}
})
test("registers tenant-scoped serial invoice execution tools", () => {
const execute = mcpToolMap.get("accounting.serial_invoices.execute")
const list = mcpToolMap.get("accounting.serial_invoice_executions.list")
const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish")
assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"])
assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"])
assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"])
assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"])
assert.deepEqual(finish?.inputSchema.required, ["id"])
})

View File

@@ -652,7 +652,7 @@ const executeSerialInvoices = async () => {
toast.add({ toast.add({
title: 'Ausführung gestartet', title: 'Ausführung gestartet',
description: `${res.length} Rechnungen werden im Hintergrund generiert.`, description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
icon: 'i-heroicons-check-circle', icon: 'i-heroicons-check-circle',
color: 'green' color: 'green'
}) })