Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c4a2b9c1 | |||
| 6ae8b7ad9e | |||
| 544d607088 | |||
| c10bfac23b | |||
| 1b617c7910 | |||
| 9e003125c2 | |||
| 76e82eae4f | |||
| f814689ddd | |||
| 755f1b7e0d | |||
| c6fd53307c | |||
| 0777315d1d |
@@ -17,7 +17,8 @@ export const outgoingRowsSchema = {
|
|||||||
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||||
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||||
text: { type: "string" },
|
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." },
|
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||||
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||||
quantity: { type: "number", default: 1 },
|
quantity: { type: "number", default: 1 },
|
||||||
|
|||||||
93
backend/src/mcp/fileUploads.ts
Normal file
93
backend/src/mcp/fileUploads.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { assertToolPermission } from "./authz"
|
||||||
|
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
|
||||||
|
import { McpContext, McpTool } from "./types"
|
||||||
|
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
|
||||||
|
import { uploadIncomingInvoiceFile } from "./tools/accounting"
|
||||||
|
import { secrets } from "../utils/secrets"
|
||||||
|
|
||||||
|
export const MCP_UPLOAD_PATH = "/api/mcp/files"
|
||||||
|
export const uploadTargets = ["inbox", "incoming_invoice"] as const
|
||||||
|
export type UploadTarget = typeof uploadTargets[number]
|
||||||
|
|
||||||
|
export type McpFileUpload = {
|
||||||
|
target: UploadTarget
|
||||||
|
filename: string
|
||||||
|
body: Buffer
|
||||||
|
mimeType?: string | null
|
||||||
|
invoiceId?: number | null
|
||||||
|
prepare?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcpUploadUrl() {
|
||||||
|
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
|
||||||
|
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
|
||||||
|
|
||||||
|
/** Reads the query options of the binary upload endpoint. */
|
||||||
|
export function parseUploadQuery(query: Record<string, unknown>) {
|
||||||
|
const target = (query.target ?? "inbox") as UploadTarget
|
||||||
|
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
|
||||||
|
let invoiceId: number | null = null
|
||||||
|
if (query.invoiceId !== undefined && query.invoiceId !== "") {
|
||||||
|
invoiceId = Number(query.invoiceId)
|
||||||
|
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
|
||||||
|
}
|
||||||
|
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
|
||||||
|
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
|
||||||
|
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
|
||||||
|
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
|
||||||
|
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
|
||||||
|
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
|
||||||
|
if (upload.target === "inbox") {
|
||||||
|
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
|
||||||
|
await assertInternalDocumentAccess(context)
|
||||||
|
const mimeType = validateScannerFile(upload.filename, upload.body)
|
||||||
|
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
|
||||||
|
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
|
||||||
|
}
|
||||||
|
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
|
||||||
|
return uploadIncomingInvoiceFile(context, {
|
||||||
|
filename: upload.filename,
|
||||||
|
buffer: upload.body,
|
||||||
|
mimeType: upload.mimeType || "application/octet-stream",
|
||||||
|
invoiceId: upload.invoiceId,
|
||||||
|
prepare: upload.prepare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadUrlTool: McpTool = {
|
||||||
|
name: "documents.files.upload_url",
|
||||||
|
title: "Upload-Adresse für Dateien",
|
||||||
|
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
|
||||||
|
// Informational only; the endpoint checks the permission of the chosen target.
|
||||||
|
requiredPermissions: [],
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
async handler() {
|
||||||
|
const url = mcpUploadUrl()
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
method: "POST",
|
||||||
|
authentication: "Authorization: Bearer <derselbe MCP-Token>",
|
||||||
|
bodies: {
|
||||||
|
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
|
||||||
|
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
|
||||||
|
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
|
||||||
|
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
|
||||||
|
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
|
||||||
|
},
|
||||||
|
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
|
||||||
|
examples: [
|
||||||
|
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
|
||||||
|
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { InvoicePdfLayout } from "../utils/pdf"
|
||||||
|
|
||||||
|
/** Typical letterheads reserve the bottom ~35 mm of A4 for their footer. */
|
||||||
|
export const DEFAULT_CONTENT_BOTTOM_MM = 262
|
||||||
|
|
||||||
|
type Warning = { type: string, page: number, message: string, insertPagebreakBeforeIndex?: number }
|
||||||
|
|
||||||
|
const round = (value: number) => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarises where rows land and suggests page breaks. FEDEO never breaks pages automatically,
|
||||||
|
* so content below the usable area runs into the letterhead footer or off the page.
|
||||||
|
*/
|
||||||
|
export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxContentBottomMm = DEFAULT_CONTENT_BOTTOM_MM) {
|
||||||
|
const warnings: Warning[] = []
|
||||||
|
const blocks = [
|
||||||
|
...layout.rows.filter((row) => row.mode !== "pagebreak").map((row) => ({ page: row.page, bottomMm: row.bottomMm })),
|
||||||
|
...(layout.totals ? [layout.totals] : []),
|
||||||
|
...(layout.endText ? [layout.endText] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const pages = Array.from({ length: layout.pageCount }, (_, offset) => {
|
||||||
|
const page = offset + 1
|
||||||
|
const rows = layout.rows.filter((row) => row.page === page && row.mode !== "pagebreak")
|
||||||
|
const contentBottomMm = Math.max(0, ...blocks.filter((block) => block.page === page).map((block) => block.bottomMm))
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
rows: rows.map((row) => ({ index: row.index, pos: row.pos, mode: row.mode, text: row.text, topMm: round(row.topMm), bottomMm: round(row.bottomMm) })),
|
||||||
|
contentBottomMm: round(contentBottomMm),
|
||||||
|
freeMm: round(maxContentBottomMm - contentBottomMm),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const overflowing = page.rows.find((row) => row.bottomMm > maxContentBottomMm)
|
||||||
|
if (overflowing) {
|
||||||
|
// Keep headings with their first position instead of leaving them at the page end.
|
||||||
|
let before = page.rows.indexOf(overflowing)
|
||||||
|
while (before > 0 && page.rows[before - 1].mode === "title") before -= 1
|
||||||
|
const target = page.rows[before]
|
||||||
|
warnings.push({
|
||||||
|
type: "overflow",
|
||||||
|
page: page.page,
|
||||||
|
message: `Position ${overflowing.pos || overflowing.index + 1} reicht bis ${overflowing.bottomMm} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
|
||||||
|
...(target.index > 0 ? { insertPagebreakBeforeIndex: target.index } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const last = page.rows[page.rows.length - 1]
|
||||||
|
if (last?.mode === "title" && page.page < layout.pageCount) {
|
||||||
|
warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index })
|
||||||
|
}
|
||||||
|
if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) {
|
||||||
|
warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The renderer moves the payment QR code to an extra page when the amounts reach its spot.
|
||||||
|
const qr = layout.paymentQr
|
||||||
|
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
|
||||||
|
if (qr && qrAlone) {
|
||||||
|
const previous = pages[qr.page - 2]
|
||||||
|
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
|
||||||
|
let before = reaching ? previous.rows.indexOf(reaching) : -1
|
||||||
|
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
|
||||||
|
warnings.push({
|
||||||
|
type: "paymentQrOwnPage",
|
||||||
|
page: qr.page,
|
||||||
|
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
|
||||||
|
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) {
|
||||||
|
if (!block || block.bottomMm <= maxContentBottomMm || pages[block.page - 1]?.rows.some((row) => row.bottomMm > maxContentBottomMm)) continue
|
||||||
|
warnings.push({
|
||||||
|
type: "overflow",
|
||||||
|
page: block.page,
|
||||||
|
message: `${name} reicht bis ${round(block.bottomMm)} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
|
||||||
|
insertPagebreakBeforeIndex: layout.rows.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
pageHeightMm: round(layout.pageHeightMm),
|
||||||
|
maxContentBottomMm,
|
||||||
|
pages,
|
||||||
|
totals: layout.totals && { page: layout.totals.page, topMm: round(layout.totals.topMm), bottomMm: round(layout.totals.bottomMm) },
|
||||||
|
endText: layout.endText && { page: layout.endText.page, topMm: round(layout.endText.topMm), bottomMm: round(layout.endText.bottomMm) },
|
||||||
|
paymentQr: layout.paymentQr && { page: layout.paymentQr.page, topMm: round(layout.paymentQr.topMm), leftMm: round(layout.paymentQr.leftMm) },
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
hint: "Seitenumbruch setzen: an der Stelle insertPagebreakBeforeIndex ein Objekt {\"mode\":\"pagebreak\"} in rows einfügen (Index 0-basiert; rows.length = vor Summenblock). Zuerst mit rows in dieser Vorschau prüfen, dann mit accounting.outgoing_documents.update speichern. Die Bilder sind maßgeblich; die mm-Werte sind Schätzungen des Renderers.",
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
62
backend/src/mcp/outgoingRowDefaults.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { masterdataTools } from "./tools/masterdata"
|
|||||||
import { organisationTools } from "./tools/organisation"
|
import { organisationTools } from "./tools/organisation"
|
||||||
import { wikiTools } from "./tools/wiki"
|
import { wikiTools } from "./tools/wiki"
|
||||||
import { documentTools } from "./tools/documents"
|
import { documentTools } from "./tools/documents"
|
||||||
|
import { archiveTools } from "./tools/archive"
|
||||||
|
import { uploadUrlTool } from "./fileUploads"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
@@ -10,6 +12,8 @@ export const mcpTools = [
|
|||||||
...organisationTools,
|
...organisationTools,
|
||||||
...wikiTools,
|
...wikiTools,
|
||||||
...documentTools,
|
...documentTools,
|
||||||
|
...archiveTools,
|
||||||
|
uploadUrlTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { McpToolResult } from "./types"
|
|||||||
|
|
||||||
const OMIT_ARCHIVED = Symbol("omit-archived")
|
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||||
|
|
||||||
|
/** Attach images to a tool payload; they become MCP image content next to the JSON text. */
|
||||||
|
export const MCP_IMAGES = Symbol("mcp-images")
|
||||||
|
export type McpImage = { data: string, mimeType: string }
|
||||||
|
export const withImages = <T extends Record<string, unknown>>(payload: T, images: McpImage[]) =>
|
||||||
|
Object.assign(payload, { [MCP_IMAGES]: images })
|
||||||
|
|
||||||
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult {
|
|||||||
? resultPayload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: resultPayload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
|
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(resultPayload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
|
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
||||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
||||||
import {
|
import {
|
||||||
@@ -22,11 +22,19 @@ import { saveFile } from "../../utils/files"
|
|||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
|
import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service"
|
||||||
|
import { loadBankBalances } from "../../modules/bank-balances.service"
|
||||||
|
import { dateField } from "../masterdataFields"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
|
||||||
|
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
|
||||||
|
import { withImages } from "../result"
|
||||||
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||||
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
@@ -309,6 +317,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoiceFileUpload = {
|
||||||
|
filename: string
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
invoiceId?: number | null
|
||||||
|
invoiceArgs?: Record<string, unknown> | null
|
||||||
|
prepare?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
|
||||||
|
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
|
||||||
|
const { filename, buffer, mimeType } = upload
|
||||||
|
const invoiceId = upload.invoiceId || null
|
||||||
|
const invoiceArgs = upload.invoiceArgs || null
|
||||||
|
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
|
||||||
|
if (!buffer.length) throw new Error("Datei ist leer")
|
||||||
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
||||||
|
|
||||||
|
let linkedInvoice: any = null
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||||
|
linkedInvoice = existing
|
||||||
|
} else if (invoiceArgs) {
|
||||||
|
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||||
|
payload.state = "Entwurf"
|
||||||
|
|
||||||
|
const [createdInvoice] = await context.server.db
|
||||||
|
.insert(incominginvoices)
|
||||||
|
.values(payload as any)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
linkedInvoice = createdInvoice
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||||
|
const saved = await saveFile(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: buffer,
|
||||||
|
contentType: mimeType,
|
||||||
|
},
|
||||||
|
defaults.folderId,
|
||||||
|
defaults.fileTagId,
|
||||||
|
{
|
||||||
|
incominginvoice: linkedInvoice?.id || null,
|
||||||
|
createdBy: context.userId,
|
||||||
|
updatedBy: context.userId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||||
|
|
||||||
|
if (shouldPrepare) {
|
||||||
|
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
|
||||||
|
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [file] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (file?.incominginvoice && !linkedInvoice) {
|
||||||
|
const [preparedInvoice] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
linkedInvoice = preparedInvoice || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
invoice: linkedInvoice,
|
||||||
|
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||||
|
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildIncomingInvoicePayload = (
|
const buildIncomingInvoicePayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -555,6 +655,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
assertNoManualDocumentNumber(args)
|
assertNoManualDocumentNumber(args)
|
||||||
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
||||||
@@ -623,6 +724,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
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 (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)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
||||||
@@ -702,6 +804,69 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document }
|
return { document }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.outgoing_documents.preview",
|
||||||
|
title: "Ausgangsbeleg-Vorschau",
|
||||||
|
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
|
||||||
|
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
|
||||||
|
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
|
||||||
|
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
|
||||||
|
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
|
||||||
|
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
|
||||||
|
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
|
||||||
|
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
|
||||||
|
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
|
||||||
|
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|
||||||
|
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
|
||||||
|
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
|
|
||||||
|
const document: Record<string, any> = { ...existing }
|
||||||
|
if (args.rows !== undefined) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
|
||||||
|
}
|
||||||
|
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
|
||||||
|
document.rows = payload.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
|
||||||
|
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
|
||||||
|
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
|
||||||
|
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
|
||||||
|
|
||||||
|
return withImages({
|
||||||
|
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
|
||||||
|
previewOnly: args.rows !== undefined,
|
||||||
|
pageCount: layout.pageCount,
|
||||||
|
imagePages: images.map((image) => image.page),
|
||||||
|
...analysis,
|
||||||
|
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
|
||||||
|
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.finalize",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
@@ -741,6 +906,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
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 payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||||
|
|
||||||
@@ -1011,7 +1177,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.files.upload",
|
name: "accounting.incoming_invoices.files.upload",
|
||||||
title: "Datei für Eingangsbeleg hochladen",
|
title: "Datei für Eingangsbeleg hochladen",
|
||||||
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
|
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
|
||||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1047,83 +1213,14 @@ export const accountingTools: McpTool[] = [
|
|||||||
const filename = stringArg(args, "filename")
|
const filename = stringArg(args, "filename")
|
||||||
if (!filename) throw new Error("filename ist erforderlich")
|
if (!filename) throw new Error("filename ist erforderlich")
|
||||||
|
|
||||||
const buffer = base64BufferArg(args, "contentBase64")
|
return uploadIncomingInvoiceFile(context, {
|
||||||
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
|
||||||
const invoiceId = numberArg(args, "invoiceId")
|
|
||||||
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
|
||||||
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
|
||||||
|
|
||||||
let linkedInvoice: any = null
|
|
||||||
|
|
||||||
if (invoiceId) {
|
|
||||||
const [existing] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(incominginvoices)
|
|
||||||
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
|
||||||
linkedInvoice = existing
|
|
||||||
} else if (invoiceArgs) {
|
|
||||||
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
|
||||||
payload.state = "Entwurf"
|
|
||||||
|
|
||||||
const [createdInvoice] = await context.server.db
|
|
||||||
.insert(incominginvoices)
|
|
||||||
.values(payload as any)
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
linkedInvoice = createdInvoice
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
|
||||||
const saved = await saveFile(
|
|
||||||
context.server,
|
|
||||||
context.tenantId,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
filename,
|
filename,
|
||||||
content: buffer,
|
buffer: base64BufferArg(args, "contentBase64"),
|
||||||
contentType: mimeType,
|
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
|
||||||
},
|
invoiceId: numberArg(args, "invoiceId"),
|
||||||
defaults.folderId,
|
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
|
||||||
defaults.fileTagId,
|
prepare: args.prepare !== false,
|
||||||
{
|
})
|
||||||
incominginvoice: linkedInvoice?.id || null,
|
|
||||||
createdBy: context.userId,
|
|
||||||
updatedBy: context.userId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
|
||||||
|
|
||||||
if (shouldPrepare) {
|
|
||||||
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [file] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (file?.incominginvoice && !linkedInvoice) {
|
|
||||||
const [preparedInvoice] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(incominginvoices)
|
|
||||||
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
linkedInvoice = preparedInvoice || null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
file,
|
|
||||||
invoice: linkedInvoice,
|
|
||||||
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
|
||||||
prepared: Boolean(shouldPrepare && linkedInvoice),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1152,6 +1249,62 @@ export const accountingTools: McpTool[] = [
|
|||||||
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.prepare",
|
||||||
|
title: "Belegvorbereitung starten",
|
||||||
|
description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fileIds: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "uuid" },
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 50,
|
||||||
|
description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
let fileIds: string[] | undefined
|
||||||
|
if (args.fileIds !== undefined) {
|
||||||
|
if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) {
|
||||||
|
throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten")
|
||||||
|
}
|
||||||
|
if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) {
|
||||||
|
throw new Error("fileIds darf nur gültige UUIDs enthalten")
|
||||||
|
}
|
||||||
|
fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds })
|
||||||
|
if (result.error && !result.filesFound) throw new Error(result.error)
|
||||||
|
|
||||||
|
const invoiceIds = result.created.map((entry) => entry.invoiceId)
|
||||||
|
const invoices = invoiceIds.length
|
||||||
|
? await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds)))
|
||||||
|
: []
|
||||||
|
const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
filesFound: result.filesFound,
|
||||||
|
created: result.created.map((entry) => {
|
||||||
|
const invoice = invoices.find((row) => row.id === entry.invoiceId) || null
|
||||||
|
return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record<string, any>) : null }
|
||||||
|
}),
|
||||||
|
skipped: [
|
||||||
|
...result.skipped,
|
||||||
|
...(fileIds || []).filter((id) => !handled.has(id))
|
||||||
|
.map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.create",
|
name: "accounting.incoming_invoices.create",
|
||||||
title: "Eingangsbeleg erstellen",
|
title: "Eingangsbeleg erstellen",
|
||||||
@@ -1229,6 +1382,44 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.cost_centres.update",
|
||||||
|
title: "Kostenstellen eines Eingangsbelegs ändern",
|
||||||
|
description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "accountCostCentres"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
accountCostCentres: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["index", "costcentre"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const invoice = await updateIncomingInvoiceCostCentres(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
id,
|
||||||
|
{ accountCostCentres: args.accountCostCentres as any },
|
||||||
|
)
|
||||||
|
return { invoice }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.book",
|
name: "accounting.incoming_invoices.book",
|
||||||
title: "Eingangsbeleg buchen",
|
title: "Eingangsbeleg buchen",
|
||||||
@@ -1310,6 +1501,26 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { invoice: updated }
|
return { invoice: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.bank_accounts.balances",
|
||||||
|
title: "Kontostände abfragen",
|
||||||
|
description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.",
|
||||||
|
requiredPermissions: ["accounting.bank.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." },
|
||||||
|
asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const account = numberArg(args, "account")
|
||||||
|
const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf")
|
||||||
|
const result = await loadBankBalances(context.server, context.tenantId, { account, asOf })
|
||||||
|
if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden")
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.bank_statements.list",
|
name: "accounting.bank_statements.list",
|
||||||
title: "Bankumsätze auflisten",
|
title: "Bankumsätze auflisten",
|
||||||
|
|||||||
150
backend/src/mcp/tools/archive.ts
Normal file
150
backend/src/mcp/tools/archive.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
|
||||||
|
import * as tables from "../../../db/schema"
|
||||||
|
import { assertToolPermission } from "../authz"
|
||||||
|
import { assertInternalDocumentAccess } from "../documentFiles"
|
||||||
|
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
|
||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
|
||||||
|
|
||||||
|
type ArchiveEntity = {
|
||||||
|
label: string
|
||||||
|
table: any
|
||||||
|
/** Key used by the history log and the generic resource routes. */
|
||||||
|
resource: string
|
||||||
|
permission: string
|
||||||
|
searchColumns: string[]
|
||||||
|
uuid?: boolean
|
||||||
|
internalDocuments?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
|
||||||
|
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
|
||||||
|
|
||||||
|
/** Every entity the MCP can archive; restore checks the matching write permission. */
|
||||||
|
export const archiveEntities: Record<string, ArchiveEntity> = {
|
||||||
|
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
|
||||||
|
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
|
||||||
|
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
|
||||||
|
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
|
||||||
|
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
|
||||||
|
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
|
||||||
|
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
|
||||||
|
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
|
||||||
|
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
|
||||||
|
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
|
||||||
|
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
|
||||||
|
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
|
||||||
|
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
|
||||||
|
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
|
||||||
|
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
|
||||||
|
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityField: InputField = {
|
||||||
|
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
|
||||||
|
parse(value, path) {
|
||||||
|
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const entityIdField: InputField = {
|
||||||
|
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
|
||||||
|
parse: (value) => value,
|
||||||
|
}
|
||||||
|
const searchField: InputField = {
|
||||||
|
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
|
||||||
|
parse(value, path) {
|
||||||
|
const text = textField().parse(value, path)
|
||||||
|
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const listInput = objectField({
|
||||||
|
entity: entityField,
|
||||||
|
search: searchField,
|
||||||
|
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
|
||||||
|
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
|
||||||
|
})
|
||||||
|
const restoreInput = objectField({ entity: entityField, id: entityIdField })
|
||||||
|
|
||||||
|
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
|
||||||
|
|
||||||
|
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
|
||||||
|
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
|
||||||
|
|
||||||
|
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
|
||||||
|
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
|
||||||
|
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
|
||||||
|
const input = restoreInput.parse(args, "arguments")
|
||||||
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
||||||
|
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
|
||||||
|
const entity = archiveEntities[input.entity]
|
||||||
|
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTool: McpTool = {
|
||||||
|
name: "archive.entities.list",
|
||||||
|
title: "Archivierte Einträge auflisten",
|
||||||
|
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
|
||||||
|
// Checked per entity in the handler.
|
||||||
|
requiredPermissions: [],
|
||||||
|
inputSchema: { ...listInput.schema, required: ["entity"] },
|
||||||
|
async handler(context, args) {
|
||||||
|
const input = listInput.parse(args, "arguments")
|
||||||
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
||||||
|
const entity = archiveEntities[input.entity]
|
||||||
|
await authorize(context, listTool, entity, "read")
|
||||||
|
const table = entity.table
|
||||||
|
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
|
||||||
|
if (input.search) {
|
||||||
|
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
|
||||||
|
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
|
||||||
|
}
|
||||||
|
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
|
||||||
|
const rows = await context.server.db.select().from(table).where(and(...conditions))
|
||||||
|
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
|
||||||
|
const result = paginatedResult(rows, input)
|
||||||
|
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreTool: McpTool = {
|
||||||
|
name: "archive.entities.restore",
|
||||||
|
title: "Archivierten Eintrag wiederherstellen",
|
||||||
|
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
|
||||||
|
requiredPermissions: [],
|
||||||
|
readOnly: false,
|
||||||
|
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
|
||||||
|
async handler(context, args) {
|
||||||
|
const { key, entity, id } = prepareArchiveRestoreInput(args)
|
||||||
|
await authorize(context, restoreTool, entity, "write")
|
||||||
|
const table = entity.table
|
||||||
|
return context.server.db.transaction(async (tx) => {
|
||||||
|
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
|
||||||
|
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
|
||||||
|
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
|
||||||
|
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
|
||||||
|
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
|
||||||
|
const [result] = await tx.update(table).set({
|
||||||
|
archived: false,
|
||||||
|
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
|
||||||
|
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
|
||||||
|
}).where(scope).returning()
|
||||||
|
if (["products", "services"].includes(entity.resource)) {
|
||||||
|
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
|
||||||
|
}
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
|
||||||
|
action: "restored", oldVal: existing, newVal: result,
|
||||||
|
})
|
||||||
|
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const archiveTools: McpTool[] = [listTool, restoreTool]
|
||||||
@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
|
|||||||
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
||||||
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
||||||
}),
|
}),
|
||||||
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
||||||
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
||||||
["filename", "contentBase64"], async (context, args) => {
|
["filename", "contentBase64"], async (context, args) => {
|
||||||
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type McpToolResult = {
|
|||||||
content: Array<{
|
content: Array<{
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
|
} | {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mimeType: string
|
||||||
}>
|
}>
|
||||||
structuredContent?: Record<string, unknown>
|
structuredContent?: Record<string, unknown>
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
@@ -23,6 +27,8 @@ export type McpTool = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
requiredPermissions: string[]
|
requiredPermissions: string[]
|
||||||
|
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
|
||||||
|
readOnly?: boolean
|
||||||
inputSchema: Record<string, unknown>
|
inputSchema: Record<string, unknown>
|
||||||
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
71
backend/src/modules/bank-balances.service.ts
Normal file
71
backend/src/modules/bank-balances.service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
|
import { bankaccounts, bankstatements } from "../../db/schema"
|
||||||
|
|
||||||
|
export const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||||
|
|
||||||
|
type BalanceAccount = Pick<typeof bankaccounts.$inferSelect, "id" | "name" | "iban" | "ownerName" | "bankId" | "datevNumber" | "balance" | "expired" | "syncedAt">
|
||||||
|
|
||||||
|
const roundMoney = (value: number) => Math.round(value * 100) / 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank accounts store the last synced booked balance; cashbooks store their opening balance
|
||||||
|
* and derive the current balance from their bookings. For a past date, bank balances are
|
||||||
|
* reconstructed by reverting the statements booked after that date.
|
||||||
|
*/
|
||||||
|
export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) {
|
||||||
|
const cashbook = account.bankId === CASHBOOK_BANK_ID
|
||||||
|
const stored = Number(account.balance || 0)
|
||||||
|
const balance = cashbook ? stored + statementSum : stored - statementSum
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
type: cashbook ? "cashbook" : "bank",
|
||||||
|
name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"),
|
||||||
|
iban: cashbook ? null : account.iban,
|
||||||
|
datevNumber: account.datevNumber,
|
||||||
|
expired: account.expired,
|
||||||
|
balance: roundMoney(balance),
|
||||||
|
currency: "EUR",
|
||||||
|
...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }),
|
||||||
|
source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) {
|
||||||
|
const asOf = options.asOf || null
|
||||||
|
const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)]
|
||||||
|
if (options.account) conditions.push(eq(bankaccounts.id, options.account))
|
||||||
|
const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id)
|
||||||
|
|
||||||
|
const sums = async (dateCondition: any) => {
|
||||||
|
const rows = await server.db
|
||||||
|
.select({ account: bankstatements.account, total: sql<string>`coalesce(sum(${bankstatements.amount}), 0)` })
|
||||||
|
.from(bankstatements)
|
||||||
|
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : [])))
|
||||||
|
.groupBy(bankstatements.account)
|
||||||
|
return new Map(rows.map((row) => [row.account, Number(row.total)]))
|
||||||
|
}
|
||||||
|
// Synced statements may carry a full timestamp; compare the calendar day only.
|
||||||
|
const bookingDay = sql`left(${bankstatements.date}, 10)`
|
||||||
|
// Cashbooks need bookings up to the date, bank accounts only those after it.
|
||||||
|
const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null)
|
||||||
|
const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map<number, number>()
|
||||||
|
|
||||||
|
const balances = accounts.map((account) => calculateAccountBalance(
|
||||||
|
account,
|
||||||
|
(account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0,
|
||||||
|
asOf,
|
||||||
|
))
|
||||||
|
const active = balances.filter((account) => !account.expired)
|
||||||
|
return {
|
||||||
|
asOf,
|
||||||
|
accounts: balances,
|
||||||
|
totals: {
|
||||||
|
balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
currency: "EUR",
|
||||||
|
note: "Summen ohne abgelaufene Bankverbindungen.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
incominginvoices,
|
incominginvoices,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import { eq, and, isNull, not } from "drizzle-orm"
|
import { eq, and, inArray, isNull, not } from "drizzle-orm"
|
||||||
|
|
||||||
const formatInvoiceItemDescription = (item: any) => {
|
const formatInvoiceItemDescription = (item: any) => {
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -86,8 +86,25 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
|||||||
return itemInfo
|
return itemInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoicePreparationResult = {
|
||||||
|
filesFound: number
|
||||||
|
created: Array<{ fileId: string, invoiceId: number, source: string | null }>
|
||||||
|
skipped: Array<{ fileId: string, reason: string }>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoicePreparationStatus = {
|
||||||
|
running: boolean
|
||||||
|
startedAt: string | null
|
||||||
|
finishedAt: string | null
|
||||||
|
result: IncomingInvoicePreparationResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAlreadyPreparedError extends Error {}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
|
||||||
|
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
|
||||||
console.log("▶ Starting Incoming Invoice Preparation")
|
console.log("▶ Starting Incoming Invoice Preparation")
|
||||||
|
|
||||||
const tenantsRes = await server.db
|
const tenantsRes = await server.db
|
||||||
@@ -98,7 +115,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (!tenantsRes.length) {
|
if (!tenantsRes.length) {
|
||||||
console.log("No tenants with autoPrepareIncomingInvoices = true")
|
console.log("No tenants with autoPrepareIncomingInvoices = true")
|
||||||
return
|
return { ...result, error: "Mandant nicht gefunden" }
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
|
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
|
||||||
@@ -124,6 +141,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
const invoiceFileTag = tagRes?.[0]?.id
|
const invoiceFileTag = tagRes?.[0]?.id
|
||||||
if (!invoiceFileTag) {
|
if (!invoiceFileTag) {
|
||||||
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
|
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
|
||||||
|
result.error = "Dateityp für Eingangsrechnungen fehlt"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +155,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
eq(files.type, invoiceFileTag),
|
eq(files.type, invoiceFileTag),
|
||||||
isNull(files.incominginvoice),
|
isNull(files.incominginvoice),
|
||||||
eq(files.archived, false),
|
eq(files.archived, false),
|
||||||
not(isNull(files.path))
|
not(isNull(files.path)),
|
||||||
|
...(fileIds ? [inArray(files.id, fileIds)] : [])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result.filesFound += filesRes.length
|
||||||
|
|
||||||
if (!filesRes.length) {
|
if (!filesRes.length) {
|
||||||
console.log(`No invoice files for tenant ${tenantId}`)
|
console.log(`No invoice files for tenant ${tenantId}`)
|
||||||
continue
|
continue
|
||||||
@@ -150,6 +171,23 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
|
try {
|
||||||
|
await processFile(tenantId, file, result)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FileAlreadyPreparedError) {
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
let fileData: Buffer
|
let fileData: Buffer
|
||||||
@@ -158,7 +196,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
fileData = await loadFileBuffer(file.path!)
|
fileData = await loadFileBuffer(file.path!)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||||
continue
|
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||||
@@ -186,7 +225,10 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||||
|
|
||||||
if (electronicInvoice.container === "xml") continue
|
if (electronicInvoice.container === "xml") {
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,50 +237,86 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
continue
|
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// 4️⃣ IncomingInvoice erstellen
|
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
|
||||||
|
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
|
||||||
|
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
const inserted = await server.db
|
const newInvoice = await server.db.transaction(async (tx) => {
|
||||||
|
const [invoice] = await tx
|
||||||
.insert(incominginvoices)
|
.insert(incominginvoices)
|
||||||
.values(itemInfo)
|
.values(itemInfo)
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
const newInvoice = inserted?.[0]
|
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
|
||||||
|
|
||||||
if (!newInvoice) {
|
const linked = await tx
|
||||||
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 5️⃣ Datei mit incominginvoice-ID verbinden
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
await server.db
|
|
||||||
.update(files)
|
.update(files)
|
||||||
.set({ incominginvoice: newInvoice.id })
|
.set({ incominginvoice: invoice.id })
|
||||||
.where(eq(files.id, file.id))
|
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
|
||||||
|
.returning({ id: files.id })
|
||||||
|
|
||||||
|
if (!linked.length) throw new FileAlreadyPreparedError()
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
})
|
||||||
|
|
||||||
|
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
|
||||||
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
|
||||||
|
const running = new Map<number, Promise<unknown>>()
|
||||||
|
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
|
||||||
|
|
||||||
|
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
|
||||||
return
|
const previous = running.get(tenant) || Promise.resolve()
|
||||||
}
|
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
|
||||||
|
running.set(tenant, current)
|
||||||
return {
|
try {
|
||||||
run: async (tenant:number) => {
|
const result = await current
|
||||||
await processInvoices(tenant)
|
|
||||||
console.log("Incoming Invoice Preparation Completed.")
|
console.log("Incoming Invoice Preparation Completed.")
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
if (running.get(tenant) === current) running.delete(tenant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = (tenant: number): IncomingInvoicePreparationStatus =>
|
||||||
|
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
|
||||||
|
|
||||||
|
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
|
||||||
|
const start = (tenant: number): IncomingInvoicePreparationStatus => {
|
||||||
|
const current = status(tenant)
|
||||||
|
if (current.running) return current
|
||||||
|
|
||||||
|
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
|
||||||
|
backgroundRuns.set(tenant, next)
|
||||||
|
|
||||||
|
run(tenant)
|
||||||
|
.then((result) => {
|
||||||
|
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
|
||||||
|
backgroundRuns.set(tenant, {
|
||||||
|
...next,
|
||||||
|
running: false,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run, start, status }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { costcentres, incominginvoices } from "../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../utils/history"
|
||||||
|
|
||||||
|
export type AccountCostCentreAssignment = {
|
||||||
|
index: number
|
||||||
|
costcentre: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoiceCostCentreUpdate = {
|
||||||
|
accountCostCentres?: AccountCostCentreAssignment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceError = (message: string, statusCode = 400) =>
|
||||||
|
Object.assign(new Error(message), { statusCode })
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
|
||||||
|
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
|
||||||
|
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
|
||||||
|
|
||||||
|
const seen = new Set<number>()
|
||||||
|
return input.map((assignment) => {
|
||||||
|
const index = assignment?.index
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
|
||||||
|
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
|
||||||
|
seen.add(index)
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
|
||||||
|
}
|
||||||
|
return { index, costcentre }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAccountCostCentreAssignments(
|
||||||
|
accounts: unknown,
|
||||||
|
assignments: AccountCostCentreAssignment[] = [],
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
|
||||||
|
|
||||||
|
for (const { index } of assignments) {
|
||||||
|
const account = accounts[index]
|
||||||
|
if (!account || typeof account !== "object" || Array.isArray(account)) {
|
||||||
|
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
|
||||||
|
return accounts.map((account, index) => {
|
||||||
|
if (!assignmentMap.has(index)) return account
|
||||||
|
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
|
||||||
|
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIncomingInvoiceCostCentres(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
invoiceId: number,
|
||||||
|
input: IncomingInvoiceCostCentreUpdate,
|
||||||
|
) {
|
||||||
|
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
eq(incominginvoices.archived, false),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
const referencedCostCentreIds = [...new Set(
|
||||||
|
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||||
|
)]
|
||||||
|
|
||||||
|
if (referencedCostCentreIds.length) {
|
||||||
|
const validCostCentres = await server.db
|
||||||
|
.select({ id: costcentres.id })
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
inArray(costcentres.id, referencedCostCentreIds),
|
||||||
|
))
|
||||||
|
|
||||||
|
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||||
|
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(incominginvoices)
|
||||||
|
.set({
|
||||||
|
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
eq(incominginvoices.archived, false),
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: "incominginvoices",
|
||||||
|
entityId: invoiceId,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: "Kostenstellen des Eingangsbelegs geändert",
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
276
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
276
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
authProfiles,
|
||||||
|
bankaccounts,
|
||||||
|
contacts,
|
||||||
|
contracts,
|
||||||
|
createddocuments,
|
||||||
|
customers,
|
||||||
|
letterheads,
|
||||||
|
plants,
|
||||||
|
products,
|
||||||
|
projects,
|
||||||
|
services,
|
||||||
|
tenants,
|
||||||
|
units,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { documentTemplateHandlebars } from "../utils/handlebars"
|
||||||
|
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
|
||||||
|
|
||||||
|
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
||||||
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
|
const invoiceLikeDocumentTypes = ["invoices", "advanceInvoices", "serialInvoices", "cancellationInvoices"]
|
||||||
|
|
||||||
|
/** Same format as useCurrency in the frontend. */
|
||||||
|
export const renderCurrency = (value: any, currency = "€") =>
|
||||||
|
`${Number(value).toFixed(2).replace(".", ",")} ${currency}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
|
||||||
|
|
||||||
|
const rowSum = (row: any) => Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)
|
||||||
|
|
||||||
|
export function documentNumberTitle(type: string) {
|
||||||
|
if (invoiceLikeDocumentTypes.includes(type)) return "Rechnungsnummer"
|
||||||
|
if (quoteLikeDocumentTypes.includes(type)) return type === "costEstimates" ? "Kostenschätzungsnummer" : "Angebotsnummer"
|
||||||
|
if (deliveryNoteLikeDocumentTypes.includes(type)) return type === "packingSlips" ? "Packscheinnummer" : "Lieferscheinnummer"
|
||||||
|
if (type === "confirmationOrders") return "Auftragsbestätigungsnr."
|
||||||
|
return "Belegnummer"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumberLikeValue = (value: any) => {
|
||||||
|
if (value === null || value === undefined || value === "") return "-"
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value)
|
||||||
|
const directValue = value.value ?? value.number ?? value.currentNumber ?? value.customerNumber ?? value.id
|
||||||
|
if (directValue !== null && directValue !== undefined && directValue !== "") return String(directValue)
|
||||||
|
if (value.prefix !== undefined || value.nextNumber !== undefined || value.suffix !== undefined) {
|
||||||
|
return `${value.prefix || ""}${value.nextNumber || ""}${value.suffix || ""}` || "-"
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of documentTotal from the document editor. */
|
||||||
|
export function calculateOutgoingDocumentTotals(document: any, advanceInvoices: any[]) {
|
||||||
|
let totalNet = 0, total19 = 0, totalNet19 = 0, total7 = 0, totalNet7 = 0, totalNet0 = 0
|
||||||
|
const rows = document.rows || []
|
||||||
|
rows.filter((row: any) => !row.optional && !row.alternative).forEach((row: any) => {
|
||||||
|
if (["pagebreak", "title", "text"].includes(row.mode)) return
|
||||||
|
const rowPrice = Number(rowSum(row).toFixed(3))
|
||||||
|
totalNet += rowPrice
|
||||||
|
if (row.taxPercent === 19) { total19 += rowPrice * 0.19; totalNet19 += rowPrice }
|
||||||
|
else if (row.taxPercent === 7) { total7 += rowPrice * 0.07; totalNet7 += rowPrice }
|
||||||
|
else if (row.taxPercent === 0) totalNet0 += rowPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleSums: Record<string, number> = {}
|
||||||
|
const titleSumsTransfer: Record<string, number> = {}
|
||||||
|
let activeTitleKeys: string[] = []
|
||||||
|
let transferCounter = 0
|
||||||
|
rows.forEach((row: any) => {
|
||||||
|
if (row.mode === "title") {
|
||||||
|
const title = `${row.pos} - ${row.text}`
|
||||||
|
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||||
|
titleSums[title] = 0
|
||||||
|
if (activeTitleKeys.some(Boolean)) titleSumsTransfer[title] = transferCounter
|
||||||
|
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
|
||||||
|
activeTitleKeys[titleLevel - 1] = title
|
||||||
|
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
|
||||||
|
} else if (!["pagebreak", "text"].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
|
||||||
|
const sum = rowSum(row)
|
||||||
|
activeTitleKeys.forEach((key) => { if (key) titleSums[key] = Number(titleSums[key]) + sum })
|
||||||
|
transferCounter += sum
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
|
||||||
|
const totalGrossAlreadyPaid = advanceInvoices.reduce((sum, invoice) => {
|
||||||
|
const advanceRow = (invoice?.rows || []).find((row: any) => row.advanceInvoiceData)
|
||||||
|
return advanceRow ? sum + advanceRow.price * ((100 + advanceRow.taxPercent) / 100) : sum
|
||||||
|
}, 0)
|
||||||
|
const totalSumToPay = document.type === "invoices" ? totalGross - totalGrossAlreadyPaid
|
||||||
|
: document.type === "cancellationInvoices" ? totalGross + totalGrossAlreadyPaid : 0
|
||||||
|
|
||||||
|
return { titleSums, titleSumsTransfer, totalNet, total19, totalNet19, total7, totalNet7, total0: 0, totalNet0, totalGross, totalGrossAlreadyPaid, totalSumToPay }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutgoingDocumentPdfContext = {
|
||||||
|
tenant: any
|
||||||
|
customer: any
|
||||||
|
contact?: any
|
||||||
|
contactPerson?: any
|
||||||
|
plant?: any
|
||||||
|
project?: any
|
||||||
|
contract?: any
|
||||||
|
units: any[]
|
||||||
|
products: any[]
|
||||||
|
services: any[]
|
||||||
|
bankAccounts: any[]
|
||||||
|
advanceInvoices: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of getDocumentData from the document editor, so MCP previews match the UI preview. */
|
||||||
|
export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocumentPdfContext) {
|
||||||
|
const document = structuredClone(source)
|
||||||
|
document.rows = document.rows || []
|
||||||
|
const address = document.address || {}
|
||||||
|
const customer = data.customer || {}
|
||||||
|
const contact = data.contact || null
|
||||||
|
const contactPerson = data.contactPerson || {}
|
||||||
|
const businessInfo = data.tenant.businessInfo || {}
|
||||||
|
const paymentQrBankAccount = data.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
|
||||||
|
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
|
||||||
|
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
|
||||||
|
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || data.tenant.name
|
||||||
|
|
||||||
|
if ((data.tenant.extraModules || []).includes("agriculture")) {
|
||||||
|
document.rows.forEach((row: any) => {
|
||||||
|
if (row.agriculture?.dieselUsage) {
|
||||||
|
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
|
||||||
|
const rows = document.rows.map((row: any) => {
|
||||||
|
if (!["pagebreak", "title"].includes(row.mode)) {
|
||||||
|
if (row.agriculture?.description) row.descriptionText = row.agriculture.description
|
||||||
|
else if (row.description) row.descriptionText = row.description
|
||||||
|
else delete row.descriptionText
|
||||||
|
}
|
||||||
|
if (["pagebreak", "title", "text"].includes(row.mode)) return row
|
||||||
|
if (row.mode === "normal") row.text = data.products.find((item) => item.id === row.product)?.name || ""
|
||||||
|
if (row.mode === "service") row.text = data.services.find((item) => item.id === row.service)?.name || ""
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
rowAmount: renderCurrency(rowSum(row)),
|
||||||
|
quantity: String(row.quantity).replace(".", ","),
|
||||||
|
unit: data.units.find((item) => item.id === row.unit)?.short || "",
|
||||||
|
pos: String(row.pos),
|
||||||
|
price: renderCurrency(row.price),
|
||||||
|
discountText: `(Rabatt: ${row.discountPercent} %)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
lohnkosten: document.report?.totalHoursSellingPrice ? renderCurrency(document.report.totalHoursSellingPrice) : null,
|
||||||
|
anrede: contact?.salutation || customer.salutation,
|
||||||
|
titel: contact?.title || customer.title,
|
||||||
|
vorname: contact?.firstName || customer.firstname,
|
||||||
|
nachname: contact?.lastName || customer.lastname,
|
||||||
|
kundenname: customer.name,
|
||||||
|
zahlungsziel_in_tagen: document.paymentDays,
|
||||||
|
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||||
|
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
|
||||||
|
}
|
||||||
|
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
|
||||||
|
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
|
||||||
|
const recipientCountry = String(address.country ?? customer.infoData?.country ?? "").trim()
|
||||||
|
const contactTel = document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: document.type,
|
||||||
|
taxType: document.taxType,
|
||||||
|
adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`,
|
||||||
|
recipient: [
|
||||||
|
customer.name,
|
||||||
|
...(customer.nameAddition ? [customer.nameAddition] : []),
|
||||||
|
...(contact ? [`${contact.firstName} ${contact.lastName}`] : []),
|
||||||
|
address.street,
|
||||||
|
...(address.special ? [address.special] : []),
|
||||||
|
`${address.zip} ${address.city}`,
|
||||||
|
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
|
||||||
|
],
|
||||||
|
info: [
|
||||||
|
{ label: documentNumberTitle(document.type), content: document.documentNumber || "XXXX" },
|
||||||
|
{ label: "Kundennummer", content: formatNumberLikeValue(customer.customerNumber) },
|
||||||
|
{ label: "Belegdatum", content: formatDate(document.documentDate) },
|
||||||
|
...(document.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
|
||||||
|
label: document.deliveryDateType,
|
||||||
|
content: !["Lieferzeitraum", "Leistungszeitraum"].includes(document.deliveryDateType)
|
||||||
|
? formatDate(document.deliveryDate)
|
||||||
|
: `${formatDate(document.deliveryDate)} - ${formatDate(document.deliveryDateEnd)}`,
|
||||||
|
}] : []),
|
||||||
|
{ label: "Ansprechpartner", content: contactPerson.full_name },
|
||||||
|
...(contactTel ? [{ label: "Telefon", content: contactTel }] : []),
|
||||||
|
...(contactPerson.email ? [{ label: "E-Mail", content: contactPerson.email }] : []),
|
||||||
|
...(document.plant ? [{ label: "Objekt", content: data.plant?.name || "Objekt nicht gefunden" }] : []),
|
||||||
|
...(document.project ? [{ label: "Projekt", content: data.project?.name || "Projekt nicht gefunden" }] : []),
|
||||||
|
...(document.contract ? [{ label: "Vertrag", content: data.contract?.contractNumber || "Vertrag nicht gefunden" }] : []),
|
||||||
|
],
|
||||||
|
title: document.title,
|
||||||
|
description: document.description,
|
||||||
|
endText: documentTemplateHandlebars.compile(document.endText || "")(context),
|
||||||
|
startText: documentTemplateHandlebars.compile(document.startText || "")(context),
|
||||||
|
rows,
|
||||||
|
totalArray: [
|
||||||
|
{ label: "Nettobetrag", content: renderCurrency(totals.totalNet) },
|
||||||
|
...[19, 7, 0].flatMap((rate) => rows.find((row: any) => row.taxPercent === rate) && document.taxType !== "13b UStG" ? [{
|
||||||
|
label: `zzgl. ${rate}% USt auf ${renderCurrency((totals as any)[`totalNet${rate}`])}`,
|
||||||
|
content: renderCurrency((totals as any)[`total${rate}`]),
|
||||||
|
}] : []),
|
||||||
|
{ label: "Gesamtbetrag", content: renderCurrency(totals.totalGross) },
|
||||||
|
],
|
||||||
|
total: {
|
||||||
|
totalNet: renderCurrency(totals.totalNet),
|
||||||
|
total19: renderCurrency(totals.total19),
|
||||||
|
total0: renderCurrency(totals.total0),
|
||||||
|
totalGross: renderCurrency(totals.totalGross),
|
||||||
|
totalGrossAlreadyPaid: renderCurrency(totals.totalGrossAlreadyPaid),
|
||||||
|
totalSumToPay: renderCurrency(totals.totalSumToPay),
|
||||||
|
titleSums: formatSums(totals.titleSums),
|
||||||
|
titleSumsTransfer: formatSums(totals.titleSumsTransfer),
|
||||||
|
},
|
||||||
|
agriculture: document.agriculture,
|
||||||
|
usedAdvanceInvoices: data.advanceInvoices,
|
||||||
|
paymentQr: paymentQrIban && document.payment_type === "transfer" ? {
|
||||||
|
iban: paymentQrIban,
|
||||||
|
bic: paymentQrBic,
|
||||||
|
recipientName: paymentQrRecipient,
|
||||||
|
amount: totals.totalSumToPay,
|
||||||
|
remittance: document.documentNumber || document.title,
|
||||||
|
} : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const one = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
|
||||||
|
if (id === null || id === undefined) return null
|
||||||
|
const [row] = await server.db.select().from(table).where(and(eq(table.id, id as any), eq(table.tenant, tenantId))).limit(1)
|
||||||
|
return row || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOutgoingDocumentPdf(server: FastifyInstance, tenantId: number, document: any) {
|
||||||
|
if (!document.letterhead) throw Object.assign(new Error("Es ist kein Briefpapier ausgewählt"), { statusCode: 400 })
|
||||||
|
if (!document.customer) throw Object.assign(new Error("Es ist kein Kunde ausgewählt"), { statusCode: 400 })
|
||||||
|
const rows = document.rows || []
|
||||||
|
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
|
||||||
|
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
|
||||||
|
|
||||||
|
const [[tenant], letterhead, customer, contact, plant, project, contract, unitRows, productRows, serviceRows, bankAccountRows, advanceInvoices, [contactPerson]] = await Promise.all([
|
||||||
|
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
|
||||||
|
one(server, letterheads, document.letterhead, tenantId),
|
||||||
|
one(server, customers, document.customer, tenantId),
|
||||||
|
one(server, contacts, document.contact, tenantId),
|
||||||
|
one(server, plants, document.plant, tenantId),
|
||||||
|
one(server, projects, document.project, tenantId),
|
||||||
|
one(server, contracts, document.contract, tenantId),
|
||||||
|
server.db.select().from(units),
|
||||||
|
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
|
||||||
|
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
|
||||||
|
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||||
|
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
|
document.created_by
|
||||||
|
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, document.created_by), eq(authProfiles.tenant_id, tenantId))).limit(1)
|
||||||
|
: [null],
|
||||||
|
])
|
||||||
|
if (!letterhead?.path) throw Object.assign(new Error("Briefpapier nicht gefunden"), { statusCode: 400 })
|
||||||
|
if (!customer) throw Object.assign(new Error("Kunde nicht gefunden"), { statusCode: 400 })
|
||||||
|
|
||||||
|
const pdfData = buildOutgoingDocumentPdfData(document, {
|
||||||
|
tenant, customer, contact, contactPerson, plant, project, contract,
|
||||||
|
units: unitRows, products: productRows, services: serviceRows, bankAccounts: bankAccountRows,
|
||||||
|
advanceInvoices: advanceIds.map((id) => advanceInvoices.find((invoice: any) => invoice.id === id)).filter(Boolean),
|
||||||
|
})
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 297, rows: [], pageCount: 0 }
|
||||||
|
const pdf: any = await createInvoicePDF(server, "base64", pdfData, letterhead.path, layout)
|
||||||
|
return { pdf: Buffer.from(pdf.base64, "base64"), layout }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
|||||||
import { centralServicesClient } from "../modules/push-server.client"
|
import { centralServicesClient } from "../modules/push-server.client"
|
||||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||||
|
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -33,9 +34,7 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
export default async function bankingRoutes(server: FastifyInstance) {
|
export default async function bankingRoutes(server: FastifyInstance) {
|
||||||
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
|
||||||
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||||
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||||
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {citys, files} from "../../db/schema";
|
|||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
|
import { updateIncomingInvoiceCostCentres } from "../modules/incoming-invoice-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
@@ -334,6 +335,26 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const invoiceId = Number(id)
|
||||||
|
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
|
||||||
|
|
||||||
|
const invoice = await updateIncomingInvoiceCostCentres(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
invoiceId,
|
||||||
|
req.body as any,
|
||||||
|
)
|
||||||
|
return { invoice }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||||
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
@@ -348,8 +369,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
||||||
|
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
|
||||||
|
})
|
||||||
|
|
||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
server.get('/functions/services/prepareincominginvoices', async (req) => {
|
||||||
|
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { s3 } from "../utils/s3"
|
|||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
import { sanitizeFilename } from "../utils/filename"
|
import { sanitizeFilename } from "../utils/filename"
|
||||||
|
import multipart from "@fastify/multipart"
|
||||||
|
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
|
||||||
|
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
|
||||||
|
|
||||||
|
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
|
||||||
|
|
||||||
const SUPPORTED_PROTOCOL_VERSIONS = [
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
|
|||||||
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function mcpRoutes(server: FastifyInstance) {
|
type McpRouteOptions = {
|
||||||
|
/** Replaceable in tests to avoid object storage. */
|
||||||
|
storeUpload?: typeof storeMcpFileUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
|
||||||
|
const storeUpload = options.storeUpload || storeMcpFileUpload
|
||||||
server.get("/mcp/tokens", async (req, reply) => {
|
server.get("/mcp/tokens", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
requireMcpTokenManagementPermission(req)
|
requireMcpTokenManagementPermission(req)
|
||||||
@@ -221,6 +232,37 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
|
||||||
|
await server.register(async (uploads) => {
|
||||||
|
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
|
||||||
|
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
|
||||||
|
|
||||||
|
uploads.post("/mcp/files", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const context = await createMcpContext(server, req)
|
||||||
|
const query = parseUploadQuery(req.query as Record<string, unknown>)
|
||||||
|
let body: Buffer
|
||||||
|
let filename = query.filename
|
||||||
|
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
|
||||||
|
if (req.isMultipart()) {
|
||||||
|
const part = await req.file()
|
||||||
|
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
|
||||||
|
body = await part.toBuffer()
|
||||||
|
filename ||= part.filename
|
||||||
|
mimeType = part.mimetype
|
||||||
|
} else if (Buffer.isBuffer(req.body)) {
|
||||||
|
body = req.body
|
||||||
|
} else {
|
||||||
|
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
|
||||||
|
}
|
||||||
|
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
|
||||||
|
return await storeUpload(context, { ...query, filename, body, mimeType })
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
||||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
const requests = Array.isArray(body) ? body : [body]
|
const requests = Array.isArray(body) ? body : [body]
|
||||||
@@ -270,7 +312,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
description: tool.description,
|
description: tool.description,
|
||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
annotations: {
|
annotations: {
|
||||||
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function insertHistoryItem(
|
|||||||
created_by: string | null
|
created_by: string | null
|
||||||
entity: string
|
entity: string
|
||||||
entityId: string | number
|
entityId: string | number
|
||||||
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
|
action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
|
||||||
oldVal?: any
|
oldVal?: any
|
||||||
newVal?: any
|
newVal?: any
|
||||||
text?: string
|
text?: string
|
||||||
@@ -62,6 +62,7 @@ export async function insertHistoryItem(
|
|||||||
updated: `Eintrag in ${entityLabel} geändert`,
|
updated: `Eintrag in ${entityLabel} geändert`,
|
||||||
unchanged: `Eintrag in ${entityLabel} unverändert`,
|
unchanged: `Eintrag in ${entityLabel} unverändert`,
|
||||||
archived: `Eintrag in ${entityLabel} archiviert`,
|
archived: `Eintrag in ${entityLabel} archiviert`,
|
||||||
|
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
|
||||||
deleted: `Eintrag in ${entityLabel} gelöscht`
|
deleted: `Eintrag in ${entityLabel} gelöscht`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,17 @@ const getDuration = (time) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
/** Positions in mm from the top edge, recorded while rendering for layout checks (e.g. page breaks). */
|
||||||
|
export type InvoicePdfLayout = {
|
||||||
|
pageHeightMm: number
|
||||||
|
rows: Array<{ index: number, id?: string, pos?: string, mode: string, text: string, page: number, topMm: number, bottomMm: number }>
|
||||||
|
totals?: { page: number, topMm: number, bottomMm: number }
|
||||||
|
endText?: { page: number, topMm: number, bottomMm: number }
|
||||||
|
paymentQr?: { page: number, topMm: number, leftMm: number }
|
||||||
|
pageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string, layout?: InvoicePdfLayout) => {
|
||||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||||
|
|
||||||
@@ -153,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
- paymentQrLayout.sizeMm * mm
|
- paymentQrLayout.sizeMm * mm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
|
||||||
|
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
|
||||||
|
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
|
||||||
|
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
|
||||||
|
let paymentQrOnNewPage = false
|
||||||
|
|
||||||
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||||
if (!paymentQrCode) return 500
|
if (!paymentQrCode || paymentQrOnNewPage) return 500
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
240,
|
240,
|
||||||
@@ -496,6 +512,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoiceData.rows.forEach((row, index) => {
|
invoiceData.rows.forEach((row, index) => {
|
||||||
|
const layoutTop = rowHeight
|
||||||
|
|
||||||
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
|
|
||||||
@@ -881,10 +898,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(rowHeight)
|
console.log(rowHeight)
|
||||||
|
layout?.rows.push({
|
||||||
|
index, id: row.id, pos: row.pos, mode: row.mode, text: String(row.text || "").slice(0, 80),
|
||||||
|
// rowHeight is the baseline of the first text line; shift to the visible cap height.
|
||||||
|
page: pageCounter, topMm: row.mode === "pagebreak" ? 30 : layoutTop - 3, bottomMm: row.mode === "pagebreak" ? 30 : rowHeight - 3,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
let endTextDiff = 35
|
let endTextDiff = 35
|
||||||
|
const layoutContentEnd = rowHeight
|
||||||
|
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
|
||||||
|
let amountsBottomMm = rowHeight - 3
|
||||||
|
|
||||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
pages[pageCounter - 1].drawLine({
|
||||||
@@ -999,8 +1024,23 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
|
||||||
|
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
|
||||||
|
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
|
||||||
|
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
|
||||||
|
|
||||||
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
||||||
|
|
||||||
|
if (layout) {
|
||||||
|
const maxWidth = getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
|
||||||
|
const lines = String(invoiceData.endText || "").split("\n")
|
||||||
|
.reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0)
|
||||||
|
const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8
|
||||||
|
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
|
||||||
|
// drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm.
|
||||||
|
layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
||||||
...endTextPosition,
|
...endTextPosition,
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -1012,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (paymentQrCode) {
|
if (paymentQrCode) {
|
||||||
|
// Invoice-like documents decide this before placing the end text.
|
||||||
|
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
|
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
|
||||||
|
}
|
||||||
|
if (paymentQrOnNewPage) {
|
||||||
|
// Positions or totals reach the QR area; never draw the code over amounts.
|
||||||
|
const qrPage = pdfDoc.addPage()
|
||||||
|
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
|
||||||
|
pages.push(qrPage)
|
||||||
|
}
|
||||||
const lastPage = pages[pages.length - 1]
|
const lastPage = pages[pages.length - 1]
|
||||||
const qrSize = paymentQrLayout.sizeMm * mm
|
const qrSize = paymentQrLayout.sizeMm * mm
|
||||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||||
@@ -1030,6 +1080,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold,
|
font: fontBold,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (layout) layout.paymentQr = {
|
||||||
|
page: pages.length,
|
||||||
|
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
|
||||||
|
leftMm: qrX / mm,
|
||||||
|
}
|
||||||
|
|
||||||
lastPage.drawImage(paymentQrCode, {
|
lastPage.drawImage(paymentQrCode, {
|
||||||
x: qrX,
|
x: qrX,
|
||||||
y: qrY,
|
y: qrY,
|
||||||
@@ -1038,6 +1094,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (layout) {
|
||||||
|
layout.pageCount = pages.length
|
||||||
|
layout.pageHeightMm = page1.getHeight() / mm
|
||||||
|
}
|
||||||
|
|
||||||
return await pdfDoc.saveAsBase64()
|
return await pdfDoc.saveAsBase64()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
backend/src/utils/pdfPreview.ts
Normal file
30
backend/src/utils/pdfPreview.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { execFile } from "node:child_process"
|
||||||
|
import { promisify } from "node:util"
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
/** Renders selected PDF pages (1-based) to PNG with poppler's pdftoppm. */
|
||||||
|
export async function renderPdfPagesAsPng(pdf: Buffer, pages: number[], dpi: number) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-preview-"))
|
||||||
|
try {
|
||||||
|
const input = path.join(dir, "document.pdf")
|
||||||
|
await fs.writeFile(input, pdf)
|
||||||
|
const images: Array<{ page: number, png: Buffer }> = []
|
||||||
|
for (const page of pages) {
|
||||||
|
const prefix = path.join(dir, `page-${page}`)
|
||||||
|
try {
|
||||||
|
await execFileAsync("pdftoppm", ["-png", "-r", String(dpi), "-f", String(page), "-l", String(page), "-singlefile", input, prefix], { timeout: 30_000 })
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") throw Object.assign(new Error("Die Seitenvorschau benötigt pdftoppm (poppler-utils) auf dem Server."), { statusCode: 501 })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
images.push({ page, png: await fs.readFile(`${prefix}.png`) })
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/tests/bankBalances.test.ts
Normal file
24
backend/tests/bankBalances.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
|
||||||
|
|
||||||
|
const syncedAt = new Date("2026-10-20T08:00:00Z")
|
||||||
|
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
|
||||||
|
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
|
||||||
|
|
||||||
|
test("bank accounts report the synced balance and revert later statements for a past date", () => {
|
||||||
|
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
|
||||||
|
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
|
||||||
|
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
|
||||||
|
assert.equal(past.balance, 1200.15)
|
||||||
|
assert.equal(past.syncedBalance, 1000.1)
|
||||||
|
assert.equal(past.source, "bank_sync_reconstructed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cashbooks add their bookings to the opening balance", () => {
|
||||||
|
const result = calculateAccountBalance(cashbook, 12.345, null)
|
||||||
|
assert.equal(result.type, "cashbook")
|
||||||
|
assert.equal(result.balance, 62.35)
|
||||||
|
assert.equal(result.openingBalance, 50)
|
||||||
|
assert.equal(result.iban, null)
|
||||||
|
})
|
||||||
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import {
|
||||||
|
applyAccountCostCentreAssignments,
|
||||||
|
normalizeAccountCostCentreAssignments,
|
||||||
|
} from "../src/modules/incoming-invoice-cost-centres.service"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
|
||||||
|
|
||||||
|
test("changes only requested account cost centres", () => {
|
||||||
|
const accounts = [
|
||||||
|
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
|
||||||
|
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
|
||||||
|
{ index: 1, costcentre: CENTRE },
|
||||||
|
]), [
|
||||||
|
accounts[0],
|
||||||
|
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects assignments for unknown invoice positions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
|
||||||
|
/Position 4.*nicht gefunden/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates cost centre assignments", () => {
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
|
||||||
|
/mehrfach/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers a dedicated incoming invoice cost centre tool", () => {
|
||||||
|
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
|
||||||
|
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
|
||||||
|
})
|
||||||
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
|
||||||
|
|
||||||
|
function server(tenantLookups: { count: number }) {
|
||||||
|
let release: () => void = () => undefined
|
||||||
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => {
|
||||||
|
tenantLookups.count += 1
|
||||||
|
await gate
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
|
||||||
|
return { server: { db, log } as any, release: () => release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("start runs the preparation in the background and does not start a second run", async () => {
|
||||||
|
const lookups = { count: 0 }
|
||||||
|
const { server: srv, release } = server(lookups)
|
||||||
|
const service = prepareIncomingInvoices(srv)
|
||||||
|
|
||||||
|
const first = service.start(7)
|
||||||
|
const second = service.start(7)
|
||||||
|
assert.equal(first.running, true)
|
||||||
|
assert.equal(second.startedAt, first.startedAt)
|
||||||
|
assert.equal(service.status(8).running, false)
|
||||||
|
|
||||||
|
release()
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
|
||||||
|
const status = service.status(7)
|
||||||
|
assert.equal(lookups.count, 1)
|
||||||
|
assert.equal(status.running, false)
|
||||||
|
assert.ok(status.finishedAt)
|
||||||
|
assert.equal(status.result?.error, "Mandant nicht gefunden")
|
||||||
|
})
|
||||||
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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 { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
|
||||||
|
const tenantId = 7
|
||||||
|
const userId = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const uuid = "00000000-0000-4000-8000-000000000002"
|
||||||
|
const dialect = new PgDialect()
|
||||||
|
|
||||||
|
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
|
||||||
|
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
|
||||||
|
const state = structuredClone(initial)
|
||||||
|
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
||||||
|
const matches = (table: any, where: any, row: any) => {
|
||||||
|
const query = dialect.sqlToQuery(where)
|
||||||
|
const columns = getTableColumns(table) as Record<string, any>
|
||||||
|
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
||||||
|
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
|
||||||
|
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
|
||||||
|
}
|
||||||
|
const listed: string[] = []
|
||||||
|
const db: any = {
|
||||||
|
transaction: async (callback: any) => callback(db),
|
||||||
|
select: () => ({ from: (table: any) => {
|
||||||
|
let condition: any
|
||||||
|
const query: any = {
|
||||||
|
where: (where: any) => { condition = where; return query },
|
||||||
|
orderBy: () => query,
|
||||||
|
offset: () => query,
|
||||||
|
limit: () => query,
|
||||||
|
for: () => query,
|
||||||
|
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||||
|
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
|
||||||
|
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
|
||||||
|
}).then(resolve, reject),
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
} }),
|
||||||
|
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
|
||||||
|
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
|
||||||
|
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
|
||||||
|
}) }) }),
|
||||||
|
}
|
||||||
|
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
||||||
|
return { context, rows: rowsFor, listed }
|
||||||
|
}
|
||||||
|
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
|
||||||
|
|
||||||
|
for (const [key, entity] of Object.entries(archiveEntities)) {
|
||||||
|
test(`${key}: restore unarchives within the tenant and records history`, async () => {
|
||||||
|
const id = entity.uuid ? uuid : 1
|
||||||
|
const f = fixture({
|
||||||
|
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
|
||||||
|
// Restoring documents requires an internal (non-portal) profile.
|
||||||
|
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
|
||||||
|
})
|
||||||
|
const result = await call(f.context, "restore", { entity: key, id })
|
||||||
|
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
|
||||||
|
assert.equal(result.record.archived, undefined)
|
||||||
|
const [row] = f.rows(entity.table)
|
||||||
|
assert.equal(row.archived, false)
|
||||||
|
assert.equal(row.updatedBy ?? row.updated_by, userId)
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
|
||||||
|
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
|
||||||
|
assert.equal(f.rows(schema.customers)[0].archived, true)
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
|
||||||
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
|
||||||
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
|
||||||
|
f.context.isAdmin = false
|
||||||
|
f.context.permissions = ["organisation.tasks.read"]
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
|
||||||
|
f.context.permissions.push("organisation.tasks.write")
|
||||||
|
await call(f.context, "restore", { entity: "tasks", id: 2 })
|
||||||
|
assert.equal(f.rows(schema.tasks)[0].archived, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("list returns archived rows visibly and requires the read permission", async () => {
|
||||||
|
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
|
||||||
|
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
|
||||||
|
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
|
||||||
|
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
|
||||||
|
f.context.isAdmin = false
|
||||||
|
f.context.permissions = []
|
||||||
|
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("restore is advertised as a write tool", () => {
|
||||||
|
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
|
||||||
|
})
|
||||||
106
backend/tests/mcpFileUploads.test.ts
Normal file
106
backend/tests/mcpFileUploads.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||||
|
import mcpRoutes from "../src/routes/mcp"
|
||||||
|
import authPlugin from "../src/plugins/auth"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
|
||||||
|
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
|
||||||
|
import { secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const id = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
|
||||||
|
const bearer = "fedeo_mcp_test_upload_token"
|
||||||
|
const headers = { authorization: `Bearer ${bearer}` }
|
||||||
|
|
||||||
|
async function app(t: any) {
|
||||||
|
const uploads: any[] = []
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
if (sql.includes('from "m2m_api_keys"')) {
|
||||||
|
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
|
||||||
|
return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] }
|
||||||
|
}
|
||||||
|
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
||||||
|
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
|
||||||
|
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
||||||
|
return { rows: [] }
|
||||||
|
})
|
||||||
|
const server = Fastify()
|
||||||
|
t.after(() => server.close())
|
||||||
|
server.decorate("db", db as any)
|
||||||
|
await server.register(authPlugin)
|
||||||
|
await server.register(mcpRoutes, {
|
||||||
|
prefix: "/api",
|
||||||
|
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
|
||||||
|
})
|
||||||
|
return { server, uploads }
|
||||||
|
}
|
||||||
|
|
||||||
|
const multipartBody = (filename: string, content: Buffer, field = "file") => {
|
||||||
|
const boundary = "----fedeo-test"
|
||||||
|
const payload = Buffer.concat([
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
|
||||||
|
content,
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
])
|
||||||
|
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
|
||||||
|
const { server, uploads } = await app(t)
|
||||||
|
const multipart = multipartBody("rechnung.pdf", pdf)
|
||||||
|
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
|
||||||
|
assert.equal(first.statusCode, 200, first.body)
|
||||||
|
assert.deepEqual(first.json(), { stored: true })
|
||||||
|
assert.equal(uploads[0].tenantId, 7)
|
||||||
|
assert.equal(uploads[0].target, "incoming_invoice")
|
||||||
|
assert.equal(uploads[0].filename, "rechnung.pdf")
|
||||||
|
assert.equal(uploads[0].invoiceId, 5)
|
||||||
|
assert.equal(uploads[0].prepare, false)
|
||||||
|
assert.deepEqual(uploads[0].body, pdf)
|
||||||
|
|
||||||
|
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
|
||||||
|
assert.equal(raw.statusCode, 200, raw.body)
|
||||||
|
assert.equal(uploads[1].target, "inbox")
|
||||||
|
assert.equal(uploads[1].mimeType, "application/pdf")
|
||||||
|
assert.deepEqual(uploads[1].body, pdf)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
|
||||||
|
const { server, uploads } = await app(t)
|
||||||
|
const raw = { "content-type": "application/pdf" }
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
||||||
|
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
|
||||||
|
assert.equal(uploads.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("query parsing keeps invoice options to the incoming invoice target", () => {
|
||||||
|
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
|
||||||
|
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
|
||||||
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
|
||||||
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stored uploads require the permission of the chosen target", async () => {
|
||||||
|
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("upload_url tool returns the absolute endpoint with examples", async (t) => {
|
||||||
|
const apiBase = secrets.API_BASE_URL
|
||||||
|
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
|
||||||
|
t.after(() => { secrets.API_BASE_URL = apiBase })
|
||||||
|
const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
|
||||||
|
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
|
||||||
|
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
|
||||||
|
})
|
||||||
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const fileA = "00000000-0000-4000-8000-00000000000a"
|
||||||
|
const fileB = "00000000-0000-4000-8000-00000000000b"
|
||||||
|
const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")!
|
||||||
|
|
||||||
|
function context(result: any, invoices: any[] = []) {
|
||||||
|
const calls: any[] = []
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({ from: () => ({ where: async () => invoices }) }),
|
||||||
|
}
|
||||||
|
const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } }
|
||||||
|
return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prepare runs for the active tenant and returns created drafts with validation", async () => {
|
||||||
|
const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false }
|
||||||
|
const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice])
|
||||||
|
const result: any = await tool.handler(ctx, {})
|
||||||
|
assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }])
|
||||||
|
assert.equal(result.created[0].invoice.id, 11)
|
||||||
|
assert.ok(result.created[0].validation)
|
||||||
|
assert.deepEqual(result.skipped, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepare limits to given files and reports files it could not handle", async () => {
|
||||||
|
const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] })
|
||||||
|
const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] })
|
||||||
|
assert.deepEqual(calls[0].options.fileIds, [fileA, fileB])
|
||||||
|
assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepare validates input and surfaces tenant configuration errors", async () => {
|
||||||
|
const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" })
|
||||||
|
await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/)
|
||||||
|
await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/)
|
||||||
|
await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/)
|
||||||
|
assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"])
|
||||||
|
})
|
||||||
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
import { PDFDocument } from "pdf-lib"
|
||||||
|
import { initS3, s3 } from "../src/utils/s3"
|
||||||
|
import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf"
|
||||||
|
import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service"
|
||||||
|
import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout"
|
||||||
|
import { renderPdfPagesAsPng } from "../src/utils/pdfPreview"
|
||||||
|
import { asToolResult, withImages } from "../src/mcp/result"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })()
|
||||||
|
|
||||||
|
const document = (rows: any[]) => ({
|
||||||
|
id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28",
|
||||||
|
deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" },
|
||||||
|
startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer",
|
||||||
|
usedAdvanceInvoices: [], report: {}, rows,
|
||||||
|
})
|
||||||
|
const context = {
|
||||||
|
tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] },
|
||||||
|
customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" },
|
||||||
|
contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" },
|
||||||
|
units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [],
|
||||||
|
}
|
||||||
|
const positions = (count: number) => Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`,
|
||||||
|
description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => {
|
||||||
|
const rows = [
|
||||||
|
{ pos: "1", mode: "title", text: "Bau", titleLevel: 1 },
|
||||||
|
{ pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true },
|
||||||
|
{ mode: "pagebreak" },
|
||||||
|
{ pos: "2", mode: "title", text: "Elektro", titleLevel: 1 },
|
||||||
|
{ pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 },
|
||||||
|
]
|
||||||
|
const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, [])
|
||||||
|
assert.equal(totals.totalNet, 110)
|
||||||
|
assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 })
|
||||||
|
assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 })
|
||||||
|
const data: any = buildOutgoingDocumentPdfData(document(rows), context)
|
||||||
|
assert.equal(data.startText, "Hallo Ada,")
|
||||||
|
assert.equal(data.endText, "Zahlbar in 14 Tagen.")
|
||||||
|
assert.equal(data.rows[5].text, "Kabel")
|
||||||
|
assert.equal(data.rows[5].unit, "Stk")
|
||||||
|
assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €")
|
||||||
|
assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"])
|
||||||
|
assert.equal(data.info[0].label, "Rechnungsnummer")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => {
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [
|
||||||
|
{ index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 },
|
||||||
|
{ index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 },
|
||||||
|
{ index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 },
|
||||||
|
], totals: { page: 1, topMm: 270, bottomMm: 300 } }
|
||||||
|
const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262)
|
||||||
|
assert.equal(warnings.length, 1)
|
||||||
|
assert.equal(warnings[0].insertPagebreakBeforeIndex, 1)
|
||||||
|
assert.equal(summary.pages[0].freeMm, -38)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
|
||||||
|
const letterhead = await PDFDocument.create()
|
||||||
|
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
|
||||||
|
const background = Buffer.from(await letterhead.save())
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
|
||||||
|
|
||||||
|
const rows: any[] = positions(30)
|
||||||
|
rows.splice(12, 0, { mode: "pagebreak" })
|
||||||
|
const data = buildOutgoingDocumentPdfData(document(rows), context)
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||||
|
const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout)
|
||||||
|
// Rows overflow page 2, so the payment QR code gets an extra third page.
|
||||||
|
assert.equal(layout.pageCount, 3)
|
||||||
|
assert.equal(layout.paymentQr!.page, 3)
|
||||||
|
assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2)
|
||||||
|
assert.ok(layout.endText && layout.totals)
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(layout)
|
||||||
|
assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings))
|
||||||
|
|
||||||
|
const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50)
|
||||||
|
assert.deepEqual(images.map((image) => image.page), [1, 2])
|
||||||
|
assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
||||||
|
const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" }))))
|
||||||
|
assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"])
|
||||||
|
assert.deepEqual(result.structuredContent, { pageCount: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preview tool is read-only for outgoing documents", () => {
|
||||||
|
assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
|
||||||
|
const letterhead = await PDFDocument.create()
|
||||||
|
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
|
||||||
|
const background = Buffer.from(await letterhead.save())
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
|
||||||
|
const render = async (count: number) => {
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||||
|
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
const short = await render(3)
|
||||||
|
assert.equal(short.pageCount, 1)
|
||||||
|
assert.equal(short.paymentQr!.page, 1)
|
||||||
|
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
|
||||||
|
|
||||||
|
const long = await render(6)
|
||||||
|
assert.equal(long.pageCount, 2)
|
||||||
|
assert.equal(long.paymentQr!.page, 2)
|
||||||
|
assert.ok(long.rows.every((row) => row.page === 1))
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(long)
|
||||||
|
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
|
||||||
|
assert.ok(warning, JSON.stringify(analysis.warnings))
|
||||||
|
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
|
||||||
|
})
|
||||||
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
@@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
|
|||||||
return values
|
return values
|
||||||
}) }
|
}) }
|
||||||
}, { schema })
|
}, { schema })
|
||||||
const context = { server: { db }, tenantId: 7, userId: "test" } as any
|
const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any
|
||||||
|
const properties = tool.inputSchema.properties as any
|
||||||
const ids: unknown[] = []
|
const ids: unknown[] = []
|
||||||
let offset = 0
|
let offset = 0
|
||||||
for (let page = 0; page < 3; page++) {
|
for (let page = 0; page < 3; page++) {
|
||||||
const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset })
|
const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset })
|
||||||
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||||
assert.equal(result.pagination.hasMore, page < 2)
|
assert.equal(result.pagination.hasMore, page < 2)
|
||||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||||
|
|||||||
@@ -730,6 +730,12 @@ const setupPage = async () => {
|
|||||||
await router.replace('/createDocument')
|
await router.replace('/createDocument')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.query.regeneratePdf && route.params.id) {
|
||||||
|
await regenerateMissingPdf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1980,8 +1986,24 @@ const closeDocument = async () => {
|
|||||||
|
|
||||||
await saveDocument("Gebucht")
|
await saveDocument("Gebucht")
|
||||||
|
|
||||||
|
await uploadGeneratedDocument()
|
||||||
|
|
||||||
|
guard.value = false
|
||||||
|
|
||||||
|
await router.push(`/createDocument/show/${itemInfo.value.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
|
||||||
|
const uploadGeneratedDocument = async () => {
|
||||||
await generateDocument()
|
await generateDocument()
|
||||||
|
|
||||||
|
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
|
||||||
|
|
||||||
let fileData = {}
|
let fileData = {}
|
||||||
|
|
||||||
fileData.project = itemInfo.value.project
|
fileData.project = itemInfo.value.project
|
||||||
@@ -2012,15 +2034,39 @@ const closeDocument = async () => {
|
|||||||
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
|
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
|
||||||
|
|
||||||
await useFiles().uploadFiles(fileData, [file])
|
await useFiles().uploadFiles(fileData, [file])
|
||||||
|
|
||||||
guard.value = false
|
|
||||||
|
|
||||||
await router.push(`/createDocument/show/${itemInfo.value.id}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regenerateMissingPdf = async () => {
|
||||||
|
guard.value = false
|
||||||
|
const showPath = `/createDocument/show/${route.params.id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await useEntities("createddocuments").selectSingle(route.params.id, "*,files(*)")
|
||||||
|
|
||||||
|
if (document?.state === "Entwurf" || document?.type === "serialInvoices") {
|
||||||
|
toast.add({ title: "PDF kann nur für fertiggestellte Belege nacherzeugt werden", color: "error" })
|
||||||
|
} else if ((document?.files || []).length > 0) {
|
||||||
|
toast.add({ title: "Am Beleg ist bereits ein Dokument angehängt", color: "warning" })
|
||||||
|
} else {
|
||||||
|
// Vertrags- und Mandats-Watcher überschreiben beim Laden die Zahlungsdaten, für die PDF gilt der gespeicherte Stand
|
||||||
|
const restoreStoredPaymentData = () => {
|
||||||
|
itemInfo.value.payment_type = document.payment_type
|
||||||
|
itemInfo.value.outgoingsepamandate = normalizeEntityId(document.outgoingsepamandate)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
restoreStoredPaymentData()
|
||||||
|
await nextTick()
|
||||||
|
restoreStoredPaymentData()
|
||||||
|
|
||||||
|
await uploadGeneratedDocument()
|
||||||
|
toast.add({ title: "PDF nacherzeugt", color: "success" })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.add({ title: "PDF konnte nicht nacherzeugt werden", description: error?.data?.error || error?.message, color: "error" })
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace(showPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTextTemplateByType = (type, pos) => {
|
const getTextTemplateByType = (type, pos) => {
|
||||||
|
|||||||
@@ -110,9 +110,17 @@ const setupPage = async () => {
|
|||||||
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
|
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (itemInfo.value.files?.length > 0) {
|
||||||
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
|
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRegeneratePdf = computed(() => itemInfo.value.id
|
||||||
|
&& itemInfo.value.state !== 'Entwurf'
|
||||||
|
&& itemInfo.value.type !== 'serialInvoices'
|
||||||
|
&& Array.isArray(itemInfo.value.files)
|
||||||
|
&& itemInfo.value.files.length === 0)
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
|
|
||||||
@@ -282,6 +290,12 @@ const togglePortalRelease = async () => {
|
|||||||
:loading="costCentresLoading"
|
:loading="costCentresLoading"
|
||||||
@click="openCostCentres"
|
@click="openCostCentres"
|
||||||
>Kostenstellen bearbeiten</UButton>
|
>Kostenstellen bearbeiten</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canRegeneratePdf"
|
||||||
|
icon="i-heroicons-document-arrow-down"
|
||||||
|
variant="outline"
|
||||||
|
@click="router.push(`/createDocument/edit/${itemInfo.id}?regeneratePdf=1`)"
|
||||||
|
>PDF nacherzeugen</UButton>
|
||||||
<!-- <UButton
|
<!-- <UButton
|
||||||
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
|
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -200,6 +200,47 @@ const getAccountLabel = (item) => {
|
|||||||
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
|
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
|
||||||
}
|
}
|
||||||
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
|
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
|
||||||
|
|
||||||
|
const canEditCostCentres = computed(() => mode.value === "show" && Boolean(itemInfo.value.id) && !itemInfo.value.archived)
|
||||||
|
const costCentresOpen = ref(false)
|
||||||
|
const costCentresSaving = ref(false)
|
||||||
|
const costCentreDraft = ref([])
|
||||||
|
const costCentreOptionLabel = (id) => {
|
||||||
|
const centre = costcentres.value.find((item) => item.id === id)
|
||||||
|
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCostCentres = () => {
|
||||||
|
costCentreDraft.value = (itemInfo.value.accounts || []).map((account, index) => ({
|
||||||
|
index,
|
||||||
|
label: `${index + 1}. ${account.description || getAccountLabel(account)}`,
|
||||||
|
costcentre: account.costCentre || null,
|
||||||
|
}))
|
||||||
|
costCentresOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCostCentres = async () => {
|
||||||
|
if (costCentresSaving.value) return
|
||||||
|
costCentresSaving.value = true
|
||||||
|
try {
|
||||||
|
const accountCostCentres = costCentreDraft.value
|
||||||
|
.filter((row) => row.costcentre !== (itemInfo.value.accounts[row.index]?.costCentre || null))
|
||||||
|
.map(({ index, costcentre }) => ({ index, costcentre }))
|
||||||
|
if (accountCostCentres.length) {
|
||||||
|
const { invoice } = await useNuxtApp().$api(`/api/functions/incoming-invoices/${itemInfo.value.id}/cost-centres`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: { accountCostCentres },
|
||||||
|
})
|
||||||
|
itemInfo.value.accounts = normalizeIncomingInvoiceAccounts(invoice.accounts || [], invoice.date)
|
||||||
|
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
|
||||||
|
}
|
||||||
|
costCentresOpen.value = false
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
|
||||||
|
} finally {
|
||||||
|
costCentresSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
|
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
|
||||||
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
|
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
|
||||||
|
|
||||||
@@ -362,6 +403,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canEditCostCentres"
|
||||||
|
icon="i-heroicons-tag"
|
||||||
|
variant="outline"
|
||||||
|
@click="openCostCentres"
|
||||||
|
>
|
||||||
|
Kostenstellen bearbeiten
|
||||||
|
</UButton>
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
v-if="canArchiveIncomingInvoice"
|
v-if="canArchiveIncomingInvoice"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -1060,6 +1109,27 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
|
||||||
|
<template #body>
|
||||||
|
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
|
||||||
|
<InputGroup>
|
||||||
|
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
|
||||||
|
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
|
||||||
|
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
|
||||||
|
</USelectMenu>
|
||||||
|
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
|
||||||
|
</InputGroup>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
|
||||||
|
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -64,35 +64,89 @@ const setupPage = async () => {
|
|||||||
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Funktion zum Vorbereiten der Belege
|
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
|
||||||
const prepareInvoices = async () => {
|
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
|
||||||
isPreparing.value = true
|
let preparationPollTimer = null
|
||||||
try {
|
|
||||||
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
|
|
||||||
|
|
||||||
toast.add({
|
const showPreparationResult = (status) => {
|
||||||
title: 'Erfolg',
|
const result = status?.result
|
||||||
description: 'Eingangsbelege wurden vorbereitet.',
|
if (result?.error && !result.created?.length) {
|
||||||
icon: 'i-heroicons-check-circle',
|
|
||||||
color: 'green'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Liste neu laden
|
|
||||||
await setupPage()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
|
description: result.error,
|
||||||
icon: 'i-heroicons-exclamation-circle',
|
icon: 'i-heroicons-exclamation-circle',
|
||||||
color: 'red'
|
color: 'red'
|
||||||
})
|
})
|
||||||
} finally {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = result?.created?.length || 0
|
||||||
|
const skipped = result?.skipped?.length || 0
|
||||||
|
toast.add({
|
||||||
|
title: 'Belegvorbereitung abgeschlossen',
|
||||||
|
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
color: 'green'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollPreparation = (notify) => {
|
||||||
|
clearTimeout(preparationPollTimer)
|
||||||
|
preparationPollTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const status = await $api(preparationEndpoint)
|
||||||
|
if (status?.running) {
|
||||||
|
pollPreparation(notify)
|
||||||
|
return
|
||||||
|
}
|
||||||
isPreparing.value = false
|
isPreparing.value = false
|
||||||
|
if (notify) showPreparationResult(status)
|
||||||
|
await setupPage()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
pollPreparation(notify)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareInvoices = async () => {
|
||||||
|
isPreparing.value = true
|
||||||
|
try {
|
||||||
|
await $api(preparationEndpoint, { method: 'POST' })
|
||||||
|
toast.add({
|
||||||
|
title: 'Belegvorbereitung gestartet',
|
||||||
|
description: 'Die Belege werden im Hintergrund vorbereitet.',
|
||||||
|
icon: 'i-heroicons-sparkles'
|
||||||
|
})
|
||||||
|
pollPreparation(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
isPreparing.value = false
|
||||||
|
toast.add({
|
||||||
|
title: 'Fehler',
|
||||||
|
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
|
||||||
|
icon: 'i-heroicons-exclamation-circle',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumePreparationStatus = async () => {
|
||||||
|
try {
|
||||||
|
const status = await $api(preparationEndpoint)
|
||||||
|
if (status?.running) {
|
||||||
|
isPreparing.value = true
|
||||||
|
pollPreparation(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
|
resumePreparationStatus()
|
||||||
|
|
||||||
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
||||||
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user