feat(mcp): manage master data and cost centre hierarchies
This commit is contained in:
277
backend/src/mcp/tools/masterdataWrites.ts
Normal file
277
backend/src/mcp/tools/masterdataWrites.ts
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user