Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b617c7910 | |||
| 9e003125c2 | |||
| 76e82eae4f | |||
| f814689ddd | |||
| 755f1b7e0d | |||
| c6fd53307c | |||
| 0777315d1d |
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"`,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
|
|||||||
@@ -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,14 @@ 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 { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
@@ -309,6 +312,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,
|
||||||
@@ -1011,7 +1106,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 +1142,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 +1178,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 +1311,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 +1430,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)
|
||||||
|
|||||||
@@ -23,6 +23,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
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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"])
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateMissingPdf = async () => {
|
||||||
guard.value = false
|
guard.value = false
|
||||||
|
const showPath = `/createDocument/show/${route.params.id}`
|
||||||
|
|
||||||
await router.push(`/createDocument/show/${itemInfo.value.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,10 +110,18 @@ 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()
|
||||||
|
|
||||||
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
|
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
|
||||||
@@ -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