From 02ab26771d29b300903d0a31166776c7dca44e78 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 10:58:33 +0000 Subject: [PATCH 1/6] =?UTF-8?q?MCP-Bankzuweisungen=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/mcp/statementAllocations.ts | 141 ++++++++++++++++ backend/src/mcp/tools/accounting.ts | 156 ++++++++++++++++++ backend/src/modules/bootstrap.service.ts | 1 + backend/tests/mcpStatementAllocations.test.ts | 114 +++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 backend/src/mcp/statementAllocations.ts create mode 100644 backend/tests/mcpStatementAllocations.test.ts diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts new file mode 100644 index 0000000..c1c33e6 --- /dev/null +++ b/backend/src/mcp/statementAllocations.ts @@ -0,0 +1,141 @@ +const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const statementAllocationTargetFields = [ + "createddocument", + "incominginvoice", + "account", + "ownaccount", + "customer", + "vendor", +] as const + +const manualDebitFields = ["account", "customer", "vendor", "ownaccount"] as const +const manualCreditFields = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] as const +const bookingModes = new Set(["expense", "depreciation_single", "depreciation_bundle"]) +const depreciationMethods = new Set(["linear", "degressive"]) + +const requiredNumber = (args: Record, key: string) => { + const value = Number(args[key]) + if (!hasValue(args[key]) || !Number.isFinite(value)) throw new Error(`${key} muss eine gültige Zahl sein`) + return value +} + +const optionalNumericId = (args: Record, key: string) => { + if (!hasValue(args[key])) return undefined + const value = Number(args[key]) + if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} muss eine gültige ID sein`) + return value +} + +export const statementAllocationUuidArg = (args: Record, key: string) => { + if (!hasValue(args[key])) return undefined + const value = String(args[key]).trim() + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new Error(`${key} muss eine gültige UUID sein`) + } + return value +} + +const validDate = (value: string | null) => + Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))) + +export const prepareStatementAllocationInput = (args: Record) => { + const payload: Record = {} + const amount = requiredNumber(args, "amount") + const bankstatement = optionalNumericId(args, "bankstatement") + const bookingMode = stringArg(args, "bookingMode") || "expense" + + if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`) + + payload.amount = amount + if (args.description !== undefined) payload.description = stringArg(args, "description") + payload.bookingMode = bookingMode + if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey") + + for (const field of ["createddocument", "incominginvoice", "account", "customer", "vendor"] as const) { + const value = optionalNumericId(args, field) + if (value !== undefined) payload[field] = value + } + for (const field of ["ownaccount", "contraOwnaccount"] as const) { + const value = statementAllocationUuidArg(args, field) + if (value !== undefined) payload[field] = value + } + for (const field of ["contraAccount", "contraCustomer", "contraVendor"] as const) { + const value = optionalNumericId(args, field) + if (value !== undefined) payload[field] = value + } + + if (bankstatement !== undefined) { + const selectedTargets = statementAllocationTargetFields.filter((field) => hasValue(payload[field])) + if (selectedTargets.length !== 1) { + throw new Error("Für eine Bankzuweisung muss genau ein Ziel ausgewählt werden") + } + if (amount === 0) throw new Error("Der Zuweisungsbetrag darf nicht 0 sein") + if (manualCreditFields.some((field) => hasValue(payload[field]))) { + throw new Error("Haben-Konten sind nur bei manuellen Buchungen zulässig") + } + + payload.bankstatement = bankstatement + } else { + const manualBookingDate = stringArg(args, "manualBookingDate") + if (!validDate(manualBookingDate)) { + throw new Error("Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich") + } + if (amount <= 0) throw new Error("Für manuelle Buchungen muss der Betrag größer als 0 sein") + + const manualInvoiceSide = stringArg(args, "manualInvoiceSide") + const hasInvoice = hasValue(payload.incominginvoice) + if (hasInvoice && manualInvoiceSide !== "debit" && manualInvoiceSide !== "credit") { + throw new Error("Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein") + } + if (!hasInvoice && manualInvoiceSide) { + throw new Error("manualInvoiceSide ist nur zusammen mit incominginvoice zulässig") + } + + const debitCount = manualDebitFields.filter((field) => hasValue(payload[field])).length + + (hasInvoice && manualInvoiceSide === "debit" ? 1 : 0) + const creditCount = manualCreditFields.filter((field) => hasValue(payload[field])).length + + (hasInvoice && manualInvoiceSide === "credit" ? 1 : 0) + if (debitCount !== 1 || creditCount !== 1) { + throw new Error("Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden") + } + + payload.bankstatement = null + payload.manualBookingDate = manualBookingDate + if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide + } + + if (bookingMode === "expense") return payload + + const depreciationMonths = requiredNumber(args, "depreciationMonths") + const depreciationStartDate = stringArg(args, "depreciationStartDate") + const depreciationMethod = stringArg(args, "depreciationMethod") || "linear" + const residualValue = args.residualValue === undefined ? 0 : requiredNumber(args, "residualValue") + + if (!Number.isInteger(depreciationMonths) || depreciationMonths <= 0) { + throw new Error("Die Abschreibungsdauer muss eine positive Anzahl Monate sein") + } + if (!validDate(depreciationStartDate)) throw new Error("Ein gültiger Abschreibungsbeginn ist erforderlich") + if (!depreciationMethods.has(depreciationMethod)) { + throw new Error(`Ungültige Abschreibungsmethode: ${depreciationMethod}`) + } + if (residualValue < 0) throw new Error("Der Restwert darf nicht negativ sein") + + payload.depreciationMonths = depreciationMonths + payload.depreciationStartDate = depreciationStartDate + payload.depreciationMethod = depreciationMethod + if (args.depreciationLabel !== undefined) payload.depreciationLabel = stringArg(args, "depreciationLabel") + payload.depreciationGroup = bookingMode === "depreciation_bundle" ? stringArg(args, "depreciationGroup") : null + payload.residualValue = residualValue + + if (bookingMode === "depreciation_bundle" && !payload.depreciationGroup) { + throw new Error("Für eine Sammelabschreibung ist eine Abschreibungsgruppe erforderlich") + } + + return payload +} diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 91c4d16..ae840c3 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -4,16 +4,26 @@ import { accounts, bankstatements, createddocuments, + customers, filetags, files, folders, incominginvoices, + ownaccounts, statementallocations, + vendors, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" import { saveFile } from "../../utils/files" +import { insertHistoryItem } from "../../utils/history" +import { + prepareStatementAllocationInput, + statementAllocationUuidArg, +} from "../statementAllocations" import { McpTool } from "../types" +export { prepareStatementAllocationInput } from "../statementAllocations" + const limitFromArgs = (args: Record, fallback = 25) => { const raw = Number(args.limit ?? fallback) if (!Number.isFinite(raw)) return fallback @@ -357,6 +367,43 @@ const validateIncomingInvoiceData = (invoice: Record) => { } } +const assertTenantEntityExists = async ( + context: any, + table: any, + idColumn: any, + tenantColumn: any | null, + id: unknown, + label: string, +) => { + if (!hasValue(id)) return + + const [row] = await context.server.db + .select({ id: idColumn }) + .from(table) + .where(tenantColumn + ? and(eq(idColumn, id as any), eq(tenantColumn, context.tenantId)) + : eq(idColumn, id as any)) + .limit(1) + + if (!row) throw new Error(`${label} nicht gefunden`) +} + +const assertStatementAllocationReferences = async (context: any, payload: Record) => { + await Promise.all([ + assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"), + assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"), + assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"), + assertTenantEntityExists(context, accounts, accounts.id, null, payload.account, "Sachkonto"), + assertTenantEntityExists(context, accounts, accounts.id, null, payload.contraAccount, "Haben-Sachkonto"), + assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.ownaccount, "Zusätzliches Konto"), + assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.contraOwnaccount, "Zusätzliches Haben-Konto"), + assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.customer, "Debitor"), + assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.contraCustomer, "Haben-Debitor"), + assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.vendor, "Kreditor"), + assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.contraVendor, "Haben-Kreditor"), + ]) +} + export const accountingTools: McpTool[] = [ { name: "accounting.outgoing_documents.tax_types.list", @@ -1175,4 +1222,113 @@ export const accountingTools: McpTool[] = [ return { rows } }, }, + { + name: "accounting.statement_allocations.create", + title: "Bankzuweisung erstellen", + description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.", + requiredPermissions: ["accounting.statement_allocations.write"], + inputSchema: { + type: "object", + required: ["amount"], + properties: { + bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." }, + amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." }, + createddocument: { type: "number", description: "Ausgangsbeleg-ID." }, + incominginvoice: { type: "number", description: "Eingangsbeleg-ID." }, + account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." }, + ownaccount: { type: "string", description: "UUID eines zusätzlichen Kontos beziehungsweise Soll-Kontos." }, + customer: { type: "number", description: "Debitor-ID beziehungsweise Soll-Debitor." }, + vendor: { type: "number", description: "Kreditor-ID beziehungsweise Soll-Kreditor." }, + contraAccount: { type: "number", description: "Haben-Sachkonto für manuelle Buchungen." }, + contraOwnaccount: { type: "string", description: "UUID eines zusätzlichen Haben-Kontos." }, + contraCustomer: { type: "number", description: "Haben-Debitor für manuelle Buchungen." }, + contraVendor: { type: "number", description: "Haben-Kreditor für manuelle Buchungen." }, + manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." }, + manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." }, + description: { type: "string" }, + datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." }, + bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" }, + depreciationMonths: { type: "number", minimum: 1 }, + depreciationStartDate: { type: "string", format: "date" }, + depreciationMethod: { type: "string", enum: ["linear", "degressive"], default: "linear" }, + depreciationLabel: { type: "string" }, + depreciationGroup: { type: "string" }, + residualValue: { type: "number", minimum: 0, default: 0 }, + }, + }, + async handler(context, args) { + const payload = prepareStatementAllocationInput(args) + await assertStatementAllocationReferences(context, payload) + + const [created] = await context.server.db + .insert(statementallocations) + .values({ + ...payload, + tenant: context.tenantId, + updated_at: new Date(), + updated_by: context.userId, + }) + .returning() + + if (created?.bankstatement) { + await insertHistoryItem(context.server, { + entity: "bankstatements", + entityId: Number(created.bankstatement), + action: "created", + created_by: context.userId, + tenant_id: context.tenantId, + oldVal: null, + newVal: created, + text: "Buchung über MCP erstellt", + }) + } + + return { allocation: created } + }, + }, + { + name: "accounting.statement_allocations.delete", + title: "Bankzuweisung löschen", + description: "Löscht eine Bankzuweisung oder manuelle Soll/Haben-Buchung im aktiven Mandanten.", + requiredPermissions: ["accounting.statement_allocations.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "UUID der Bankzuweisung." }, + }, + }, + async handler(context, args) { + const id = statementAllocationUuidArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [existing] = await context.server.db + .select() + .from(statementallocations) + .where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Bankzuweisung nicht gefunden") + + const [deleted] = await context.server.db + .delete(statementallocations) + .where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId))) + .returning() + + if (existing.bankstatement) { + await insertHistoryItem(context.server, { + entity: "bankstatements", + entityId: Number(existing.bankstatement), + action: "deleted", + created_by: context.userId, + tenant_id: context.tenantId, + oldVal: existing, + newVal: null, + text: "Buchung über MCP gelöscht", + }) + } + + return { deleted: true, allocation: deleted } + }, + }, ] diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index d53b9bb..df8a8c9 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -42,6 +42,7 @@ const adminPermissions = [ "accounting.incoming_invoices.write", "accounting.bank.read", "accounting.statement_allocations.read", + "accounting.statement_allocations.write", "organisation.customers.read", "organisation.projects.read", "organisation.plants.read", diff --git a/backend/tests/mcpStatementAllocations.test.ts b/backend/tests/mcpStatementAllocations.test.ts new file mode 100644 index 0000000..4c18ea9 --- /dev/null +++ b/backend/tests/mcpStatementAllocations.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + accountingTools, + prepareStatementAllocationInput, +} from "../src/mcp/tools/accounting" + +const createTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.create") +const deleteTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.delete") + +test("registers write tools for creating and deleting statement allocations", () => { + assert.deepEqual(createTool()?.requiredPermissions, ["accounting.statement_allocations.write"]) + assert.deepEqual(deleteTool()?.requiredPermissions, ["accounting.statement_allocations.write"]) +}) + +test("prepares a direct bank allocation to every target type exposed by the UI", () => { + const base = { + bankstatement: 42, + amount: -125.5, + description: "Teilzahlung", + } + + const targets = [ + ["createddocument", 11], + ["incominginvoice", 12], + ["account", 13], + ["ownaccount", "57d31d62-11a2-47c5-b074-69f9c5ab8bba"], + ["customer", 14], + ["vendor", 15], + ] as const + + for (const [field, value] of targets) { + assert.deepEqual(prepareStatementAllocationInput({ ...base, [field]: value }), { + bankstatement: 42, + amount: -125.5, + description: "Teilzahlung", + bookingMode: "expense", + [field]: value, + }) + } +}) + +test("prepares depreciation metadata for direct account allocations", () => { + assert.deepEqual(prepareStatementAllocationInput({ + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 60, + depreciationStartDate: "2026-08-01", + depreciationMethod: "degressive", + depreciationLabel: "Werkzeug", + depreciationGroup: "BGA 2026", + residualValue: 100, + }), { + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 60, + depreciationStartDate: "2026-08-01", + depreciationMethod: "degressive", + depreciationLabel: "Werkzeug", + depreciationGroup: "BGA 2026", + residualValue: 100, + }) +}) + +test("prepares manual Soll/Haben bookings including incoming invoices", () => { + assert.deepEqual(prepareStatementAllocationInput({ + manualBookingDate: "2026-08-31", + amount: 99.95, + incominginvoice: 7, + manualInvoiceSide: "debit", + contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + datevTaxKey: "9", + description: "Manuelle Buchung", + }), { + bankstatement: null, + manualBookingDate: "2026-08-31", + amount: 99.95, + incominginvoice: 7, + manualInvoiceSide: "debit", + contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + datevTaxKey: "9", + description: "Manuelle Buchung", + bookingMode: "expense", + }) +}) + +test("rejects ambiguous bank targets and invalid manual booking sides", () => { + assert.throws( + () => prepareStatementAllocationInput({ bankstatement: 42, amount: 10, account: 1, vendor: 2 }), + /genau ein Ziel/, + ) + assert.throws( + () => prepareStatementAllocationInput({ manualBookingDate: "2026-08-31", amount: 10, account: 1 }), + /Soll- und ein Haben-Konto/, + ) +}) + +test("rejects incomplete depreciation settings", () => { + assert.throws( + () => prepareStatementAllocationInput({ + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 0, + depreciationStartDate: "2026-08-01", + }), + /Abschreibungsdauer/, + ) +}) From 6afc799f69741a5830ce8a3e290a3448a0aeb01b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 11:08:53 +0000 Subject: [PATCH 2/6] MCP-Projektphasen aktualisierbar machen --- backend/src/mcp/projectPhases.ts | 88 ++++++++++++++++++++++++ backend/src/mcp/tools/organisation.ts | 73 ++++++++++++++++++++ backend/src/modules/bootstrap.service.ts | 1 + backend/tests/mcpProjectPhases.test.ts | 75 ++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 backend/src/mcp/projectPhases.ts create mode 100644 backend/tests/mcpProjectPhases.test.ts diff --git a/backend/src/mcp/projectPhases.ts b/backend/src/mcp/projectPhases.ts new file mode 100644 index 0000000..5e0e522 --- /dev/null +++ b/backend/src/mcp/projectPhases.ts @@ -0,0 +1,88 @@ +export type ProjectPhase = { + key?: string + label?: string + active?: boolean + optional?: boolean + activated_at?: string + activated_by?: string + [key: string]: unknown +} + +type PhaseSelector = { + phaseKey?: string | null + phaseLabel?: string | null +} + +export const activateProjectPhase = ( + value: unknown, + selector: PhaseSelector, + userId: string, + activatedAt = new Date().toISOString(), +) => { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Das Projekt hat keine Phasen") + } + + const phases = value as ProjectPhase[] + const phaseKey = String(selector.phaseKey || "").trim() + const phaseLabel = String(selector.phaseLabel || "").trim() + + if (Boolean(phaseKey) === Boolean(phaseLabel)) { + throw new Error("Genau phaseKey oder phaseLabel ist erforderlich") + } + + const matches = phases + .map((phase, index) => ({ phase, index })) + .filter(({ phase }) => phaseKey ? phase.key === phaseKey : phase.label === phaseLabel) + + if (matches.length === 0) throw new Error("Projektphase nicht gefunden") + if (matches.length > 1) throw new Error("Projektphase ist nicht eindeutig; bitte phaseKey verwenden") + + const { phase: target, index: targetIndex } = matches[0] + const activeIndex = phases.findIndex((phase) => phase.active === true) + const active = activeIndex >= 0 ? phases[activeIndex] : null + + if (target.active) throw new Error(`Projektphase „${target.label || target.key}“ ist bereits aktiv`) + if (target.activated_at) throw new Error(`Projektphase „${target.label || target.key}“ wurde bereits aktiviert`) + + const completesProject = target.label === "Abgeschlossen" + if (!completesProject && activeIndex >= 0) { + if (targetIndex <= activeIndex) { + throw new Error("Eine bereits durchlaufene Projektphase kann nicht erneut aktiviert werden") + } + + const requiredSkippedPhase = phases + .slice(activeIndex + 1, targetIndex) + .find((candidate) => !candidate.optional) + + if (requiredSkippedPhase) { + throw new Error(`Die Phase „${requiredSkippedPhase.label || requiredSkippedPhase.key}“ muss zuerst aktiviert werden`) + } + } else if (!completesProject && activeIndex < 0 && targetIndex !== 0) { + throw new Error(`Die Phase „${phases[0].label || phases[0].key}“ muss zuerst aktiviert werden`) + } + + const nextPhases = phases.map((phase, index) => { + if (index === targetIndex) { + return { + ...phase, + active: true, + activated_at: activatedAt, + activated_by: userId, + } + } + if (phase.active) { + return { + ...phase, + active: false, + } + } + return { ...phase } + }) + + return { + phases: nextPhases, + activePhase: String(target.label || "").trim() || null, + previousPhase: active ? String(active.label || "").trim() || null : null, + } +} diff --git a/backend/src/mcp/tools/organisation.ts b/backend/src/mcp/tools/organisation.ts index 79d00bf..42a8ead 100644 --- a/backend/src/mcp/tools/organisation.ts +++ b/backend/src/mcp/tools/organisation.ts @@ -1,5 +1,7 @@ import { and, desc, eq, ilike, or } from "drizzle-orm" import { customers, events, plants, projects, tasks } from "../../../db/schema" +import { insertHistoryItem } from "../../utils/history" +import { activateProjectPhase } from "../projectPhases" import { McpTool } from "../types" const limitFromArgs = (args: Record, fallback = 25) => { @@ -136,6 +138,77 @@ export const organisationTools: McpTool[] = [ return { project: rows[0] } }, }, + { + name: "organisation.projects.phase.update", + title: "Projektphase aktualisieren", + description: "Aktiviert eine Projektphase anhand ihres Schlüssels oder ihrer eindeutigen Bezeichnung. Pflichtphasen können nicht übersprungen werden; optionale Phasen und der direkte Abschluss entsprechen dem Verhalten der Oberfläche.", + requiredPermissions: ["organisation.projects.write"], + inputSchema: { + type: "object", + required: ["id"], + oneOf: [ + { required: ["phaseKey"] }, + { required: ["phaseLabel"] }, + ], + properties: { + id: { type: "number", description: "Projekt-ID." }, + phaseKey: { type: "string", description: "Technischer Schlüssel der zu aktivierenden Phase; alternativ zu phaseLabel." }, + phaseLabel: { type: "string", description: "Eindeutige Bezeichnung der zu aktivierenden Phase, falls kein phaseKey bekannt ist." }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [existing] = await context.server.db + .select() + .from(projects) + .where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Projekt nicht gefunden") + if (existing.archived) throw new Error("Die Phase eines archivierten Projekts kann nicht geändert werden") + + const transition = activateProjectPhase(existing.phases, { + phaseKey: stringArg(args, "phaseKey"), + phaseLabel: stringArg(args, "phaseLabel"), + }, context.userId) + + const [updated] = await context.server.db + .update(projects) + .set({ + phases: transition.phases, + active_phase: transition.activePhase, + updatedAt: new Date(), + updatedBy: context.userId, + }) + .where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId))) + .returning() + + if (!updated) throw new Error("Projekt nicht gefunden") + + await insertHistoryItem(context.server, { + tenant_id: context.tenantId, + created_by: context.userId, + entity: "projects", + entityId: id, + action: "updated", + oldVal: existing, + newVal: updated, + text: transition.previousPhase + ? `Projektphase von „${transition.previousPhase}“ auf „${transition.activePhase}“ geändert` + : `Projektphase „${transition.activePhase}“ aktiviert`, + }) + + return { + project: updated, + phaseTransition: { + previousPhase: transition.previousPhase, + activePhase: transition.activePhase, + }, + } + }, + }, { name: "organisation.plants.list", title: "Anlagen auflisten", diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index df8a8c9..13c94b7 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -45,6 +45,7 @@ const adminPermissions = [ "accounting.statement_allocations.write", "organisation.customers.read", "organisation.projects.read", + "organisation.projects.write", "organisation.plants.read", "organisation.events.read", "organisation.tasks.read", diff --git a/backend/tests/mcpProjectPhases.test.ts b/backend/tests/mcpProjectPhases.test.ts new file mode 100644 index 0000000..2f5358a --- /dev/null +++ b/backend/tests/mcpProjectPhases.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { organisationTools } from "../src/mcp/tools/organisation" +import { activateProjectPhase } from "../src/mcp/projectPhases" + +const phases = [ + { key: "start", label: "Erstkontakt", active: true, optional: false }, + { key: "planning", label: "Planung", optional: false }, + { key: "review", label: "Prüfung", optional: true }, + { key: "execution", label: "Umsetzung", optional: false }, + { key: "done", label: "Abgeschlossen", optional: false }, +] + +test("registers a write-protected project phase update tool", () => { + const tool = organisationTools.find((candidate) => candidate.name === "organisation.projects.phase.update") + + assert.deepEqual(tool?.requiredPermissions, ["organisation.projects.write"]) + assert.deepEqual((tool?.inputSchema as any).required, ["id"]) +}) + +test("activates the next project phase by key and records actor and timestamp", () => { + const result = activateProjectPhase(phases, { phaseKey: "planning" }, "user-1", "2026-08-31T12:00:00.000Z") + + assert.equal(result.activePhase, "Planung") + assert.equal(result.previousPhase, "Erstkontakt") + assert.equal(result.phases[0].active, false) + assert.deepEqual(result.phases[1], { + key: "planning", + label: "Planung", + optional: false, + active: true, + activated_at: "2026-08-31T12:00:00.000Z", + activated_by: "user-1", + }) +}) + +test("allows skipping optional phases and selecting a unique phase label", () => { + const planningActive = phases.map((phase) => ({ ...phase, active: phase.key === "planning" })) + const result = activateProjectPhase(planningActive, { phaseLabel: "Umsetzung" }, "user-1", "2026-08-31T12:00:00.000Z") + + assert.equal(result.activePhase, "Umsetzung") +}) + +test("rejects skipping required phases", () => { + assert.throws( + () => activateProjectPhase(phases, { phaseKey: "execution" }, "user-1", "2026-08-31T12:00:00.000Z"), + /Planung.*zuerst aktiviert/, + ) +}) + +test("allows completing a project directly like the UI", () => { + const result = activateProjectPhase(phases, { phaseKey: "done" }, "user-1", "2026-08-31T12:00:00.000Z") + + assert.equal(result.activePhase, "Abgeschlossen") +}) + +test("rejects unknown, ambiguous and already activated phases", () => { + assert.throws( + () => activateProjectPhase(phases, { phaseKey: "missing" }, "user-1", "2026-08-31T12:00:00.000Z"), + /nicht gefunden/, + ) + + assert.throws( + () => activateProjectPhase([ + ...phases, + { key: "planning-2", label: "Planung", optional: true }, + ], { phaseLabel: "Planung" }, "user-1", "2026-08-31T12:00:00.000Z"), + /nicht eindeutig/, + ) + + assert.throws( + () => activateProjectPhase(phases, { phaseKey: "start" }, "user-1", "2026-08-31T12:00:00.000Z"), + /bereits aktiv/, + ) +}) From d90499a101c0789120b7050be8ca72052d789d89 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 18:49:40 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Dokumentenvorlagen=20im=20Ausgangsbeleg-Edi?= =?UTF-8?q?tor=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/0065_document_templates.sql | 16 ++++ backend/db/schema/documenttemplates.ts | 39 ++++++++ backend/db/schema/index.ts | 1 + backend/src/utils/resource.config.ts | 4 + .../EntityShowSubCreatedDocuments.vue | 40 ++------ frontend/components/MainNav.vue | 5 + .../createDocumentFromTemplateModal.vue | 94 +++++++++++++++++++ frontend/components/createDocumentModal.vue | 62 ++++++++++++ frontend/pages/createDocument/edit/[[id]].vue | 73 +++++++++++++- frontend/pages/createDocument/index.vue | 14 ++- frontend/pages/createDocument/show/[id].vue | 7 ++ frontend/pages/settings/documenttemplates.vue | 81 ++++++++++++++++ 12 files changed, 397 insertions(+), 39 deletions(-) create mode 100644 backend/db/migrations/0065_document_templates.sql create mode 100644 backend/db/schema/documenttemplates.ts create mode 100644 frontend/components/createDocumentFromTemplateModal.vue create mode 100644 frontend/components/createDocumentModal.vue create mode 100644 frontend/pages/settings/documenttemplates.vue diff --git a/backend/db/migrations/0065_document_templates.sql b/backend/db/migrations/0065_document_templates.sql new file mode 100644 index 0000000..99caf5f --- /dev/null +++ b/backend/db/migrations/0065_document_templates.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "documenttemplates" ( + "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "created_at" timestamptz NOT NULL DEFAULT now(), + "tenant" bigint NOT NULL REFERENCES "tenants"("id"), + "name" text NOT NULL, + "document_type" text NOT NULL, + "template_data" jsonb NOT NULL DEFAULT '{}'::jsonb, + "default" boolean NOT NULL DEFAULT false, + "archived" boolean NOT NULL DEFAULT false, + "updated_at" timestamptz, + "updated_by" uuid REFERENCES "auth_users"("id"), + "created_by" uuid REFERENCES "auth_users"("id") +); + +CREATE INDEX IF NOT EXISTS "documenttemplates_tenant_type_idx" + ON "documenttemplates" ("tenant", "document_type"); \ No newline at end of file diff --git a/backend/db/schema/documenttemplates.ts b/backend/db/schema/documenttemplates.ts new file mode 100644 index 0000000..b9a8c80 --- /dev/null +++ b/backend/db/schema/documenttemplates.ts @@ -0,0 +1,39 @@ +import { + pgTable, + bigint, + text, + timestamp, + boolean, + jsonb, + uuid, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const documenttemplates = pgTable("documenttemplates", { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + tenant: bigint("tenant", { mode: "number" }) + .notNull() + .references(() => tenants.id), + + name: text("name").notNull(), + documentType: text("document_type").notNull(), + templateData: jsonb("template_data").notNull().default({}), + default: boolean("default").notNull().default(false), + archived: boolean("archived").notNull().default(false), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + updatedBy: uuid("updated_by").references(() => authUsers.id), + createdBy: uuid("created_by").references(() => authUsers.id), +}) + +export type DocumentTemplate = typeof documenttemplates.$inferSelect +export type NewDocumentTemplate = typeof documenttemplates.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 3afc23c..e6d1e5e 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -22,6 +22,7 @@ export * from "./contracttypes" export * from "./costcentres" export * from "./countrys" export * from "./createddocuments" +export * from "./documenttemplates" export * from "./createdletters" export * from "./customers" export * from "./customerspaces" diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index bf7e486..476c5cc 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -12,6 +12,7 @@ import { contracttypes, costcentres, createddocuments, + documenttemplates, customerinventoryitems, customerspaces, customers, @@ -237,6 +238,9 @@ export const resourceConfig = { texttemplates: { table: texttemplates }, + documenttemplates: { + table: documenttemplates, + }, incominginvoices: { table: incominginvoices, mtmLoad: ["statementallocations","files"], diff --git a/frontend/components/EntityShowSubCreatedDocuments.vue b/frontend/components/EntityShowSubCreatedDocuments.vue index 272da37..febb98e 100644 --- a/frontend/components/EntityShowSubCreatedDocuments.vue +++ b/frontend/components/EntityShowSubCreatedDocuments.vue @@ -2,6 +2,8 @@ import dayjs from "dayjs"; import {useSum} from "~/composables/useSum.js"; +import CreateDocumentModal from "~/components/createDocumentModal.vue"; +import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"; defineShortcuts({ /*'/': () => { //console.log(searchinput) @@ -56,6 +58,7 @@ const dataStore = useDataStore() const tempStore = useTempStore() const router = useRouter() +const modal = useModal() const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips'] const createddocuments = ref([]) @@ -154,34 +157,17 @@ const selectItem = (item) => { Lieferscheine/Packscheine abrechnen - + Angebot + + Dokument - + Kostenschätzung - - - + Auftragsbestätigung - - - + Lieferschein - - - + Packschein - - - + Abschlagsrechnung + + Dokument aus Vorlage { - - + Rechnung - - +
+ + + + +
@@ -163,12 +164,15 @@ + + From e86e66a6e411b8276e51f9bb9ebc5b34012fdf42 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 18:59:23 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Drizzle-Migration=20f=C3=BCr=20Dokumentenvo?= =?UTF-8?q?rlagen=20registrieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/db/migrations/meta/_journal.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index c707a25..032bf0c 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -428,6 +428,20 @@ "when": 1786287600000, "tag": "0063_skr03_output_tax_account", "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1788202715000, + "tag": "0064_tenant_import_job_status", + "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1788202715001, + "tag": "0065_document_templates", + "breakpoints": true } ] } From 0250cc0dd7f0227c23d47e8f57ef15b0c7836a54 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 19:07:11 +0000 Subject: [PATCH 5/6] Pluszeichen aus Dokumentbuttons entfernen --- frontend/components/EntityShowSubCreatedDocuments.vue | 4 ++-- frontend/pages/createDocument/index.vue | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/EntityShowSubCreatedDocuments.vue b/frontend/components/EntityShowSubCreatedDocuments.vue index febb98e..40ebef8 100644 --- a/frontend/components/EntityShowSubCreatedDocuments.vue +++ b/frontend/components/EntityShowSubCreatedDocuments.vue @@ -160,14 +160,14 @@ const selectItem = (item) => { icon="i-heroicons-plus" @click="modal.open(CreateDocumentModal, { queryStringData: props.queryStringData })" > - + Dokument + Dokument
- + Dokument aus Vorlage + Dokument aus Vorlage - + Dokument + Dokument - + Dokument aus Vorlage + Dokument aus Vorlage From f1ca725d89ee10a96422a617a9dcd7b3ed3d175d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 31 Aug 2026 19:19:01 +0000 Subject: [PATCH 6/6] Vorlagen beim Erstellen als Dokument laden --- frontend/pages/createDocument/edit/[[id]].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 3457654..7dccaf2 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -13,7 +13,7 @@ const router = useRouter() const modal = useModal() const auth = useAuthStore() const toast = useToast() -const isTemplateMode = computed(() => Boolean(route.query.templateFromDocument || route.query.templateId || route.query.mode === "template")) +const isTemplateMode = computed(() => Boolean(route.query.templateFromDocument || route.query.mode === "template")) const templateName = ref("") const quoteLikeDocumentTypes = ["quotes", "costEstimates"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]