feat(mcp): manage master data and cost centre hierarchies

This commit is contained in:
2026-09-14 13:29:25 +02:00
parent 342ad9764e
commit cfef07b96d
8 changed files with 725 additions and 53 deletions

View File

@@ -0,0 +1,83 @@
import { getTableColumns } from "drizzle-orm"
export type InputField = {
schema: Record<string, any>
parse: (value: unknown, path: string) => any
}
const invalid = (path: string, expected: string): never => { throw new Error(`${path}: ${expected}`) }
export const textField = (nonEmpty = false): InputField => ({
schema: { type: "string", ...(nonEmpty ? { minLength: 1 } : {}) },
parse: (value, path) => typeof value === "string" && (!nonEmpty || value.trim())
? value.trim() : invalid(path, "Text" + (nonEmpty ? " darf nicht leer sein" : " erwartet")),
})
export const numericField = (integer = false, minimum?: number, maximum?: number): InputField => ({
schema: { type: integer ? "integer" : "number", ...(minimum !== undefined ? { minimum } : {}), ...(maximum !== undefined ? { maximum } : {}) },
parse: (value, path) => {
if (typeof value !== "number" || !Number.isFinite(value) || (integer && !Number.isSafeInteger(value))
|| (minimum !== undefined && value < minimum) || (maximum !== undefined && value > maximum)) {
return invalid(path, `gültige ${integer ? "ganze " : ""}Zahl im angegebenen Bereich erwartet`)
}
return value
},
})
export const idField = numericField(true, 1)
export const uuidField: InputField = {
schema: { type: "string", format: "uuid" },
parse: (value, path) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
? value.toLowerCase() : invalid(path, "gültige UUID erwartet"),
}
export const booleanField: InputField = {
schema: { type: "boolean" },
parse: (value, path) => typeof value === "boolean" ? value : invalid(path, "Boolean erwartet"),
}
export const dateField: InputField = {
schema: { type: "string", format: "date", description: "Kalenderdatum YYYY-MM-DD." },
parse: (value, path) => {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return invalid(path, "Datum YYYY-MM-DD erwartet")
const date = new Date(`${value}T00:00:00Z`)
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) return invalid(path, "ungültiges Kalenderdatum")
return value
},
}
export const nullable = (field: InputField): InputField => ({
schema: { ...field.schema, type: [field.schema.type, "null"] },
parse: (value, path) => value === null ? null : field.parse(value, path),
})
export const arrayField = (item: InputField): InputField => ({
schema: { type: "array", items: item.schema },
parse: (value, path) => Array.isArray(value) ? value.map((entry, index) => item.parse(entry, `${path}[${index}]`)) : invalid(path, "Array erwartet"),
})
export const objectField = (fields: Record<string, InputField>, additionalProperties = false): InputField => ({
schema: { type: "object", properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema])), additionalProperties },
parse: (value, path) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(path, "Objekt erwartet")
const entries = Object.entries(value).map(([key, entry]) => {
if (Object.prototype.hasOwnProperty.call(fields, key)) return [key, fields[key].parse(entry, `${path}.${key}`)]
if (!additionalProperties || ["__proto__", "constructor", "prototype"].includes(key)) return invalid(`${path}.${key}`, "unbekanntes Feld")
return [key, entry]
})
return Object.fromEntries(entries)
},
})
/** Explicit allowlist; scalar types and nullability follow the persisted columns. */
export function tableFields(table: any, names: string, overrides: Record<string, InputField> = {}) {
const columns = getTableColumns(table) as Record<string, any>
return Object.fromEntries(names.split(/\s+/).filter(Boolean).map((name) => {
const column = columns[name]
if (!column) throw new Error(`Unbekanntes Stammdatenfeld ${name}`)
let field = overrides[name]
if (!field) {
if (column.columnType === "PgUUID") field = uuidField
else if (column.columnType === "PgDateString") field = dateField
else if (column.dataType === "string") field = textField(name === "name")
else if (column.dataType === "boolean") field = booleanField
else if (column.dataType === "number") {
field = column.columnType === "PgSmallInt" ? numericField(true, -32768, 32767)
: numericField(column.columnType !== "PgDoublePrecision")
} else throw new Error(`Explizites Eingabeschema für ${name} erforderlich`)
}
return [name, column.notNull ? field : nullable(field)]
}))
}

View File

@@ -1,3 +1,5 @@
import { masterdataMutationTools } from "./masterdataWrites"
import { organisationTools } from "./organisation"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import {
@@ -34,6 +36,12 @@ const uuidArg = (args: Record<string, unknown>, key: string) => {
}
export const masterdataTools: McpTool[] = [
...masterdataMutationTools,
{
...organisationTools.find((tool) => tool.name === "organisation.customers.search")!,
name: "masterdata.customers.search",
requiredPermissions: ["masterdata.customers.read"],
},
{
name: "masterdata.customers.get",
title: "Kunde laden",

View File

@@ -0,0 +1,277 @@
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"
import * as tables from "../../../db/schema"
import { assertToolPermission } from "../authz"
import { McpContext, McpTool } from "../types"
import { arrayField, booleanField, idField, InputField, nullable, numericField, objectField, tableFields, textField, uuidField } from "../masterdataFields"
import { useNextNumberRangeNumber } from "../../utils/functions"
import { insertHistoryItem } from "../../utils/history"
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
const infoDataField = objectField({
...Object.fromEntries("street special zip city country tel mobileTel email invoiceEmail web ustid".split(" ").map((key) => [key, nullable(textField())])),
bankAccountIds: arrayField(idField),
hasSEPA: booleanField,
sepaSignedAt: nullable(textField()),
}, true)
infoDataField.schema.description = "Kontaktdaten und Zusatzfelder. Bei update werden angegebene Unterfelder mit infoData zusammengeführt; Arrays werden ersetzt."
const profilesField = arrayField(uuidField)
const compositionField = arrayField(objectField({
id: uuidField, name: textField(), quantity: numericField(), price: numericField(), purchasePrice: numericField(),
product: nullable(idField), service: nullable(idField), hourrate: nullable(uuidField),
}, true))
const priceField = objectField({ total: numericField(), material: numericField(), worker: numericField() }, true)
type Reference = { field: string; table: any; idKey?: string; tenantKey?: string }
type Config = {
resource: string
namespace: string
label: string
resultKey: string
table: any
fields: Record<string, InputField>
required: string[]
numberField?: string
references?: Reference[]
addGet?: boolean
}
const makeConfig = (config: Omit<Config, "fields"> & { fieldNames: string; overrides?: Record<string, InputField> }): Config => ({
...config,
fields: tableFields(config.table, config.fieldNames, config.overrides),
})
const commonProfiles: Reference = { field: "profiles", table: tables.authProfiles, tenantKey: "tenant_id" }
const bankAccounts: Reference = { field: "infoData.bankAccountIds", table: tables.entitybankaccounts }
export const masterdataWriteConfigs: Config[] = [
makeConfig({
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
required: ["name"], numberField: "customerNumber",
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
}),
makeConfig({
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
required: ["name"], numberField: "vendorNumber",
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
references: [commonProfiles, bankAccounts],
}),
makeConfig({
resource: "contacts", namespace: "contacts", label: "Kontakt", resultKey: "contact", table: tables.contacts, required: [], addGet: true,
fieldNames: "firstName lastName fullName email customer vendor phoneMobile phoneHome role salutation active birthday notes profiles title",
overrides: { profiles: profilesField, customer: idField, vendor: idField },
references: [commonProfiles, { field: "customer", table: tables.customers }, { field: "vendor", table: tables.vendors }],
}),
makeConfig({
resource: "products", namespace: "products", label: "Artikel", resultKey: "product", table: tables.products, required: ["name", "unit"],
fieldNames: "name manufacturer unit tags ean barcode purchase_price selling_price description manufacturer_number vendor_allocation article_number supplier_link barcodes productcategories tax_percentage markup_percentage",
overrides: {
unit: idField, tags: arrayField(textField()), barcodes: arrayField(textField()), productcategories: arrayField(idField), tax_percentage: numericField(true, 0, 100),
vendor_allocation: arrayField(objectField({ vendor: idField, articleNumber: textField(), purchasePrice: numericField() }, true)),
},
references: [{ field: "unit", table: tables.units }, { field: "productcategories", table: tables.productcategories }, { field: "vendor_allocation.vendor", table: tables.vendors }],
}),
makeConfig({
resource: "services", namespace: "services", label: "Leistung", resultKey: "service", table: tables.services, required: ["name"],
fieldNames: "name sellingPrice description unit costcentre serviceNumber tags servicecategories purchasePriceComposed sellingPriceComposed taxPercentage materialComposition personalComposition priceUpdateLocked",
overrides: {
unit: idField, tags: arrayField(textField()), servicecategories: arrayField(idField), taxPercentage: numericField(true, 0, 100),
purchasePriceComposed: priceField, sellingPriceComposed: priceField, materialComposition: compositionField, personalComposition: compositionField,
},
references: [
{ field: "unit", table: tables.units }, { field: "costcentre", table: tables.costcentres }, { field: "servicecategories", table: tables.servicecategories },
{ field: "materialComposition.product", table: tables.products }, { field: "materialComposition.service", table: tables.services },
{ field: "personalComposition.hourrate", table: tables.hourrates },
],
}),
makeConfig({
resource: "costcentres", namespace: "cost_centres", label: "Kostenstelle", resultKey: "costCentre", table: tables.costcentres,
required: ["name"], numberField: "number",
fieldNames: "name number parentCostcentre vehicle project branch inventoryitem description profiles",
overrides: { number: textField(true), vehicle: idField, project: idField, branch: idField, inventoryitem: idField, profiles: profilesField },
references: [commonProfiles, { field: "parentCostcentre", table: tables.costcentres }, { field: "vehicle", table: tables.vehicles }, { field: "project", table: tables.projects }, { field: "branch", table: tables.branches }, { field: "inventoryitem", table: tables.inventoryitems }],
}),
makeConfig({
resource: "branches", namespace: "branches", label: "Niederlassung", resultKey: "branch", table: tables.branches,
required: ["name"], numberField: "number", fieldNames: "name number description", overrides: { number: textField(true) }, addGet: true,
}),
makeConfig({
resource: "teams", namespace: "teams", label: "Team", resultKey: "team", table: tables.teams,
required: ["name"], fieldNames: "name description branch", overrides: { branch: idField }, references: [{ field: "branch", table: tables.branches }], addGet: true,
}),
makeConfig({
resource: "vehicles", namespace: "vehicles", label: "Fahrzeug", resultKey: "vehicle", table: tables.vehicles,
required: ["name"], fieldNames: "name license_plate type active driver vin tank_size build_year towing_capacity power_in_kw color profiles",
overrides: { profiles: profilesField, tank_size: numericField(false, 0), towing_capacity: numericField(true, 0), power_in_kw: numericField(true, 0) },
references: [commonProfiles, { field: "driver", table: tables.authProfiles, idKey: "user_id", tenantKey: "tenant_id" }], addGet: true,
}),
makeConfig({
resource: "inventoryitems", namespace: "inventory_items", label: "Inventar", resultKey: "inventoryItem", table: tables.inventoryitems,
required: ["name"], numberField: "articleNumber",
fieldNames: "name usePlanning description currentSpace articleNumber serialNumber purchaseDate vendor quantity purchasePrice manufacturer manufacturerNumber currentValue profiles",
overrides: { articleNumber: textField(true), currentSpace: idField, vendor: idField, profiles: profilesField, quantity: numericField(true, 0) },
references: [commonProfiles, { field: "currentSpace", table: tables.spaces }, { field: "vendor", table: tables.vendors }],
}),
]
const identifier = (config: Config) => config.resource === "costcentres" ? uuidField : idField
export function prepareMasterdataInput(config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
const fields = action === "archive" ? {} : config.fields
const input = objectField({ ...fields, ...(action === "create" ? {} : { id: identifier(config) }) }).parse(args, "arguments")
for (const key of action === "create" ? config.required : ["id"]) {
if (input[key] === undefined || input[key] === null) throw new Error(`${key} ist erforderlich`)
}
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
return input
}
function valuesAtPath(value: any, path: string[]): any[] {
if (Array.isArray(value)) return value.flatMap((entry) => valuesAtPath(entry, path))
if (!path.length) return value === undefined || value === null ? [] : [value]
return valuesAtPath(value?.[path[0]], path.slice(1))
}
async function validateReferences(db: any, config: Config, tenantId: number, data: Record<string, any>) {
for (const reference of config.references || []) {
const ids = [...new Set(valuesAtPath(data, reference.field.split(".")))]
if (!ids.length) continue
const table = reference.table
const idColumn = table[reference.idKey || "id"]
const tenantColumn = table[reference.tenantKey || "tenant"]
const conditions = [inArray(idColumn, ids)]
if (tenantColumn) conditions.push(eq(tenantColumn, tenantId))
if (table.archived) conditions.push(eq(table.archived, false))
if (table === tables.authProfiles) conditions.push(eq(table.active, true))
const found = await db.select({ id: idColumn }).from(table).where(and(...conditions))
if (new Set(found.map((row: any) => row.id)).size !== ids.length) {
throw new Error(`${reference.field}: mindestens ein verknüpfter Datensatz ist im aktiven Mandanten nicht verfügbar`)
}
}
}
async function mutate(context: McpContext, config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
const { id, ...input } = prepareMasterdataInput(config, action, args)
return context.server.db.transaction(async (tx) => {
// Coordinate number allocation and hierarchy changes across concurrent MCP calls.
// The existing number-range helper uses the same transaction-scoped tenant lock.
await tx.execute(sql`select pg_advisory_xact_lock(${context.tenantId})`)
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
const table = config.table
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))
let existing: any = null
if (action !== "create") {
;[existing] = await tx.select().from(table).where(scope).limit(1).for("update")
if (!existing) throw new Error(`${config.label} nicht gefunden oder archiviert`)
}
const data: Record<string, any> = action === "archive" ? { archived: true } : { ...input }
if (action !== "archive") {
if (data.infoData && existing?.infoData) data.infoData = { ...existing.infoData, ...data.infoData }
if (config.resource === "contacts" && data.fullName === undefined && (action === "create" || data.firstName !== undefined || data.lastName !== undefined)) {
const merged = { ...existing, ...data }
data.fullName = [merged.firstName, merged.lastName].filter(Boolean).join(" ")
}
const merged = { ...existing, ...data }
if (config.resource === "contacts" && !merged.fullName?.trim()) throw new Error("Kontakt benötigt fullName oder Vor-/Nachname")
if (config.resource === "customers" && merged.type === "Mitglied") {
data.isCompany = false
if (!merged.firstname?.trim() || !merged.lastname?.trim() || !merged.infoData?.bankAccountIds?.length) {
throw new Error("Mitglieder benötigen Vorname, Nachname und mindestens ein Bankkonto")
}
if (merged.infoData.hasSEPA && !merged.infoData.sepaSignedAt) throw new Error("SEPA-Mandat benötigt ein Unterschriftsdatum")
}
if (config.resource === "services") {
// The shared recalculation service prefers sellingPriceComposed.total.
// Keep both representations consistent for manual price edits, including zero.
if (data.sellingPrice !== undefined) {
const total = data.sellingPrice ?? 0
if (data.sellingPriceComposed?.total !== undefined && data.sellingPriceComposed.total !== total) {
throw new Error("sellingPrice und sellingPriceComposed.total widersprechen sich")
}
data.sellingPriceComposed = { ...existing?.sellingPriceComposed, ...data.sellingPriceComposed, total }
} else if (data.sellingPriceComposed?.total !== undefined) {
data.sellingPrice = data.sellingPriceComposed.total
}
}
await validateReferences(tx, config, context.tenantId, data)
if (config.resource === "costcentres") {
const error = await validateCostCentreParent(server, context.tenantId, id || null, merged.parentCostcentre || null)
if (error) throw new Error(error)
}
if (config.numberField) {
if (action === "create" && !data[config.numberField]) {
const result = await useNextNumberRangeNumber(server, context.tenantId, config.resource, async (candidate, numberTx) => {
const rows = await numberTx.select({ id: table.id }).from(table)
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField!], candidate))).limit(1)
return rows.length === 0
})
data[config.numberField] = result.usedNumber
} else if (data[config.numberField] !== undefined && data[config.numberField] !== null) {
const rows = await tx.select({ id: table.id }).from(table)
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField], data[config.numberField]))).limit(2)
if (rows.some((row: any) => row.id !== id)) throw new Error(`${config.numberField} ist bereits vergeben`)
}
}
}
const columns = getTableColumns(table)
data["updated_at" in columns ? "updated_at" : "updatedAt"] = new Date()
data["updated_by" in columns ? "updated_by" : "updatedBy"] = context.userId
let result: any
if (action === "create") {
;[result] = await tx.insert(table).values({ ...data, tenant: context.tenantId, archived: false }).returning()
} else {
;[result] = await tx.update(table).set(data).where(scope).returning()
}
if (["products", "services"].includes(config.resource)) {
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
;[result] = await tx.select().from(table).where(and(eq(table.id, result.id), eq(table.tenant, context.tenantId))).limit(1)
}
await insertHistoryItem(server, {
tenant_id: context.tenantId, created_by: context.userId, entity: config.resource, entityId: result.id,
action: action === "create" ? "created" : action === "archive" ? "archived" : "updated", oldVal: existing, newVal: result,
})
// Archived objects are filtered from normal MCP results; return an explicit confirmation.
return action === "archive" ? { id: result.id, success: true, action: "archived" } : { [config.resultKey]: result }
})
}
export const masterdataMutationTools: McpTool[] = masterdataWriteConfigs.flatMap((config) => {
const tools: McpTool[] = (["create", "update", "archive"] as const).map((action) => {
const fields = action === "archive" ? {} : config.fields
const properties = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema]))
if (action !== "create") properties.id = identifier(config).schema
if (config.numberField && action === "create") properties[config.numberField] = { ...properties[config.numberField], description: "Optional; ohne Angabe Vergabe aus dem FEDEO-Nummernkreis." }
const tool: McpTool = {
name: `masterdata.${config.namespace}.${action}`,
title: `${config.label} ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten" : "archivieren"}`,
description: `${config.label} im aktiven Mandanten ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten. Nur übergebene Felder werden geändert; null entfernt nullable Werte, Arrays werden ersetzt" : "archivieren"}. Verknüpfungen werden auf Mandantenzugehörigkeit geprüft.`,
requiredPermissions: [`masterdata.${config.namespace}.write`],
inputSchema: { type: "object", properties, required: action === "create" ? config.required : ["id"], additionalProperties: false },
async handler(context, args) {
assertToolPermission(context, tool)
return mutate(context, config, action, args)
},
}
return tool
})
if (config.addGet) {
const tool: McpTool = {
name: `masterdata.${config.namespace}.get`, title: `${config.label} laden`,
description: `Lädt ${config.label} des aktiven Mandanten anhand der ID.`,
requiredPermissions: [`masterdata.${config.namespace}.read`],
inputSchema: { type: "object", properties: { id: identifier(config).schema }, required: ["id"], additionalProperties: false },
async handler(context, args) {
assertToolPermission(context, tool)
const id = identifier(config).parse(args.id, "id")
const table = config.table
const [result] = await context.server.db.select().from(table)
.where(and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))).limit(1)
if (!result) throw new Error(`${config.label} nicht gefunden oder archiviert`)
return { [config.resultKey]: result }
},
}
tools.push(tool)
}
return tools
})