3.0.0 #220
83
backend/src/mcp/masterdataFields.ts
Normal file
83
backend/src/mcp/masterdataFields.ts
Normal 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)]
|
||||
}))
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
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
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
@@ -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<string>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, any>) {
|
||||
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<string>()
|
||||
|
||||
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 ""
|
||||
|
||||
245
backend/tests/mcpMasterdata.test.ts
Normal file
245
backend/tests/mcpMasterdata.test.ts
Normal file
@@ -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<string, any[]> = {}) {
|
||||
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<string, any>
|
||||
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<string, any>) => ({ 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<string, any>) => {
|
||||
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/)
|
||||
})
|
||||
}
|
||||
@@ -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.<Namensraum>.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.
|
||||
|
||||
Reference in New Issue
Block a user