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 required: string[] numberField?: string references?: Reference[] addGet?: boolean } const makeConfig = (config: Omit & { fieldNames: string; overrides?: Record }): 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) { 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) { 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) { 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 = 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 })