Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-08-31 21:37:21 +02:00
20 changed files with 1060 additions and 39 deletions

View File

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

View File

@@ -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
}
]
}

View File

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

View File

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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,141 @@
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const stringArg = (args: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>) => {
const payload: Record<string, any> = {}
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
}

View File

@@ -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<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
@@ -357,6 +367,43 @@ const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
}
}
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<string, any>) => {
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 }
},
},
]

View File

@@ -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<string, unknown>, 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",

View File

@@ -42,8 +42,10 @@ const adminPermissions = [
"accounting.incoming_invoices.write",
"accounting.bank.read",
"accounting.statement_allocations.read",
"accounting.statement_allocations.write",
"organisation.customers.read",
"organisation.projects.read",
"organisation.projects.write",
"organisation.plants.read",
"organisation.events.read",
"organisation.tasks.read",

View File

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

View File

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

View File

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

View File

@@ -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
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
icon="i-heroicons-plus"
@click="modal.open(CreateDocumentModal, { queryStringData: props.queryStringData })"
>
+ Angebot
Dokument
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'costEstimates'})}`)"
icon="i-heroicons-document-duplicate"
variant="outline"
@click="modal.open(CreateDocumentFromTemplateModal, { queryStringData: props.queryStringData })"
>
+ Kostenschätzung
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
>
+ Auftragsbestätigung
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'deliveryNotes'})}`)"
>
+ Lieferschein
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'packingSlips'})}`)"
>
+ Packschein
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
>
+ Abschlagsrechnung
Dokument aus Vorlage
</UButton>
<UButton
@click="showFinalInvoiceConfig = true"
@@ -238,12 +224,6 @@ const selectItem = (item) => {
</UCard>
</template>
</UModal>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)"
>
+ Rechnung
</UButton>
<template #right>
<USelectMenu
v-model="selectedColumns"

View File

@@ -331,6 +331,11 @@ const links = computed(() => {
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsDocumenttemplates") ? {
label: "Dokumentenvorlagen",
to: "/settings/documenttemplates",
icon: "i-heroicons-document-duplicate",
} : null,
featureEnabled("settingsLetterheads") ? {
label: "Briefpapiere",
to: "/settings/letterheads",

View File

@@ -0,0 +1,94 @@
<script setup>
const dataStore = useDataStore()
const modal = useModal()
const router = useRouter()
const templates = ref([])
const selectedType = ref(null)
const loading = ref(true)
const props = defineProps({
queryStringData: { type: String, default: "" },
})
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => key !== 'serialInvoices')
.map(([key, value]) => ({ key, ...value })))
const visibleTemplates = computed(() => templates.value.filter(template => {
return !selectedType.value || template.documentType === selectedType.value
}))
const loadTemplates = async () => {
try {
templates.value = await useEntities('documenttemplates').select('*')
} finally {
loading.value = false
}
}
const selectTemplate = (template) => {
const query = new URLSearchParams(props.queryStringData)
query.set('templateId', template.id)
query.set('type', template.documentType)
router.push(`/createDocument/edit?${query.toString()}`)
modal.close()
}
loadTemplates()
</script>
<template>
<UModal :ui="{ content: 'sm:max-w-3xl' }">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Dokument aus Vorlage erstellen</h2>
<p class="mt-1 text-sm text-muted">Zuerst Dokumenttyp, anschließend die passende Vorlage auswählen.</p>
</div>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="modal.close()" />
</div>
</template>
<UFormField label="Dokumenttyp" required>
<USelectMenu
v-model="selectedType"
:items="documentTypes"
value-key="key"
label-key="labelSingle"
class="w-full"
placeholder="Dokumenttyp auswählen"
/>
</UFormField>
<div class="mt-5 space-y-2">
<p class="text-sm font-medium text-highlighted">Vorlagen</p>
<div v-if="loading" class="py-6 text-center text-sm text-muted">Vorlagen werden geladen </div>
<div v-else-if="visibleTemplates.length === 0" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">
Keine Vorlagen für diesen Dokumenttyp vorhanden.
</div>
<div v-else class="space-y-2">
<button
v-for="template in visibleTemplates"
:key="template.id"
type="button"
class="flex w-full items-center justify-between rounded-lg border border-default p-3 text-left transition hover:border-primary hover:bg-primary/5"
@click="selectTemplate(template)"
>
<span>
<span class="block font-medium text-highlighted">{{ template.name }}</span>
<span class="text-xs text-muted">{{ dataStore.documentTypesForCreation[template.documentType]?.labelSingle }}</span>
</span>
<UBadge v-if="template.default" color="primary" variant="soft">Standard</UBadge>
</button>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<UButton color="neutral" variant="ghost" @click="modal.close()">Abbrechen</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
const dataStore = useDataStore()
const modal = useModal()
const router = useRouter()
const props = defineProps({
queryStringData: { type: String, default: "" },
projectId: { type: [String, Number], default: null },
customerId: { type: [String, Number], default: null },
})
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => key !== 'serialInvoices')
.map(([key, value]) => ({ key, ...value })))
const createDocument = (type) => {
const query = new URLSearchParams(props.queryStringData)
query.set('type', type)
if (props.projectId) query.set('project', props.projectId)
if (props.customerId) query.set('customer', props.customerId)
router.push(`/createDocument/edit?${query.toString()}`)
modal.close()
}
</script>
<template>
<UModal :ui="{ content: 'sm:max-w-3xl' }">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Dokument erstellen</h2>
<p class="mt-1 text-sm text-muted">Wähle den gewünschten Ausgangsbeleg.</p>
</div>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="modal.close()" />
</div>
</template>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<UButton
v-for="documentType in documentTypes"
:key="documentType.key"
block
color="neutral"
variant="outline"
class="flex h-auto min-h-24 flex-col items-start justify-center gap-1 p-4 text-left"
@click="createDocument(documentType.key)"
>
<span class="font-semibold text-highlighted">{{ documentType.labelSingle }}</span>
<span class="text-xs text-muted">{{ documentType.label }}</span>
</UButton>
</div>
<template #footer>
<div class="flex justify-end">
<UButton color="neutral" variant="ghost" @click="modal.close()">Abbrechen</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -13,6 +13,8 @@ const router = useRouter()
const modal = useModal()
const auth = useAuthStore()
const toast = useToast()
const isTemplateMode = computed(() => Boolean(route.query.templateFromDocument || route.query.mode === "template"))
const templateName = ref("")
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const documentStorageFallbackTypes = {
@@ -399,6 +401,24 @@ const setupPage = async () => {
if (route.query) {
if (route.query.type) itemInfo.value.type = route.query.type
if (route.query.templateFromDocument) {
const sourceDocument = await useEntities("createddocuments").selectSingle(route.query.templateFromDocument, '', false)
const sourceData = JSON.parse(JSON.stringify(sourceDocument || {}))
;["id", "createdAt", "tenant", "documentNumber", "documentDate", "state", "customer", "contact", "address", "project", "costcentre", "createddocument", "availableInPortal", "archived", "statementallocations", "files", "linkedDocument", "createddocuments", "serialexecution"].forEach((key) => delete sourceData[key])
Object.assign(itemInfo.value, sourceData)
itemInfo.value.type = route.query.type || sourceDocument?.type || itemInfo.value.type
templateName.value = `${dataStore.documentTypesForCreation[itemInfo.value.type]?.labelSingle || "Dokument"} Vorlage`
}
if (route.query.templateId) {
const template = await useEntities("documenttemplates").selectSingle(route.query.templateId, '', false)
if (template?.templateData) {
Object.assign(itemInfo.value, JSON.parse(JSON.stringify(template.templateData)))
itemInfo.value.type = template.documentType
templateName.value = template.name
}
}
if (!itemInfo.value.startText && !itemInfo.value.endText) {
setDocumentTypeConfig(true)
} else {
@@ -1756,6 +1776,31 @@ const saveSerialInvoice = async () => {
await router.push(`/createDocument/edit/${data.id}`)
}
const serializeTemplateData = () => {
const data = JSON.parse(JSON.stringify(itemInfo.value))
;["id", "createdAt", "tenant", "documentNumber", "documentDate", "state", "customer", "contact", "address", "project", "costcentre", "createddocument", "availableInPortal", "archived", "statementallocations", "files", "linkedDocument", "createddocuments", "serialexecution", "createdBy", "created_by"].forEach((key) => delete data[key])
return data
}
const saveDocumentTemplate = async () => {
if (!templateName.value.trim()) {
toast.add({ title: "Vorlagenname fehlt", description: "Bitte einen Namen für die Vorlage vergeben.", color: "error" })
return
}
const payload = {
name: templateName.value.trim(),
documentType: itemInfo.value.type,
templateData: serializeTemplateData(),
default: false,
}
const template = route.query.templateId
? await useEntities("documenttemplates").update(route.query.templateId, payload, true)
: await useEntities("documenttemplates").create(payload)
toast.add({ title: "Vorlage gespeichert", color: "success" })
await router.push(`/createDocument/edit?mode=template&templateId=${template.id || route.query.templateId}`)
}
const saveDocument = async (state, resetup = false) => {
itemInfo.value.state = state
@@ -2024,7 +2069,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<ArchiveButton
color="error"
type="createddocuments"
v-if="itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices'"
v-if="!isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
variant="outline"
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
router.push('/')"
@@ -2032,27 +2077,46 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<UButton
icon="i-mdi-content-save"
@click="saveDocument('Entwurf',true)"
v-if="itemInfo.type !== 'serialInvoices' "
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
:disabled="!itemInfo.customer"
>
Speichern
</UButton>
<UButton
v-if="isTemplateMode"
icon="i-mdi-content-save"
color="primary"
@click="saveDocumentTemplate"
>
Als Vorlage speichern
</UButton>
<UButton
@click="closeDocument"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices'"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
>
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
</UButton>
<UButton
icon="i-mdi-content-save"
@click="saveSerialInvoice"
v-if="itemInfo.type === 'serialInvoices'"
v-if="itemInfo.type === 'serialInvoices' && !isTemplateMode"
>
Serienrechnung
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div v-if="isTemplateMode" class="px-5 pt-5">
<UAlert
color="primary"
variant="soft"
title="Vorlagenmodus"
description="Dieser Editor speichert ausschließlich eine Dokumentenvorlage. Es wird kein Ausgangsbeleg erstellt."
/>
<UFormField label="Vorlagenname" required class="mt-3">
<UInput v-model="templateName" placeholder="z. B. Standardrechnung" class="w-full" />
</UFormField>
</div>
<UTabs class="p-5" :items="tabItems" @update:model-value="onChangeTab" v-if="loaded" v-model="selectedTab">
<template #content="{item}">
<div v-if="item.label === 'Editor'">
@@ -2084,6 +2148,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<USelectMenu
:items="documentTypeItems"
v-model="itemInfo.type"
:disabled="isTemplateMode"
value-key="type"
label-key="label"
@update:model-value="setDocumentTypeConfig"

View File

@@ -21,10 +21,11 @@
variant="outline"
@click="clearSearchString()"
/>
<UButton
@click="router.push(`/createDocument/edit`)"
>
+ Ausgangsbeleg
<UButton icon="i-heroicons-plus" @click="modal.open(CreateDocumentModal)">
Dokument
</UButton>
<UButton icon="i-heroicons-document-duplicate" variant="outline" @click="modal.open(CreateDocumentFromTemplateModal)">
Dokument aus Vorlage
</UButton>
</template>
</UDashboardNavbar>
@@ -163,12 +164,15 @@
</template>
<script setup>
import CreateDocumentModal from "~/components/createDocumentModal.vue"
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
import dayjs from "dayjs";
import { ref, computed, reactive, watch } from 'vue';
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const modal = useModal()
const quoteLikeDocumentTypes = ['quotes', 'costEstimates']
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
@@ -209,7 +213,7 @@ defineShortcuts({
document.getElementById("searchinput").focus()
},
'+': () => {
router.push('/createDocument/edit')
modal.open(CreateDocumentModal)
},
'Enter': {
usingInput: true,

View File

@@ -226,6 +226,13 @@ const togglePortalRelease = async () => {
>
Kopieren
</UButton>
<UButton
icon="i-heroicons-document-duplicate"
variant="outline"
@click="router.push(`/createDocument/edit?templateFromDocument=${itemInfo.id}&type=${itemInfo.type}`)"
>
Als Vorlage übernehmen
</UButton>
<UButton
@click="openEmail"
icon="i-heroicons-envelope"

View File

@@ -0,0 +1,81 @@
<script setup>
const dataStore = useDataStore()
const router = useRouter()
const toast = useToast()
const templates = ref([])
const loading = ref(true)
const documentTypeItems = computed(() => dataStore.documentTypesForCreation || {})
const refresh = async () => {
loading.value = true
try {
templates.value = await useEntities("documenttemplates").select("*")
} catch (error) {
toast.add({ title: "Vorlagen konnten nicht geladen werden", description: error.message, color: "error" })
} finally {
loading.value = false
}
}
const openNew = () => router.push("/createDocument/edit?mode=template&type=invoices")
const openTemplate = (template) => router.push(`/createDocument/edit?mode=template&templateId=${template.id}`)
const archiveTemplate = async (template) => {
await useEntities("documenttemplates").update(template.id, { archived: true }, true)
await refresh()
}
const typeLabel = (type) => documentTypeItems.value[type]?.labelSingle || type
refresh()
</script>
<template>
<UDashboardNavbar title="Dokumentenvorlagen">
<template #right>
<UButton icon="i-heroicons-plus" @click="openNew">Neue Vorlage</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UAlert
class="mx-5 mt-2"
color="primary"
variant="soft"
title="Vorlagen für alle Ausgangsdokumente"
description="Dokumentenvorlagen verwenden denselben Editor wie die Ausgangsbelege. Jede Einstellung wird als Vorlage gespeichert."
/>
<UTable
class="mt-4"
:data="templates"
:loading="loading"
:columns="normalizeTableColumns([
{ key: 'name', label: 'Bezeichnung' },
{ key: 'documentType', label: 'Dokumenttyp' },
{ key: 'default', label: 'Standard' },
{ key: 'actions', label: '' }
])"
>
<template #name-cell="{ row }">
<span class="font-medium text-highlighted">{{ row.original.name }}</span>
</template>
<template #documentType-cell="{ row }">
<UBadge color="neutral" variant="soft">{{ typeLabel(row.original.documentType) }}</UBadge>
</template>
<template #default-cell="{ row }">
<UIcon v-if="row.original.default" name="i-heroicons-check-circle-20-solid" class="text-green-500" />
<span v-else class="text-muted">-</span>
</template>
<template #actions-cell="{ row }">
<div class="flex justify-end gap-1">
<UButton icon="i-heroicons-pencil-square" color="neutral" variant="ghost" @click="openTemplate(row.original)" />
<UButton icon="i-heroicons-archive-box" color="error" variant="ghost" @click="archiveTemplate(row.original)" />
</div>
</template>
<template #empty>
<TableEmptyState label="Keine Dokumentenvorlagen gefunden" icon="i-heroicons-document-duplicate" />
</template>
</UTable>
</UDashboardPanelContent>
</template>