feat(mcp): support serial invoice workflows
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
34
backend/tests/mcpSerialInvoices.test.ts
Normal 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"])
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user