Compare commits

..

9 Commits

Author SHA1 Message Date
a50ff256ee fix(accounting): validate serial invoice templates
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 43s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 24s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-09 14:38:24 +02:00
ebbd1e0cd0 feat(accounting): edit finalized document cost centres 2026-09-09 14:37:22 +02:00
2ec162c0e7 feat(mcp): support serial invoice workflows 2026-09-09 14:30:42 +02:00
d0bc8a5e0f fix(mcp): exclude archived records 2026-09-09 14:19:34 +02:00
a6f55b61e4 fix: Einheit in Artikelansicht laden
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 22s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-09 14:00:34 +02:00
c383a291e1 Kundeninventar-Nummern kollisionsfrei vergeben
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 21s
Build and Push Docker Images / build-website (push) Successful in 21s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
2026-09-09 13:34:14 +02:00
0eadf42973 fix: Nummernkreise beim Öffnen aktualisieren
All checks were successful
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 21s
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
2026-09-09 13:10:50 +02:00
616bb5d7f3 fix: send employee test push via mobile service
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 43s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-09 13:08:17 +02:00
6297480b89 Heroicons-Picker für Projektphasen ergänzen 2026-09-09 13:07:22 +02:00
23 changed files with 1165 additions and 127 deletions

View File

@@ -1,16 +1,42 @@
import { McpToolResult } from "./types"
const OMIT_ARCHIVED = Symbol("omit-archived")
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
if (Array.isArray(value)) {
return value
.map(omitArchivedRecords)
.filter((item) => item !== OMIT_ARCHIVED)
}
if (!value || typeof value !== "object") return value
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) return value
const record = value as Record<string, unknown>
if (record.archived === true) return OMIT_ARCHIVED
return Object.fromEntries(
Object.entries(record)
.map(([key, item]) => [key, omitArchivedRecords(item)] as const)
.filter(([, item]) => item !== OMIT_ARCHIVED),
)
}
export function asToolResult(payload: unknown): McpToolResult {
const sanitizedPayload = omitArchivedRecords(payload)
const resultPayload = sanitizedPayload === OMIT_ARCHIVED ? {} : sanitizedPayload
const structuredContent =
payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: { result: payload }
resultPayload && typeof resultPayload === "object" && !Array.isArray(resultPayload)
? resultPayload as Record<string, unknown>
: { result: resultPayload }
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
text: JSON.stringify(resultPayload, null, 2),
},
],
structuredContent,
@@ -33,4 +59,3 @@ export function asToolError(error: unknown): McpToolResult {
},
}
}

View File

@@ -10,12 +10,15 @@ import {
folders,
incominginvoices,
ownaccounts,
serialExecutions,
statementallocations,
vendors,
} from "../../../db/schema"
import { useNextNumberRangeNumber } from "../../utils/functions"
import { saveFile } from "../../utils/files"
import { insertHistoryItem } from "../../utils/history"
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
import {
prepareStatementAllocationInput,
statementAllocationUuidArg,
@@ -43,6 +46,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
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([
"quotes",
@@ -437,7 +456,6 @@ export const accountingTools: McpTool[] = [
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
customer: { type: "number" },
project: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -455,7 +473,7 @@ export const accountingTools: McpTool[] = [
if (state) conditions.push(eq(createddocuments.state, state))
if (customer) conditions.push(eq(createddocuments.customer, customer))
if (project) conditions.push(eq(createddocuments.project, project))
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
conditions.push(eq(createddocuments.archived, false))
const rows = await context.server.db
.select()
@@ -486,7 +504,7 @@ export const accountingTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
@@ -502,7 +520,7 @@ export const accountingTools: McpTool[] = [
type: "object",
required: ["type"],
properties: {
type: { type: "string" },
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
customer: { type: "number" },
contact: { type: "number" },
contract: { type: "number" },
@@ -530,6 +548,7 @@ export const accountingTools: McpTool[] = [
availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" },
report: { type: "object" },
serialConfig: serialConfigSchema,
},
},
async handler(context, args) {
@@ -556,7 +575,7 @@ export const accountingTools: McpTool[] = [
required: ["id"],
properties: {
id: { type: "number" },
type: { type: "string" },
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
state: { type: "string" },
customer: { type: "number" },
contact: { type: "number" },
@@ -585,6 +604,7 @@ export const accountingTools: McpTool[] = [
availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" },
report: { type: "object" },
serialConfig: serialConfigSchema,
},
},
async handler(context, args) {
@@ -614,6 +634,72 @@ export const accountingTools: McpTool[] = [
return { document: updated }
},
},
{
name: "accounting.outgoing_documents.cost_centres.update",
title: "Kostenstellen eines Ausgangsbelegs ändern",
description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["id"],
anyOf: [
{ required: ["costcentre"] },
{ required: ["rowCostCentres"] },
],
properties: {
id: { type: "number" },
costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." },
rowCostCentres: {
type: "array",
items: {
type: "object",
required: ["rowId", "costcentre"],
properties: {
rowId: { type: "string", description: "ID der Belegposition." },
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
},
},
},
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const input: {
costcentre?: string | null
rowCostCentres?: Array<{ rowId: string, costcentre: string | null }>
} = {}
if (Object.prototype.hasOwnProperty.call(args, "costcentre")) {
const costcentre = args.costcentre
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw new Error("costcentre muss eine gültige UUID oder null sein")
}
input.costcentre = costcentre as string | null
}
if (args.rowCostCentres !== undefined) {
if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein")
input.rowCostCentres = args.rowCostCentres.map((assignment: any) => {
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
const costcentre = assignment?.costcentre
if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId")
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw new Error(`Ungültige Kostenstelle für Position ${rowId}`)
}
return { rowId, costcentre }
})
}
const document = await updateOutgoingDocumentCostCentres(
context.server,
context.tenantId,
context.userId,
id,
input,
)
return { document }
},
},
{
name: "accounting.outgoing_documents.finalize",
title: "Ausgangsbeleg finalisieren",
@@ -669,6 +755,90 @@ export const accountingTools: McpTool[] = [
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",
title: "Ausgangsbeleg archivieren",
@@ -754,7 +924,6 @@ export const accountingTools: McpTool[] = [
properties: {
state: { type: "string", description: "Optionaler Statusfilter." },
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -764,7 +933,7 @@ export const accountingTools: McpTool[] = [
if (state) conditions.push(eq(incominginvoices.state, state))
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
conditions.push(eq(incominginvoices.archived, false))
const rows = await context.server.db
.select()
@@ -795,7 +964,7 @@ export const accountingTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId), eq(incominginvoices.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
@@ -1142,7 +1311,6 @@ export const accountingTools: McpTool[] = [
type: "object",
properties: {
account: { type: "number", description: "Optionale Bankkonto-ID." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -1151,7 +1319,7 @@ export const accountingTools: McpTool[] = [
const account = numberArg(args, "account")
if (account) conditions.push(eq(bankstatements.account, account))
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
conditions.push(eq(bankstatements.archived, false))
const rows = await context.server.db
.select()
@@ -1182,7 +1350,7 @@ export const accountingTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
@@ -1199,7 +1367,6 @@ export const accountingTools: McpTool[] = [
properties: {
bankstatement: { type: "number" },
incominginvoice: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -1210,7 +1377,7 @@ export const accountingTools: McpTool[] = [
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
conditions.push(eq(statementallocations.archived, false))
const rows = await context.server.db
.select()

View File

@@ -55,7 +55,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(customers)
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId), eq(customers.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Kunde nicht gefunden")
@@ -71,7 +71,6 @@ export const masterdataTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -86,7 +85,7 @@ export const masterdataTools: McpTool[] = [
ilike(vendors.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
conditions.push(eq(vendors.archived, false))
const rows = await context.server.db
.select()
@@ -115,7 +114,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(vendors)
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId), eq(vendors.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
@@ -133,7 +132,6 @@ export const masterdataTools: McpTool[] = [
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
customer: { type: "number" },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -157,7 +155,7 @@ export const masterdataTools: McpTool[] = [
}
if (customer) conditions.push(eq(contacts.customer, customer))
if (vendor) conditions.push(eq(contacts.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
conditions.push(eq(contacts.archived, false))
const rows = await context.server.db
.select()
@@ -178,7 +176,6 @@ export const masterdataTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -197,7 +194,7 @@ export const masterdataTools: McpTool[] = [
ilike(products.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
conditions.push(eq(products.archived, false))
const rows = await context.server.db
.select()
@@ -226,7 +223,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(products)
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId), eq(products.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Artikel nicht gefunden")
@@ -242,7 +239,6 @@ export const masterdataTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -256,7 +252,7 @@ export const masterdataTools: McpTool[] = [
ilike(services.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
conditions.push(eq(services.archived, false))
const rows = await context.server.db
.select()
@@ -285,7 +281,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(services)
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId), eq(services.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Leistung nicht gefunden")
@@ -303,7 +299,6 @@ export const masterdataTools: McpTool[] = [
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
branch: { type: "number" },
project: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -322,7 +317,7 @@ export const masterdataTools: McpTool[] = [
}
if (branch) conditions.push(eq(costcentres.branch, branch))
if (project) conditions.push(eq(costcentres.project, project))
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
conditions.push(eq(costcentres.archived, false))
const rows = await context.server.db
.select()
@@ -351,7 +346,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(costcentres)
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId), eq(costcentres.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
@@ -367,7 +362,6 @@ export const masterdataTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -382,7 +376,7 @@ export const masterdataTools: McpTool[] = [
ilike(branches.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
conditions.push(eq(branches.archived, false))
const rows = await context.server.db
.select()
@@ -404,7 +398,6 @@ export const masterdataTools: McpTool[] = [
properties: {
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
branch: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -420,7 +413,7 @@ export const masterdataTools: McpTool[] = [
))
}
if (branch) conditions.push(eq(teams.branch, branch))
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
conditions.push(eq(teams.archived, false))
const rows = await context.server.db
.select()
@@ -441,7 +434,6 @@ export const masterdataTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -457,7 +449,7 @@ export const masterdataTools: McpTool[] = [
ilike(vehicles.color, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
conditions.push(eq(vehicles.archived, false))
const rows = await context.server.db
.select()
@@ -479,7 +471,6 @@ export const masterdataTools: McpTool[] = [
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -499,7 +490,7 @@ export const masterdataTools: McpTool[] = [
))
}
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
conditions.push(eq(inventoryitems.archived, false))
const rows = await context.server.db
.select()
@@ -528,7 +519,7 @@ export const masterdataTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(inventoryitems)
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId), eq(inventoryitems.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Inventar nicht gefunden")
@@ -568,4 +559,3 @@ export const masterdataTools: McpTool[] = [
},
},
]

View File

@@ -30,7 +30,6 @@ export const organisationTools: McpTool[] = [
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -47,7 +46,7 @@ export const organisationTools: McpTool[] = [
ilike(customers.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
conditions.push(eq(customers.archived, false))
const rows = await context.server.db
.select({
@@ -80,7 +79,6 @@ export const organisationTools: McpTool[] = [
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
customer: { type: "number" },
activePhase: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -100,7 +98,7 @@ export const organisationTools: McpTool[] = [
}
if (customer) conditions.push(eq(projects.customer, customer))
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
conditions.push(eq(projects.archived, false))
const rows = await context.server.db
.select()
@@ -131,7 +129,7 @@ export const organisationTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId), eq(projects.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Projekt nicht gefunden")
@@ -219,7 +217,6 @@ export const organisationTools: McpTool[] = [
properties: {
query: { type: "string", description: "Suchtext für Name." },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -230,7 +227,7 @@ export const organisationTools: McpTool[] = [
if (query) conditions.push(ilike(plants.name, `%${query}%`))
if (customer) conditions.push(eq(plants.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
conditions.push(eq(plants.archived, false))
const rows = await context.server.db
.select()
@@ -254,7 +251,6 @@ export const organisationTools: McpTool[] = [
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -275,7 +271,7 @@ export const organisationTools: McpTool[] = [
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
@@ -298,7 +294,6 @@ export const organisationTools: McpTool[] = [
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
project: { type: "number" },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
@@ -317,7 +312,7 @@ export const organisationTools: McpTool[] = [
}
if (project) conditions.push(eq(tasks.project, project))
if (customer) conditions.push(eq(tasks.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
conditions.push(eq(tasks.archived, false))
const rows = await context.server.db
.select()
@@ -348,7 +343,7 @@ export const organisationTools: McpTool[] = [
const rows = await context.server.db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId), eq(tasks.archived, false)))
.limit(1)
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")

View File

@@ -0,0 +1,146 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, createddocuments } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type RowCostCentreAssignment = {
rowId: string
costcentre: string | null
}
export type OutgoingDocumentCostCentreUpdate = {
costcentre?: string | null
rowCostCentres?: RowCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
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
export function applyRowCostCentreAssignments(
rows: unknown,
assignments: RowCostCentreAssignment[] = [],
) {
if (!Array.isArray(rows)) return []
if (!assignments.length) return rows
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
const existingIds = new Set(rows
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => (row as Record<string, unknown>).id)
.filter((id) => id !== null && id !== undefined && id !== "")
.map(String))
for (const rowId of assignmentMap.keys()) {
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
}
return rows.map((row) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return row
const rowRecord = row as Record<string, unknown>
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
const rowId = String(rowRecord.id)
if (!assignmentMap.has(rowId)) return row
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
})
}
export async function updateOutgoingDocumentCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
documentId: number,
input: OutgoingDocumentCostCentreUpdate,
) {
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
throw serviceError("costcentre muss eine gültige UUID oder null sein")
}
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
throw serviceError("rowCostCentres muss ein Array sein")
}
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
const costcentre = assignment?.costcentre
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
}
return { rowId, costcentre }
})
if (!hasDocumentCostCentre && !rowAssignments.length) {
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
}
const [existing] = await server.db
.select()
.from(createddocuments)
.where(and(
eq(createddocuments.id, documentId),
eq(createddocuments.tenant, tenantId),
eq(createddocuments.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
if (existing.state !== "Gebucht") {
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
}
const referencedCostCentreIds = [...new Set([
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
])]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
const update: Record<string, unknown> = {
rows: updatedRows,
updatedAt: new Date(),
updatedBy: userId,
}
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
const [updated] = await server.db
.update(createddocuments)
.set(update)
.where(and(
eq(createddocuments.id, documentId),
eq(createddocuments.tenant, tenantId),
eq(createddocuments.archived, false),
eq(createddocuments.state, "Gebucht"),
))
.returning()
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "createddocuments",
entityId: documentId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
})
return updated
}

View File

@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear);
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
try {
console.log(executedBy)
export const executeManualGeneration = async (
server: FastifyInstance,
executionDate: string | Date,
templateIds: number[],
tenantId: number,
executedBy: string,
) => {
const executionDayjs = dayjs(executionDate);
@@ -33,20 +37,22 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
// 2. Templates laden
const templates = await server.db
const uniqueTemplateIds = [...new Set(templateIds)]
const templates = (await server.db
.select()
.from(schema.createddocuments)
.where(
and(
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.type, "serialInvoices"),
inArray(schema.createddocuments.id, templateIds)
eq(schema.createddocuments.archived, false),
inArray(schema.createddocuments.id, uniqueTemplateIds)
)
);
))
.filter((template) => Boolean((template.serialConfig as any)?.active));
if (templates.length === 0) {
console.warn("Keine passenden Vorlagen gefunden.");
return [];
if (templates.length !== uniqueTemplateIds.length) {
throw new Error("Mindestens eine Serienrechnungsvorlage wurde nicht gefunden, ist archiviert oder inaktiv.");
}
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
@@ -62,7 +68,7 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
executionDate: executionDayjs.toDate(),
status: "draft",
createdBy: executedBy,
summary: `${templateIds.length} Vorlagen verarbeitet`
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
})
.returning();
@@ -88,13 +94,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
}
}
return results;
} catch (error) {
console.log(error);
}
return { execution: executionRecord, results };
}
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
try {
console.log(`Beende Ausführung ${executionId}...`);
@@ -103,15 +106,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const [executionRecord] = await server.db
.select()
.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);
if (!executionRecord) throw new Error("Execution nicht gefunden");
console.log(executionRecord);
const tenantId = executionRecord.tenant;
console.log(tenantId)
// Tenant laden (für Settings etc.)
@@ -132,7 +136,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const documents = await server.db
.select()
.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...`);
@@ -228,7 +236,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
status: finalStatus,
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
})// @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 };
@@ -240,7 +251,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
.update(schema.serialExecutions)
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
//@ts-ignore
.where(eq(schema.serialExecutions.id, executionId));
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
throw error;
}
}

View File

@@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
@@ -296,15 +297,34 @@ export default async function functionRoutes(server: FastifyInstance) {
})
server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body)
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
})
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string }
//@ts-ignore
await finishManualGeneration(server,execution_id)
return finishManualGeneration(server, execution_id, req.user.tenant_id)
})
server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => {
try {
const { id } = req.params as { id: string }
const documentId = Number(id)
if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" })
const document = await updateOutgoingDocumentCostCentres(
server,
req.user.tenant_id,
req.user.user_id,
documentId,
req.body as any,
)
return { document }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
}
})
server.post('/functions/services/bankstatementsync', async (req, reply) => {

View File

@@ -198,34 +198,51 @@ export default async function notificationsRoutes(server: FastifyInstance) {
})
}
const result = await svc.trigger({
tenantId,
userId: profile.userId,
eventType: "system.test_push",
title: "FEDEO Push ist aktiv",
message: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
payload: {
link: "/",
icon: "/favicon.ico",
},
channels: ["push"],
})
const devices = await server.db
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
.from(notificationMobilePushDevices)
.where(and(
eq(notificationMobilePushDevices.tenantId, tenantId),
eq(notificationMobilePushDevices.userId, profile.userId),
isNull(notificationMobilePushDevices.disabledAt)
))
if (result.created === 0) {
if (!devices.length) {
return reply.code(409).send({
error: "Pushnachrichten sind für diesen Mitarbeiter deaktiviert",
result,
error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert",
})
}
if (!result.success || result.delivered === 0) {
return reply.code(424).send({
error: "Die Pushnachricht konnte an kein aktives Gerät zugestellt werden",
result,
try {
const result = await pushServerClient.sendPush({
idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`,
devices: devices.map((device) => device.centralDeviceId),
priority: "high",
ttlSeconds: 600,
notification: {
title: "FEDEO Push ist aktiv",
body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
},
data: {
type: "system.test_mobile_push",
link: "/",
},
})
if (result.accepted === 0) {
return reply.code(502).send({
error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen",
result,
})
}
return result
} catch (error: any) {
server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen")
return reply.code(502).send({
error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen",
})
}
return result
})
server.post("/notifications/trigger", async (req, reply) => {

View File

@@ -1035,7 +1035,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
const numberRangeColumn = table[config.numberRangeHolder]
const tenantColumn = getTenantColumn(resource, table)
const result = await useNextNumberRangeNumber(
server,
req.user.tenant_id,
numberRangeResource,
async (candidate, tx) => {
const existing = await tx
.select({ id: table.id })
.from(table)
.where(and(
eq(tenantColumn, req.user!.tenant_id),
eq(numberRangeColumn, candidate)
))
.limit(1)
return existing.length === 0
}
)
createData[config.numberRangeHolder] = result.usedNumber
}

View File

@@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm"
export const useNextNumberRangeNumber = async (
server: FastifyInstance,
tenantId: number,
numberRange: string
numberRange: string,
isAvailable: (candidate: string, tx: any) => Promise<boolean> = async () => true
) => {
const numberRangeFallbacks: Record<string, string> = {
costEstimates: "quotes",
@@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async (
}
const current = numberRanges[resolvedNumberRange]
let nextNumber = Number(current.nextNumber)
const usedNumber =
(current.prefix || "") +
current.nextNumber +
(current.suffix || "")
if (!Number.isFinite(nextNumber)) {
throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`)
}
let usedNumber = ""
const maxAttempts = 10000
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidate =
(current.prefix || "") +
nextNumber +
(current.suffix || "")
if (await isAvailable(candidate, tx)) {
usedNumber = candidate
break
}
nextNumber++
}
if (!usedNumber) {
throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`)
}
const updatedRanges = {
// @ts-ignore
...numberRanges,
[resolvedNumberRange]: {
...current,
nextNumber: current.nextNumber + 1,
nextNumber: nextNumber + 1,
},
}

View File

@@ -147,6 +147,7 @@ export const resourceConfig = {
},
products: {
table: products,
mtoLoad: ["unit"],
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
},
productcategories: {

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mcpTools } from "../src/mcp/registry"
import { asToolResult } from "../src/mcp/result"
test("removes archived records from MCP text and structured output", () => {
const result = asToolResult({
rows: [
{ id: 1, archived: false, name: "Aktiv" },
{ id: 2, archived: true, name: "Archiviert" },
],
nested: {
current: { id: 3, archived: false },
previous: { id: 4, archived: true },
},
})
assert.deepEqual(result.structuredContent, {
rows: [{ id: 1, archived: false, name: "Aktiv" }],
nested: { current: { id: 3, archived: false } },
})
assert.deepEqual(JSON.parse(result.content[0].text), result.structuredContent)
})
test("does not expose a singular archived record", () => {
const result = asToolResult({ task: { id: 1, archived: true } })
assert.deepEqual(result.structuredContent, {})
assert.deepEqual(JSON.parse(result.content[0].text), {})
})
test("does not advertise includeArchived on MCP tools", () => {
for (const tool of mcpTools) {
const properties = (tool.inputSchema.properties || {}) as Record<string, unknown>
assert.equal(properties.includeArchived, undefined, tool.name)
}
})

View File

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

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import test from "node:test"
import { useNextNumberRangeNumber } from "../src/utils/functions"
test("number range skips identifiers that are already in use", async () => {
const tenant = {
id: 7,
numberRanges: {
customerinventoryitems: {
prefix: "KIA-",
suffix: "",
nextNumber: 1000,
},
},
}
let savedNumberRanges: any = null
const tx = {
execute: async () => undefined,
select: () => ({
from: () => ({
where: async () => [tenant],
}),
}),
update: () => ({
set: (data: any) => ({
where: async () => {
savedNumberRanges = data.numberRanges
},
}),
}),
}
const server = {
db: {
transaction: async (callback: (transaction: typeof tx) => Promise<any>) => callback(tx),
},
} as any
const occupied = new Set(["KIA-1000", "KIA-1001"])
const result = await useNextNumberRangeNumber(
server,
tenant.id,
"customerinventoryitems",
async (candidate) => !occupied.has(candidate)
)
assert.equal(result.usedNumber, "KIA-1002")
assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003)
})

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import test from "node:test"
import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
test("changes only requested position cost centres", () => {
const rows = [
{ id: "row-1", description: "Montage", price: 100, costCentre: "old-1" },
{ id: "row-2", description: "Material", price: 50, costCentre: "old-2" },
]
assert.deepEqual(applyRowCostCentreAssignments(rows, [
{ rowId: "row-1", costcentre: "new-1" },
]), [
{ id: "row-1", description: "Montage", price: 100, costCentre: "new-1" },
rows[1],
])
})
test("rejects assignments for unknown document positions", () => {
assert.throws(
() => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]),
/Position missing.*nicht gefunden/,
)
})
test("registers a dedicated finalized-document cost centre tool", () => {
const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"])
assert.deepEqual(tool?.inputSchema.required, ["id"])
assert.ok(tool?.inputSchema.anyOf)
})

View File

@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import test from "node:test"
import { resourceConfig } from "../src/utils/resource.config"
test("product details load their configured unit", () => {
assert.ok(resourceConfig.products.mtoLoad.includes("unit"))
})

View File

@@ -0,0 +1,396 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const heroiconNames = [
'academic-cap',
'adjustments-horizontal',
'adjustments-vertical',
'archive-box',
'archive-box-arrow-down',
'archive-box-x-mark',
'arrow-down',
'arrow-down-circle',
'arrow-down-left',
'arrow-down-on-square',
'arrow-down-on-square-stack',
'arrow-down-right',
'arrow-down-tray',
'arrow-left',
'arrow-left-circle',
'arrow-left-end-on-rectangle',
'arrow-left-on-rectangle',
'arrow-left-start-on-rectangle',
'arrow-long-down',
'arrow-long-left',
'arrow-long-right',
'arrow-long-up',
'arrow-path',
'arrow-path-rounded-square',
'arrow-right',
'arrow-right-circle',
'arrow-right-end-on-rectangle',
'arrow-right-on-rectangle',
'arrow-right-start-on-rectangle',
'arrow-small-down',
'arrow-small-left',
'arrow-small-right',
'arrow-small-up',
'arrow-top-right-on-square',
'arrow-trending-down',
'arrow-trending-up',
'arrow-turn-down-left',
'arrow-turn-down-right',
'arrow-turn-left-down',
'arrow-turn-left-up',
'arrow-turn-right-down',
'arrow-turn-right-up',
'arrow-turn-up-left',
'arrow-turn-up-right',
'arrow-up',
'arrow-up-circle',
'arrow-up-left',
'arrow-up-on-square',
'arrow-up-on-square-stack',
'arrow-up-right',
'arrow-up-tray',
'arrow-uturn-down',
'arrow-uturn-left',
'arrow-uturn-right',
'arrow-uturn-up',
'arrows-pointing-in',
'arrows-pointing-out',
'arrows-right-left',
'arrows-up-down',
'at-symbol',
'backspace',
'backward',
'banknotes',
'bars-2',
'bars-3',
'bars-3-bottom-left',
'bars-3-bottom-right',
'bars-3-center-left',
'bars-4',
'bars-arrow-down',
'bars-arrow-up',
'battery-0',
'battery-100',
'battery-50',
'beaker',
'bell',
'bell-alert',
'bell-slash',
'bell-snooze',
'bold',
'bolt',
'bolt-slash',
'book-open',
'bookmark',
'bookmark-slash',
'bookmark-square',
'briefcase',
'bug-ant',
'building-library',
'building-office',
'building-office-2',
'building-storefront',
'cake',
'calculator',
'calendar',
'calendar-date-range',
'calendar-days',
'camera',
'chart-bar',
'chart-bar-square',
'chart-pie',
'chat-bubble-bottom-center',
'chat-bubble-bottom-center-text',
'chat-bubble-left',
'chat-bubble-left-ellipsis',
'chat-bubble-left-right',
'chat-bubble-oval-left',
'chat-bubble-oval-left-ellipsis',
'check',
'check-badge',
'check-circle',
'chevron-double-down',
'chevron-double-left',
'chevron-double-right',
'chevron-double-up',
'chevron-down',
'chevron-left',
'chevron-right',
'chevron-up',
'chevron-up-down',
'circle-stack',
'clipboard',
'clipboard-document',
'clipboard-document-check',
'clipboard-document-list',
'clock',
'cloud',
'cloud-arrow-down',
'cloud-arrow-up',
'code-bracket',
'code-bracket-square',
'cog',
'cog-6-tooth',
'cog-8-tooth',
'command-line',
'computer-desktop',
'cpu-chip',
'credit-card',
'cube',
'cube-transparent',
'currency-bangladeshi',
'currency-dollar',
'currency-euro',
'currency-pound',
'currency-rupee',
'currency-yen',
'cursor-arrow-rays',
'cursor-arrow-ripple',
'device-phone-mobile',
'device-tablet',
'divide',
'document',
'document-arrow-down',
'document-arrow-up',
'document-chart-bar',
'document-check',
'document-currency-bangladeshi',
'document-currency-dollar',
'document-currency-euro',
'document-currency-pound',
'document-currency-rupee',
'document-currency-yen',
'document-duplicate',
'document-magnifying-glass',
'document-minus',
'document-plus',
'document-text',
'ellipsis-horizontal',
'ellipsis-horizontal-circle',
'ellipsis-vertical',
'envelope',
'envelope-open',
'equals',
'exclamation-circle',
'exclamation-triangle',
'eye',
'eye-dropper',
'eye-slash',
'face-frown',
'face-smile',
'film',
'finger-print',
'fire',
'flag',
'folder',
'folder-arrow-down',
'folder-minus',
'folder-open',
'folder-plus',
'forward',
'funnel',
'gif',
'gift',
'gift-top',
'globe-alt',
'globe-americas',
'globe-asia-australia',
'globe-europe-africa',
'h1',
'h2',
'h3',
'hand-raised',
'hand-thumb-down',
'hand-thumb-up',
'hashtag',
'heart',
'home',
'home-modern',
'identification',
'inbox',
'inbox-arrow-down',
'inbox-stack',
'information-circle',
'italic',
'key',
'language',
'lifebuoy',
'light-bulb',
'link',
'link-slash',
'list-bullet',
'lock-closed',
'lock-open',
'magnifying-glass',
'magnifying-glass-circle',
'magnifying-glass-minus',
'magnifying-glass-plus',
'map',
'map-pin',
'megaphone',
'microphone',
'minus',
'minus-circle',
'minus-small',
'moon',
'musical-note',
'newspaper',
'no-symbol',
'numbered-list',
'paint-brush',
'paper-airplane',
'paper-clip',
'pause',
'pause-circle',
'pencil',
'pencil-square',
'percent-badge',
'phone',
'phone-arrow-down-left',
'phone-arrow-up-right',
'phone-x-mark',
'photo',
'play',
'play-circle',
'play-pause',
'plus',
'plus-circle',
'plus-small',
'power',
'presentation-chart-bar',
'presentation-chart-line',
'printer',
'puzzle-piece',
'qr-code',
'question-mark-circle',
'queue-list',
'radio',
'receipt-percent',
'receipt-refund',
'rectangle-group',
'rectangle-stack',
'rocket-launch',
'rss',
'scale',
'scissors',
'server',
'server-stack',
'share',
'shield-check',
'shield-exclamation',
'shopping-bag',
'shopping-cart',
'signal',
'signal-slash',
'slash',
'sparkles',
'speaker-wave',
'speaker-x-mark',
'square-2-stack',
'square-3-stack-3d',
'squares-2x2',
'squares-plus',
'star',
'stop',
'stop-circle',
'strikethrough',
'sun',
'swatch',
'table-cells',
'tag',
'ticket',
'trash',
'trophy',
'truck',
'tv',
'underline',
'user',
'user-circle',
'user-group',
'user-minus',
'user-plus',
'users',
'variable',
'video-camera',
'video-camera-slash',
'view-columns',
'viewfinder-circle',
'wallet',
'wifi',
'window',
'wrench',
'wrench-screwdriver',
'x-circle',
'x-mark',
]
const iconOptions = heroiconNames.map(name => ({
label: name,
value: `i-heroicons-${name}`
}))
const selectedIcon = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value || '')
})
const selectedName = computed(() => {
return iconOptions.find(option => option.value === selectedIcon.value)?.label
|| selectedIcon.value.replace(/^i-heroicons-/, '')
})
</script>
<template>
<div class="flex items-center gap-2">
<div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-default">
<UIcon
:name="selectedIcon || 'i-heroicons-question-mark-circle'"
class="size-5"
:class="{ 'text-muted': !selectedIcon }"
/>
</div>
<USelectMenu
v-model="selectedIcon"
:items="iconOptions"
value-key="value"
label-key="label"
:search-input="{ placeholder: 'Heroicon suchen ' }"
:filter-fields="['label', 'value']"
placeholder="Heroicon auswählen"
class="min-w-0 flex-1"
clear-search-on-close
>
<template #default>
<span class="truncate">{{ selectedName || 'Heroicon auswählen' }}</span>
</template>
<template #item="{ item }">
<div class="flex min-w-0 items-center gap-3">
<UIcon :name="item.value" class="size-5 shrink-0" />
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</USelectMenu>
<UButton
v-if="selectedIcon"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
aria-label="Icon entfernen"
@click="selectedIcon = ''"
/>
</div>
</template>

View File

@@ -119,9 +119,10 @@ export default defineNuxtConfig({
]
},
/* WICHTIG FÜR SAFARI / iOS */
// FEDEO wird serverseitig gerendert; es gibt daher keine vorab gecachte
// index.html, die Workbox als Navigations-Fallback verwenden könnte.
workbox: {
navigateFallback: '/',
navigateFallback: null,
},
devOptions: {

View File

@@ -1896,6 +1896,31 @@ const saveDocument = async (state, resetup = false) => {
if (resetup) await setupPage()
}
const saveFinalizedCostCentres = async () => {
const rowCostCentres = itemInfo.value.rows
.filter(row => row?.id)
.map(row => ({
rowId: String(row.id),
costcentre: row.costCentre || null,
}))
const result = await $api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: {
costcentre: itemInfo.value.costcentre || null,
rowCostCentres,
},
})
itemInfo.value.costcentre = result.document.costcentre
itemInfo.value.rows = normalizeCreatedDocumentRows(result.document.rows)
toast.add({
title: "Kostenstellen gespeichert",
description: "Die Kostenstellen des fertiggestellten Belegs wurden aktualisiert.",
color: "success",
})
}
const selectedTab = ref("0")
const closeDocument = async () => {
@@ -2077,7 +2102,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<UButton
icon="i-mdi-content-save"
@click="saveDocument('Entwurf',true)"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
:disabled="!itemInfo.customer"
>
Speichern
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</UButton>
<UButton
@click="closeDocument"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
>
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
</UButton>
<UButton
icon="i-heroicons-tag"
@click="saveFinalizedCostCentres"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
>
Kostenstellen speichern
</UButton>
<UButton
icon="i-mdi-content-save"
@click="saveSerialInvoice"

View File

@@ -652,7 +652,7 @@ const executeSerialInvoices = async () => {
toast.add({
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',
color: 'green'
})

View File

@@ -316,7 +316,7 @@ const archiveItem = async () => {
<UInput v-model="phase.label" class="w-full" placeholder="Phasenname" />
</UFormField>
<UFormField label="Icon">
<UInput v-model="phase.icon" class="w-full" placeholder="i-heroicons-…" />
<HeroiconPicker v-model="phase.icon" />
</UFormField>
<UFormField label="Beschreibung" class="xl:col-span-2">
<UInput v-model="phase.description" class="w-full" placeholder="Optionale Beschreibung" />

View File

@@ -49,29 +49,54 @@ const resources = {
}
}
const numberRanges = ref(auth.activeTenantData.numberRanges || {})
const createNumberRanges = (ranges = {}) => {
const freshRanges = structuredClone(ranges)
Object.keys(resources).forEach((key) => {
if (!numberRanges.value[key]) {
numberRanges.value[key] = {
prefix: "",
suffix: "",
nextNumber: 1000
Object.keys(resources).forEach((key) => {
if (!freshRanges[key]) {
freshRanges[key] = {
prefix: "",
suffix: "",
nextNumber: 1000
}
}
})
return freshRanges
}
const numberRanges = ref({})
const loading = ref(true)
const loadNumberRanges = async () => {
loading.value = true
try {
const tenant = await useNuxtApp().$api("/api/tenant")
numberRanges.value = createNumberRanges(tenant?.numberRanges || {})
if (tenant?.id) {
auth.activeTenantData = tenant
}
} finally {
loading.value = false
}
})
}
onMounted(loadNumberRanges)
const updateNumberRanges = async (range) => {
const res = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
const tenant = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
method: "PUT",
body: {
numberRange: numberRanges.value[range]
}
})
console.log(res)
if (tenant?.id) {
auth.activeTenantData = tenant
numberRanges.value = createNumberRanges(tenant.numberRanges || {})
}
}
@@ -92,7 +117,13 @@ const updateNumberRanges = async (range) => {
/>
</UDashboardToolbar>
<UProgress
v-if="loading"
animation="carousel"
class="m-5 w-1/2"
/>
<table
v-else
class="m-3"
>
<tr class="text-left">
@@ -103,6 +134,7 @@ const updateNumberRanges = async (range) => {
</tr>
<tr
v-for="key in Object.keys(resources)"
:key="key"
>
<td>{{resources[key].label}}</td>
<td>

View File

@@ -490,7 +490,7 @@ onMounted(async () => {
<p class="text-sm font-medium text-gray-900 dark:text-white">Push-Zustellung testen</p>
<p class="mt-1 text-sm text-gray-500">
<template v-if="profile.user_id">
Sendet einmalig eine Testnachricht an alle aktiven Push-Geräte dieses Mitarbeiters.
Sendet einmalig eine Testnachricht an alle aktiven mobilen Push-Geräte dieses Mitarbeiters.
</template>
<template v-else>
Für einen Push-Test muss der Mitarbeiter zuerst mit einem Benutzerkonto verknüpft werden.