dev #255

Merged
flfeders merged 3 commits from dev into main 2026-10-29 17:20:59 +00:00
4 changed files with 99 additions and 1 deletions
Showing only changes of commit c10bfac23b - Show all commits

View File

@@ -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 },

View File

@@ -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<string, any>[], 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<McpContext, "server" | "tenantId">, rows: unknown) {
if (!Array.isArray(rows)) return rows
const parsed = rows.map(parseRow)
const objects = parsed.filter((row): row is Record<string, any> => !!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<number, any>(productRows.map((row: any) => [row.id, row] as const))
const serviceMap = new Map<number, any>(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<string, any>) }
let defaults: Record<string, unknown> | 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
})
}

View File

@@ -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)

View File

@@ -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<string, any[]> = {
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/)
})