From c10bfac23ba9ae48d75ca1f1f9c4fa4298d46f43 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 28 Oct 2026 08:23:33 +0100 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20Artikel-=20und=20Leistungsbeschreib?= =?UTF-8?q?ung=20in=20Ausgangsbelegpositionen=20=C3=BCbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wie im Belegeditor werden bei mode=normal/service fehlende Beschreibung, Name, Einheit, Preis, USt-Satz und Kostenstelle aus Artikel bzw. Leistung übernommen. descriptionText wird als description gespeichert, weil der Editor nur description liest. Co-Authored-By: Claude Opus 5.5 --- backend/src/mcp/documentSchemas.ts | 3 +- backend/src/mcp/outgoingRowDefaults.ts | 62 ++++++++++++++++++++ backend/src/mcp/tools/accounting.ts | 4 ++ backend/tests/mcpOutgoingRowDefaults.test.ts | 31 ++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 backend/src/mcp/outgoingRowDefaults.ts create mode 100644 backend/tests/mcpOutgoingRowDefaults.test.ts diff --git a/backend/src/mcp/documentSchemas.ts b/backend/src/mcp/documentSchemas.ts index 4a3fb09d..b1f56ecf 100644 --- a/backend/src/mcp/documentSchemas.ts +++ b/backend/src/mcp/documentSchemas.ts @@ -17,7 +17,8 @@ export const outgoingRowsSchema = { pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." }, mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" }, text: { type: "string" }, - descriptionText: { type: "string" }, + description: { type: "string", description: "Positionsbeschreibung. Ohne Angabe wird sie bei mode=normal/service aus Artikel bzw. Leistung übernommen, ebenso fehlender Name, Einheit, Preis und USt-Satz." }, + descriptionText: { type: "string", description: "Veraltet; wird als description übernommen, wenn description fehlt." }, product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." }, service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." }, quantity: { type: "number", default: 1 }, diff --git a/backend/src/mcp/outgoingRowDefaults.ts b/backend/src/mcp/outgoingRowDefaults.ts new file mode 100644 index 00000000..34fe1399 --- /dev/null +++ b/backend/src/mcp/outgoingRowDefaults.ts @@ -0,0 +1,62 @@ +import { and, eq, inArray } from "drizzle-orm" +import { products, services } from "../../db/schema" +import { McpContext } from "./types" + +const isMissing = (value: unknown) => value === undefined || value === null || value === "" +const ids = (rows: Record[], mode: string, key: string) => + [...new Set(rows.filter((row) => row.mode === mode && Number.isSafeInteger(Number(row[key])) && Number(row[key]) > 0).map((row) => Number(row[key])))] + +const parseRow = (row: unknown) => { + if (typeof row !== "string") return row + try { return JSON.parse(row) } catch { return row } +} + +/** + * Mirrors the document editor: selecting a product or service fills name, description, unit, + * price, tax rate and (for services) cost centre. Values passed by the caller always win. + */ +export async function applyOutgoingRowCatalogDefaults(context: Pick, rows: unknown) { + if (!Array.isArray(rows)) return rows + const parsed = rows.map(parseRow) + const objects = parsed.filter((row): row is Record => !!row && typeof row === "object" && !Array.isArray(row)) + const productIds = ids(objects, "normal", "product") + const serviceIds = ids(objects, "service", "service") + const [productRows, serviceRows] = await Promise.all([ + productIds.length ? context.server.db.select().from(products) + .where(and(eq(products.tenant, context.tenantId), eq(products.archived, false), inArray(products.id, productIds))) : [], + serviceIds.length ? context.server.db.select().from(services) + .where(and(eq(services.tenant, context.tenantId), eq(services.archived, false), inArray(services.id, serviceIds))) : [], + ]) + const productMap = new Map(productRows.map((row: any) => [row.id, row] as const)) + const serviceMap = new Map(serviceRows.map((row: any) => [row.id, row] as const)) + + return parsed.map((row, index) => { + if (!row || typeof row !== "object" || Array.isArray(row)) return row + const result = { ...(row as Record) } + let defaults: Record | null = null + if (result.mode === "normal" && !isMissing(result.product)) { + const product = productMap.get(Number(result.product)) + if (!product) throw new Error(`Position ${index + 1}: Artikel ${result.product} nicht gefunden oder archiviert`) + defaults = { text: product.name, description: product.description, unit: product.unit, price: product.selling_price, taxPercent: product.tax_percentage } + } else if (result.mode === "service" && !isMissing(result.service)) { + const service = serviceMap.get(Number(result.service)) + if (!service) throw new Error(`Position ${index + 1}: Leistung ${result.service} nicht gefunden oder archiviert`) + defaults = { + text: service.name, description: service.description, unit: service.unit, + price: service.sellingPriceComposed?.total || service.sellingPrice, taxPercent: service.taxPercentage, costCentre: service.costcentre, + } + } + // The editor stores the position text in description; descriptionText is only derived for the PDF. + if (isMissing(result.description) && !isMissing(result.descriptionText)) result.description = result.descriptionText + if (!defaults) return result + for (const key of ["text", "description", "unit", "costCentre"]) { + if (isMissing(result[key]) && !isMissing(defaults[key])) result[key] = defaults[key] + } + if (isMissing(result.price) && isMissing(result.inputPrice) && !isMissing(defaults.price)) { + result.inputPrice = Number(defaults.price) + result.price = Number(defaults.price) + } + if (isMissing(result.taxPercent) && !isMissing(defaults.taxPercent)) result.taxPercent = Number(defaults.taxPercent) + return result + }) +} diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index f220a6d1..aa836534 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -30,6 +30,7 @@ import { statementAllocationUuidArg, } from "../statementAllocations" import { McpContext, McpTool } from "../types" +import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults" import { outgoingStatusTools } from "./outgoingStatus" export { prepareStatementAllocationInput } from "../statementAllocations" @@ -650,6 +651,7 @@ export const accountingTools: McpTool[] = [ }, async handler(context, args) { assertNoManualDocumentNumber(args) + if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true) payload.state = "Entwurf" applyOutgoingDocumentTaxType(payload, args, String(payload.type)) @@ -718,6 +720,7 @@ export const accountingTools: McpTool[] = [ if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden") + if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) payload.state = "Entwurf" applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows) @@ -836,6 +839,7 @@ export const accountingTools: McpTool[] = [ if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert") + if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type) diff --git a/backend/tests/mcpOutgoingRowDefaults.test.ts b/backend/tests/mcpOutgoingRowDefaults.test.ts new file mode 100644 index 00000000..5d0e677b --- /dev/null +++ b/backend/tests/mcpOutgoingRowDefaults.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getTableName } from "drizzle-orm" +import { applyOutgoingRowCatalogDefaults } from "../src/mcp/outgoingRowDefaults" + +const catalog: Record = { + products: [{ id: 1, name: "Kabel", description: "NYM-J 3x1,5", unit: 2, selling_price: 1.25, tax_percentage: 19 }], + services: [{ id: 5, name: "Montage", description: "Montage vor Ort", unit: 3, sellingPrice: 50, sellingPriceComposed: { total: 55 }, taxPercentage: 7, costcentre: "cc" }], +} +const context: any = { tenantId: 7, server: { db: { select: () => ({ from: (table: any) => ({ where: async () => catalog[getTableName(table)] }) }) } } } + +test("selected products and services bring their description and editor defaults", async () => { + const rows: any = await applyOutgoingRowCatalogDefaults(context, [ + { mode: "normal", product: 1, quantity: 3 }, + JSON.stringify({ mode: "service", service: 5 }), + { mode: "free", text: "Frei", descriptionText: "Alt" }, + ]) + assert.deepEqual(rows[0], { mode: "normal", product: 1, quantity: 3, text: "Kabel", description: "NYM-J 3x1,5", unit: 2, inputPrice: 1.25, price: 1.25, taxPercent: 19 }) + assert.equal(rows[1].description, "Montage vor Ort") + assert.equal(rows[1].price, 55) + assert.equal(rows[1].costCentre, "cc") + assert.equal(rows[2].description, "Alt") +}) + +test("caller values win and unknown catalog entries are rejected", async () => { + const [row]: any = await applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 1, description: "Eigene", price: 2, taxPercent: 7 }]) + assert.equal(row.description, "Eigene") + assert.equal(row.price, 2) + assert.equal(row.taxPercent, 7) + await assert.rejects(applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 9 }]), /Artikel 9 nicht gefunden/) +})