diff --git a/backend/src/mcp/masterdataFields.ts b/backend/src/mcp/masterdataFields.ts new file mode 100644 index 00000000..27ad2a21 --- /dev/null +++ b/backend/src/mcp/masterdataFields.ts @@ -0,0 +1,83 @@ +import { getTableColumns } from "drizzle-orm" + +export type InputField = { + schema: Record + 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, 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 = {}) { + const columns = getTableColumns(table) as Record + 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)] + })) +} diff --git a/backend/src/mcp/tools/masterdata.ts b/backend/src/mcp/tools/masterdata.ts index 90e73153..c84a2495 100644 --- a/backend/src/mcp/tools/masterdata.ts +++ b/backend/src/mcp/tools/masterdata.ts @@ -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, 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", diff --git a/backend/src/mcp/tools/masterdataWrites.ts b/backend/src/mcp/tools/masterdataWrites.ts new file mode 100644 index 00000000..e86bda78 --- /dev/null +++ b/backend/src/mcp/tools/masterdataWrites.ts @@ -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 + 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 +}) diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index d362e67a..52a5d4b7 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -24,15 +24,25 @@ const adminPermissions = [ "mcp.tokens.write", "staff.time.read_all", "masterdata.customers.read", + "masterdata.customers.write", "masterdata.vendors.read", + "masterdata.vendors.write", "masterdata.contacts.read", + "masterdata.contacts.write", "masterdata.products.read", + "masterdata.products.write", "masterdata.services.read", + "masterdata.services.write", "masterdata.cost_centres.read", + "masterdata.cost_centres.write", "masterdata.branches.read", + "masterdata.branches.write", "masterdata.teams.read", + "masterdata.teams.write", "masterdata.vehicles.read", + "masterdata.vehicles.write", "masterdata.inventory_items.read", + "masterdata.inventory_items.write", "masterdata.units.read", "accounting.outgoing_documents.read", "accounting.outgoing_documents.write", diff --git a/backend/src/modules/cost-centre-hierarchy.service.ts b/backend/src/modules/cost-centre-hierarchy.service.ts new file mode 100644 index 00000000..b4c83a80 --- /dev/null +++ b/backend/src/modules/cost-centre-hierarchy.service.ts @@ -0,0 +1,56 @@ +import { FastifyInstance } from "fastify" +import { eq } from "drizzle-orm" +import { costcentres } from "../../db/schema" + +export async function validateCostCentreParent( + server: FastifyInstance, + tenantId: number, + costCentreId: string | null, + parentCostcentreId: string | null +) { + if (!parentCostcentreId) { + return null + } + + const hierarchyRows = await server.db + .select({ + id: costcentres.id, + parentCostcentre: costcentres.parentCostcentre, + }) + .from(costcentres) + .where(eq(costcentres.tenant, tenantId)) + + const hierarchyMap = new Map( + hierarchyRows.map((row) => [row.id, row.parentCostcentre || null]) + ) + + if (!hierarchyMap.has(parentCostcentreId)) { + return "Die übergeordnete Kostenstelle wurde nicht gefunden." + } + + if (costCentreId && parentCostcentreId === costCentreId) { + return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben." + } + + if (!costCentreId) { + return null + } + + let currentParentId: string | null = parentCostcentreId + const visited = new Set() + + while (currentParentId) { + if (currentParentId === costCentreId) { + return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen." + } + + if (visited.has(currentParentId)) { + break + } + + visited.add(currentParentId) + currentParentId = hierarchyMap.get(currentParentId) || null + } + + return null +} diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index cffac5da..f38e7d64 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -1,3 +1,4 @@ +import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service" import { FastifyInstance } from "fastify" import { eq, @@ -13,7 +14,6 @@ import { import { authProfiles, - costcentres, createddocuments, customers, entitybankaccounts, @@ -324,58 +324,6 @@ function validateMemberPayload(payload: Record) { return null } -async function validateCostCentreParent( - server: FastifyInstance, - tenantId: number, - costCentreId: string | null, - parentCostcentreId: string | null -) { - if (!parentCostcentreId) { - return null - } - - const hierarchyRows = await server.db - .select({ - id: costcentres.id, - parentCostcentre: costcentres.parentCostcentre, - }) - .from(costcentres) - .where(eq(costcentres.tenant, tenantId)) - - const hierarchyMap = new Map( - hierarchyRows.map((row) => [row.id, row.parentCostcentre || null]) - ) - - if (!hierarchyMap.has(parentCostcentreId)) { - return "Die übergeordnete Kostenstelle wurde nicht gefunden." - } - - if (costCentreId && parentCostcentreId === costCentreId) { - return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben." - } - - if (!costCentreId) { - return null - } - - let currentParentId: string | null = parentCostcentreId - const visited = new Set() - - while (currentParentId) { - if (currentParentId === costCentreId) { - return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen." - } - - if (visited.has(currentParentId)) { - break - } - - visited.add(currentParentId) - currentParentId = hierarchyMap.get(currentParentId) || null - } - - return null -} function maskIban(iban: string) { if (!iban) return "" diff --git a/backend/tests/mcpMasterdata.test.ts b/backend/tests/mcpMasterdata.test.ts new file mode 100644 index 00000000..44bdb380 --- /dev/null +++ b/backend/tests/mcpMasterdata.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getTableColumns, getTableName } from "drizzle-orm" +import { PgDialect } from "drizzle-orm/pg-core" +import * as schema from "../db/schema" +import { mcpToolMap } from "../src/mcp/registry" +import { masterdataWriteConfigs, prepareMasterdataInput } from "../src/mcp/tools/masterdataWrites" +import { asToolResult } from "../src/mcp/result" + +const tenantId = 7 +const userId = "00000000-0000-4000-8000-000000000001" +const parentId = "00000000-0000-4000-8000-000000000002" +const childId = "00000000-0000-4000-8000-000000000003" +const otherId = "00000000-0000-4000-8000-000000000004" +const dialect = new PgDialect() + +/** Transactional fixture which evaluates actual Drizzle WHERE clauses, including tenant filters. */ +function fixture(initial: Record = {}) { + let state = structuredClone(initial) + const statements: string[] = [] + let failHistory = false + const matches = (table: any, where: any, row: any): boolean => { + if (!where) return true + const query = dialect.sqlToQuery(where) + statements.push(query.sql) + const columns = getTableColumns(table) as Record + const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)! + const predicates = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)] + assert.ok(predicates.length, `Unimplemented fixture predicate: ${query.sql}`) + return predicates.every((match) => { + const value = row[keyFor(match[1])] + if (match[2]) return value === query.params[Number(match[2]) - 1] + return [...match[3].matchAll(/\$(\d+)/g)].some((parameter) => value === query.params[Number(parameter[1]) - 1]) + }) + } + const rowsFor = (table: any) => state[getTableName(table)] ||= [] + const db: any = { + execute: async () => undefined, + transaction: async (callback: any) => { + const before = structuredClone(state) + try { return await callback(db) } catch (error) { state = before; throw error } + }, + select: (selection?: Record) => ({ from: (table: any) => { + let condition: any + let limit = Infinity + const query: any = { + where: (where: any) => { condition = where; return query }, + limit: (value: number) => { limit = value; return query }, + for: () => query, + then: (resolve: any, reject: any) => Promise.resolve().then(() => { + const rows = rowsFor(table).filter((row) => matches(table, condition, row)).slice(0, limit) + const columns = getTableColumns(table) + return rows.map((row) => selection ? Object.fromEntries(Object.entries(selection).map(([key, column]) => { + const field = Object.keys(columns).find((name) => columns[name] === column)! + return [key, row[field]] + })) : { ...row }) + }).then(resolve, reject), + } + return query + } }), + insert: (table: any) => ({ values: (data: any) => { + let completed = false + let created: any + const run = () => { + if (!completed) { + if (failHistory && table === schema.historyitems) throw new Error("History unavailable") + const id = table === schema.costcentres ? otherId : Math.max(0, ...rowsFor(table).map((row) => typeof row.id === "number" ? row.id : 0)) + 1 + created = { id, archived: false, ...data } + rowsFor(table).push(created) + completed = true + } + return [created] + } + return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) } + } }), + update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => { + let result: any[] | undefined + const run = () => { + if (!result) result = rowsFor(table).filter((row) => matches(table, where, row)).map((row) => { Object.assign(row, data); return { ...row } }) + return result + } + return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) } + } }) }), + } + const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] } + return { context, rows: (table: any) => rowsFor(table), statements, failHistory: () => { failHistory = true } } +} +const call = async (context: any, name: string, args: Record) => { + const tool = mcpToolMap.get(`masterdata.${name}`) + assert.ok(tool, name) + return await tool.handler(context, args) as any +} + +for (const config of masterdataWriteConfigs) { + test(`${config.namespace}: create, update and archive are scoped and attribute changes to the actor`, async () => { + const f = fixture({ units: [{ id: 1 }], services: [], products: [], hourrates: [] }) + const input: any = config.resource === "contacts" ? { firstName: "Ada", lastName: "Lovelace" } : { name: "Test" } + if (config.numberField) input[config.numberField] = "MCP-100" + if (config.resource === "products") input.unit = 1 + const created = await call(f.context, `${config.namespace}.create`, input) + const record = created[config.resultKey] + assert.equal(record.tenant, tenantId) + assert.equal(record.archived, false) + assert.equal(record.updatedBy || record.updated_by, userId) + const update = config.resource === "contacts" ? { firstName: "Grace", lastName: "Hopper" } : { name: "Changed" } + const updated = await call(f.context, `${config.namespace}.update`, { id: record.id, ...update }) + assert.equal(updated[config.resultKey][config.resource === "contacts" ? "fullName" : "name"], config.resource === "contacts" ? "Grace Hopper" : "Changed") + const confirmation = await call(f.context, `${config.namespace}.archive`, { id: record.id }) + assert.deepEqual(asToolResult(confirmation).structuredContent, { id: record.id, success: true, action: "archived" }) + assert.equal(f.rows(config.table)[0].archived, true) + await assert.rejects(call(f.context, `${config.namespace}.update`, { id: record.id, ...update }), /archiviert/) + }) + + test(`${config.namespace}: rejects missing permissions, foreign records and injected system fields`, async () => { + const id = config.resource === "costcentres" ? parentId : 1 + const f = fixture({ [getTableName(config.table)]: [{ id, tenant: 8, archived: false, name: "Other tenant" }] }) + const fields = config.resource === "contacts" ? { firstName: "Changed" } : { name: "Changed" } + await assert.rejects(call(f.context, `${config.namespace}.update`, { id, ...fields }), /nicht gefunden/) + await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/) + assert.equal(f.rows(config.table)[0].archived, false) + for (const key of ["tenant", "createdAt", "updatedBy", "updated_by", "archived"]) { + await assert.rejects(call(f.context, `${config.namespace}.update`, { id, [key]: "injected" }), /unbekanntes Feld/) + } + f.context.isAdmin = false + f.context.permissions = [`masterdata.${config.namespace}.read`] + await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /Fehlende Berechtigung/) + f.context.permissions.push(`masterdata.${config.namespace}.write`) + await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/) + }) +} + +test("numbered resources allocate numbers, skip occupied values and reject duplicates", async () => { + for (const config of masterdataWriteConfigs.filter((item) => item.numberField)) { + const f = fixture({ + tenants: [{ id: tenantId, numberRanges: { [config.resource]: { prefix: "K-", nextNumber: 1 } } }], + [getTableName(config.table)]: [{ id: config.resource === "costcentres" ? parentId : 10, tenant: tenantId, [config.numberField!]: "K-1", archived: true }], + }) + const result = await call(f.context, `${config.namespace}.create`, { name: "New" }) + assert.equal(result[config.resultKey][config.numberField!], "K-2") + assert.equal(f.rows(schema.tenants)[0].numberRanges[config.resource].nextNumber, 3) + await assert.rejects(call(f.context, `${config.namespace}.create`, { name: "Duplicate", [config.numberField!]: "K-1" }), /bereits vergeben/) + } +}) + +test("cost centre hierarchy rejects self, descendants, foreign and archived parents", async () => { + const f = fixture({ costcentres: [ + { id: parentId, tenant: tenantId, name: "Parent", number: "1", parentCostcentre: null, archived: false }, + { id: childId, tenant: tenantId, name: "Child", number: "2", parentCostcentre: parentId, archived: false }, + { id: otherId, tenant: 8, name: "Foreign", archived: false }, + ] }) + await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: parentId }), /sich selbst/) + await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /Zyklus/) + await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: otherId }), /nicht verfügbar/) + f.rows(schema.costcentres)[1].archived = true + await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /nicht verfügbar/) + const result = await call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: null }) + assert.equal(result.costCentre.parentCostcentre, null) + assert.equal(f.rows(schema.costcentres)[1].parentCostcentre, parentId) +}) + +test("all declared relationships reject foreign-tenant references before insert", async () => { + for (const config of masterdataWriteConfigs) { + for (const reference of config.references || []) { + if (reference.table === schema.units) continue + const refId = (reference.table[reference.idKey || "id"].columnType === "PgUUID") ? parentId : 99 + const initial = { [getTableName(reference.table)]: [{ [reference.idKey || "id"]: refId, [reference.tenantKey || "tenant"]: 8, archived: false, active: true }], units: [{ id: 1 }] } + const f = fixture(initial) + const input: any = config.resource === "contacts" ? { fullName: "Contact" } : { name: "Record" } + if (config.resource === "products") input.unit = 1 + if (config.numberField) input[config.numberField] = "N-1" + const parts = reference.field.split(".") + if (parts.length === 1) { + input[parts[0]] = ["profiles", "productcategories", "servicecategories"].includes(parts[0]) ? [refId] : refId + } else if (parts[0] === "infoData") input.infoData = { bankAccountIds: [refId] } + else input[parts[0]] = [{ [parts[1]]: refId }] + await assert.rejects(call(f.context, `${config.namespace}.create`, input), /nicht verfügbar/, `${config.namespace}.${reference.field}`) + } + } +}) + +test("partial updates preserve unrelated fields and nullable links can be cleared", async () => { + const f = fixture({ customers: [{ id: 1, tenant: tenantId, name: "Existing", archived: false, customerNumber: "C-1", infoData: { email: "old@example.org", city: "Berlin" } }] }) + const result = await call(f.context, "customers.update", { id: 1, infoData: { email: "new@example.org" }, notes: null }) + assert.deepEqual(result.customer.infoData, { email: "new@example.org", city: "Berlin" }) + assert.equal(result.customer.name, "Existing") + assert.equal(result.customer.notes, null) +}) + +test("failed history write rolls back record creation and number range consumption", async () => { + const f = fixture({ tenants: [{ id: tenantId, numberRanges: { customers: { prefix: "C-", nextNumber: 1 } } }] }) + f.failHistory() + await assert.rejects(call(f.context, "customers.create", { name: "Rollback" }), /History unavailable/) + assert.equal(f.rows(schema.customers).length, 0) + assert.equal(f.rows(schema.tenants)[0].numberRanges.customers.nextNumber, 1) +}) + +test("product prices propagate to composed services in the same transaction", async () => { + const f = fixture({ + products: [{ id: 1, tenant: tenantId, name: "Product", unit: 1, selling_price: 10, purchase_price: 5, archived: false }], + services: [{ id: 2, tenant: tenantId, name: "Service", archived: false, materialComposition: [{ product: 1, quantity: 2 }], personalComposition: [], priceUpdateLocked: false }], + hourrates: [], + }) + await call(f.context, "products.update", { id: 1, selling_price: 20 }) + assert.equal(f.rows(schema.services)[0].sellingPriceComposed.total, 40) + assert.equal(f.rows(schema.services)[0].purchasePriceComposed.total, 10) +}) + +test("validates scalar types, required values, dates and empty updates", () => { + const config = (name: string) => masterdataWriteConfigs.find((item) => item.namespace === name)! + assert.throws(() => prepareMasterdataInput(config("products"), "create", { name: "Article" }), /unit ist erforderlich/) + assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: "invalid", name: "Cost centre" }), /UUID/) + assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, name: " " }), /nicht leer/) + assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1 }), /Mindestens ein/) + for (const active of ["false", 0, null]) assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1, active }), /Boolean/) + for (const birthday of ["2026-02-30", "not a date"]) assert.throws(() => prepareMasterdataInput(config("contacts"), "update", { id: 1, birthday }), /Datum|Kalenderdatum/) + assert.throws(() => prepareMasterdataInput(config("products"), "update", { id: 1, selling_price: "20" }), /Zahl/) + assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, profiles: ["invalid"] }), /UUID/) +}) + +test("manual service price updates replace the previous composed total, including zero", async () => { + const f = fixture({ services: [{ + id: 1, tenant: tenantId, name: "Manual", archived: false, sellingPrice: 10, + sellingPriceComposed: { total: 10 }, materialComposition: [], personalComposition: [], + }], products: [], hourrates: [] }) + for (const price of [20, 0]) { + const result = await call(f.context, "services.update", { id: 1, sellingPrice: price }) + assert.equal(result.service.sellingPrice, price) + assert.equal(result.service.sellingPriceComposed.total, price) + } + await assert.rejects(call(f.context, "services.update", { id: 1, sellingPrice: 20, sellingPriceComposed: { total: 30 } }), /widersprechen/) +}) + +for (const config of masterdataWriteConfigs.filter((item) => item.addGet)) { + test(`${config.namespace}.get: returns only an active record from the current tenant`, async () => { + const f = fixture({ [getTableName(config.table)]: [ + { id: 1, tenant: tenantId, archived: false, name: "Active" }, + { id: 2, tenant: 8, archived: false, name: "Foreign" }, + { id: 3, tenant: tenantId, archived: true, name: "Archived" }, + ] }) + assert.equal((await call(f.context, `${config.namespace}.get`, { id: 1 }))[config.resultKey].name, "Active") + for (const id of [2, 3]) await assert.rejects(call(f.context, `${config.namespace}.get`, { id }), /nicht gefunden/) + f.context.isAdmin = false + await assert.rejects(call(f.context, `${config.namespace}.get`, { id: 1 }), /Fehlende Berechtigung/) + }) +} diff --git a/docs/technik/mcp.md b/docs/technik/mcp.md index 9198c55b..8da9734b 100644 --- a/docs/technik/mcp.md +++ b/docs/technik/mcp.md @@ -17,3 +17,48 @@ Auch `accounting.incoming_invoices.files.list` ist paginiert. Archivierte Eintr ## Ausgangsbeleg-Schemas `tools/list` beschreibt die Unterfelder von `rows`, `address` und `report` für Beleg-Erstellung und -Bearbeitung sowie Positionen und Report beim Finalisieren. Positionen verwenden `costCentre`, der Beleg selbst `costcentre`. `unit` ist die numerische Einheiten-ID. Preise werden numerisch übergeben. Reportwerte liefert der Aufrufer; MCP berechnet sie nicht. Zusätzliche modulspezifische Objektfelder bleiben zulässig. + +## Stammdaten und Kostenstellen + +Die folgenden Namensräume besitzen jeweils `create`, `update` und `archive`: + +| Namensraum nach `masterdata.` | Anlegen: Pflichtfelder | Automatischer Nummernkreis | +| --- | --- | --- | +| `customers` | `name` | `customerNumber` | +| `vendors` | `name` | `vendorNumber` | +| `contacts` | `fullName` oder `firstName`/`lastName` | – | +| `products` | `name`, `unit` | – | +| `services` | `name` | – | +| `cost_centres` | `name` | `number` | +| `branches` | `name` | `number` | +| `teams` | `name` | – | +| `vehicles` | `name` | – | +| `inventory_items` | `name` | `articleNumber` | + +Nummernfelder sind beim Anlegen optional; ohne Angabe wird der bestehende Mandanten-Nummernkreis verwendet. Dieser muss in FEDEO eingerichtet sein. Bereits belegte Nummern werden übersprungen; explizite doppelte Nummern werden abgewiesen, auch wenn der vorhandene Eintrag archiviert ist. + +`update` verlangt `id` und mindestens ein bearbeitbares Feld. Kostenstellen verwenden UUIDs, die übrigen Bereiche numerische IDs. Nur angegebene Felder werden geändert. Nullable Felder lassen sich mit `null` leeren, Arrays werden vollständig ersetzt. Die Unterfelder von `infoData` werden beim Update zusammengeführt. Systemfelder wie Mandant, ID beim Anlegen, Archivstatus und Änderungsautor sind nicht frei schreibbar. + +Beispiel zum Anlegen einer Kostenstelle: + +```json +{"name":"masterdata.cost_centres.create","arguments":{"name":"Werkstatt","description":"Kosten der Werkstatt","branch":3}} +``` + +Über `cost_centres.update` lassen sich `parentCostcentre`, `project`, `branch`, `vehicle`, `inventoryitem` und `profiles` pflegen. Eltern müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Selbstbezüge und neue Hierarchie-Zyklen werden abgewiesen. Die HTTP-Ressourcenroute und MCP verwenden denselben Hierarchieprüfer. + +Fremdschlüssel und bekannte Verknüpfungen in JSON-Feldern werden auf Mandantenzugehörigkeit und Verfügbarkeit geprüft. Globale Mengeneinheiten bleiben lesbar; es werden keine Schreibwerkzeuge für diese mandantenübergreifenden Daten registriert. + +Artikel- und Leistungsänderungen stoßen die bestehende Leistungspreisberechnung an. Bei zusammengesetzten Leistungen stammen Preise aus der Zusammensetzung; für eine manuelle Preisvorgabe die Leistung mit `priceUpdateLocked: true` sperren. Manuelle `sellingPrice`-Werte werden mit `sellingPriceComposed.total` synchronisiert. + +Schreibvorgänge einschließlich Nummernvergabe und Preisberechnung laufen in einer Transaktion. Historieneinträge werden über den vorhandenen History-Service erstellt; für Niederlassungen und Teams unterstützt dieser derzeit keine entitätsbezogene Historie. Die Änderungsfelder enthalten auch dort den ausführenden Benutzer und Zeitpunkt. Archivieren liefert `{ "id": ..., "success": true, "action": "archived" }`; der archivierte Datensatz wird entsprechend der MCP-Regel nicht zurückgegeben. + +Zusätzlich verfügbar: `masterdata.customers.search` sowie `get` für Kontakte, Niederlassungen, Teams und Fahrzeuge. Die vorhandene Kundensuche unter `organisation.customers.search` bleibt erhalten. + +### Berechtigungen + +Schreibwerkzeuge benötigen `masterdata..write`, lesende Werkzeuge `.read`. Die neuen Schreibrechte sind in den Bootstrap-Administratorrechten enthalten. Bestehende Nicht-Administratorrollen erhalten nicht automatisch Schreibzugriff; ihnen müssen die gewünschten Rechte ausdrücklich zugewiesen werden. Globale Administratoren verwenden die bestehende Admin-Ausnahme. + +### Verifikation + +Die MCP-Tests prüfen unter anderem seitenweisen Abruf, Tenant-Filter in den erzeugten SQL-Abfragen, Create/Update/Archive, unbekannte Systemfelder, Fremdmandanten-Verknüpfungen, Kostenstellen-Zyklen, Nummernkollisionen, Transaktions-Rollback und die Preisneuberechnung. Die Datenbanktests verwenden isolierte Fixtures bzw. den Drizzle-Proxy; sie schreiben keine produktiven Daten.