29 Commits
dev ... v3.15.0

Author SHA1 Message Date
9676a270da Merge pull request 'dev' (#251) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #251
2026-10-25 13:17:14 +00:00
f3b4fac4c0 Merge pull request 'dev' (#247) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Reviewed-on: #247
2026-10-15 16:47:32 +00:00
a8c6c2625c Merge pull request 'dev' (#242) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m31s
Reviewed-on: #242
2026-10-13 16:18:53 +00:00
a2a1151ccb Merge pull request 'dev' (#241) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m29s
Reviewed-on: #241
2026-10-07 17:11:45 +00:00
daaed01d02 Merge pull request 'dev' (#240) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #240
2026-10-02 16:30:02 +00:00
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
35 changed files with 225 additions and 2290 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern. about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] ' title: '[BUG] '
labels: bug labels: Problem
assignees: '' assignees: ''
--- ---
**Beschreibung** **Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion** **Reproduktion**
Schritte, um den Fehler zu reproduzieren:
Entweder:
1. Gehe zu '...'
2. Klicke auf '...'
3. Scrolle runter zu '...'
4. Siehe Fehler
Oder Link zur Seite
**Erwartetes Verhalten**
Eine klare Beschreibung dessen, was du erwartet hast.
**Screenshots** **Screenshots**
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.** **Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
**Umgebung:**
- Betriebssystem: [z.B. Windows, macOS, Linux]
- Browser / Version (falls relevant): [z.B. Chrome 120]
- Projekt-Version: [z.B. v1.0.2]
**Zusätzlicher Kontext**
Füge hier alle anderen Informationen zum Problem hinzu.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor. about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] ' title: '[FEATURE] '
labels: enhancement labels: Funktionswunsch
assignees: '' assignees: ''
--- ---
**Ist dein Feature-Wunsch mit einem Problem verbunden?** **Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag** **Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen** **Alternativen**
Hast du über alternative Lösungen oder Workarounds nachgedacht?
**Zusätzlicher Kontext**
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.

View File

@@ -17,8 +17,7 @@ export const outgoingRowsSchema = {
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." }, pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" }, mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
text: { type: "string" }, text: { type: "string" },
description: { type: "string", description: "Positionsbeschreibung. Ohne Angabe wird sie bei mode=normal/service aus Artikel bzw. Leistung übernommen, ebenso fehlender Name, Einheit, Preis und USt-Satz." }, descriptionText: { type: "string" },
descriptionText: { type: "string", description: "Veraltet; wird als description übernommen, wenn description fehlt." },
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." }, product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." }, service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
quantity: { type: "number", default: 1 }, quantity: { type: "number", default: 1 },

View File

@@ -1,93 +0,0 @@
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"`,
],
}
},
}

View File

@@ -1,95 +0,0 @@
import { InvoicePdfLayout } from "../utils/pdf"
/** Typical letterheads reserve the bottom ~35 mm of A4 for their footer. */
export const DEFAULT_CONTENT_BOTTOM_MM = 262
type Warning = { type: string, page: number, message: string, insertPagebreakBeforeIndex?: number }
const round = (value: number) => Math.round(value * 10) / 10
/**
* Summarises where rows land and suggests page breaks. FEDEO never breaks pages automatically,
* so content below the usable area runs into the letterhead footer or off the page.
*/
export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxContentBottomMm = DEFAULT_CONTENT_BOTTOM_MM) {
const warnings: Warning[] = []
const blocks = [
...layout.rows.filter((row) => row.mode !== "pagebreak").map((row) => ({ page: row.page, bottomMm: row.bottomMm })),
...(layout.totals ? [layout.totals] : []),
...(layout.endText ? [layout.endText] : []),
]
const pages = Array.from({ length: layout.pageCount }, (_, offset) => {
const page = offset + 1
const rows = layout.rows.filter((row) => row.page === page && row.mode !== "pagebreak")
const contentBottomMm = Math.max(0, ...blocks.filter((block) => block.page === page).map((block) => block.bottomMm))
return {
page,
rows: rows.map((row) => ({ index: row.index, pos: row.pos, mode: row.mode, text: row.text, topMm: round(row.topMm), bottomMm: round(row.bottomMm) })),
contentBottomMm: round(contentBottomMm),
freeMm: round(maxContentBottomMm - contentBottomMm),
}
})
for (const page of pages) {
const overflowing = page.rows.find((row) => row.bottomMm > maxContentBottomMm)
if (overflowing) {
// Keep headings with their first position instead of leaving them at the page end.
let before = page.rows.indexOf(overflowing)
while (before > 0 && page.rows[before - 1].mode === "title") before -= 1
const target = page.rows[before]
warnings.push({
type: "overflow",
page: page.page,
message: `Position ${overflowing.pos || overflowing.index + 1} reicht bis ${overflowing.bottomMm} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
...(target.index > 0 ? { insertPagebreakBeforeIndex: target.index } : {}),
})
}
const last = page.rows[page.rows.length - 1]
if (last?.mode === "title" && page.page < layout.pageCount) {
warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index })
}
if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) {
warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." })
}
}
// The renderer moves the payment QR code to an extra page when the amounts reach its spot.
const qr = layout.paymentQr
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
if (qr && qrAlone) {
const previous = pages[qr.page - 2]
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
let before = reaching ? previous.rows.indexOf(reaching) : -1
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
warnings.push({
type: "paymentQrOwnPage",
page: qr.page,
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length,
})
}
for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) {
if (!block || block.bottomMm <= maxContentBottomMm || pages[block.page - 1]?.rows.some((row) => row.bottomMm > maxContentBottomMm)) continue
warnings.push({
type: "overflow",
page: block.page,
message: `${name} reicht bis ${round(block.bottomMm)} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
insertPagebreakBeforeIndex: layout.rows.length,
})
}
return {
layout: {
pageHeightMm: round(layout.pageHeightMm),
maxContentBottomMm,
pages,
totals: layout.totals && { page: layout.totals.page, topMm: round(layout.totals.topMm), bottomMm: round(layout.totals.bottomMm) },
endText: layout.endText && { page: layout.endText.page, topMm: round(layout.endText.topMm), bottomMm: round(layout.endText.bottomMm) },
paymentQr: layout.paymentQr && { page: layout.paymentQr.page, topMm: round(layout.paymentQr.topMm), leftMm: round(layout.paymentQr.leftMm) },
},
warnings,
hint: "Seitenumbruch setzen: an der Stelle insertPagebreakBeforeIndex ein Objekt {\"mode\":\"pagebreak\"} in rows einfügen (Index 0-basiert; rows.length = vor Summenblock). Zuerst mit rows in dieser Vorschau prüfen, dann mit accounting.outgoing_documents.update speichern. Die Bilder sind maßgeblich; die mm-Werte sind Schätzungen des Renderers.",
}
}

View File

@@ -1,62 +0,0 @@
import { and, eq, inArray } from "drizzle-orm"
import { products, services } from "../../db/schema"
import { McpContext } from "./types"
const isMissing = (value: unknown) => value === undefined || value === null || value === ""
const ids = (rows: Record<string, any>[], mode: string, key: string) =>
[...new Set(rows.filter((row) => row.mode === mode && Number.isSafeInteger(Number(row[key])) && Number(row[key]) > 0).map((row) => Number(row[key])))]
const parseRow = (row: unknown) => {
if (typeof row !== "string") return row
try { return JSON.parse(row) } catch { return row }
}
/**
* Mirrors the document editor: selecting a product or service fills name, description, unit,
* price, tax rate and (for services) cost centre. Values passed by the caller always win.
*/
export async function applyOutgoingRowCatalogDefaults(context: Pick<McpContext, "server" | "tenantId">, rows: unknown) {
if (!Array.isArray(rows)) return rows
const parsed = rows.map(parseRow)
const objects = parsed.filter((row): row is Record<string, any> => !!row && typeof row === "object" && !Array.isArray(row))
const productIds = ids(objects, "normal", "product")
const serviceIds = ids(objects, "service", "service")
const [productRows, serviceRows] = await Promise.all([
productIds.length ? context.server.db.select().from(products)
.where(and(eq(products.tenant, context.tenantId), eq(products.archived, false), inArray(products.id, productIds))) : [],
serviceIds.length ? context.server.db.select().from(services)
.where(and(eq(services.tenant, context.tenantId), eq(services.archived, false), inArray(services.id, serviceIds))) : [],
])
const productMap = new Map<number, any>(productRows.map((row: any) => [row.id, row] as const))
const serviceMap = new Map<number, any>(serviceRows.map((row: any) => [row.id, row] as const))
return parsed.map((row, index) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return row
const result = { ...(row as Record<string, any>) }
let defaults: Record<string, unknown> | null = null
if (result.mode === "normal" && !isMissing(result.product)) {
const product = productMap.get(Number(result.product))
if (!product) throw new Error(`Position ${index + 1}: Artikel ${result.product} nicht gefunden oder archiviert`)
defaults = { text: product.name, description: product.description, unit: product.unit, price: product.selling_price, taxPercent: product.tax_percentage }
} else if (result.mode === "service" && !isMissing(result.service)) {
const service = serviceMap.get(Number(result.service))
if (!service) throw new Error(`Position ${index + 1}: Leistung ${result.service} nicht gefunden oder archiviert`)
defaults = {
text: service.name, description: service.description, unit: service.unit,
price: service.sellingPriceComposed?.total || service.sellingPrice, taxPercent: service.taxPercentage, costCentre: service.costcentre,
}
}
// The editor stores the position text in description; descriptionText is only derived for the PDF.
if (isMissing(result.description) && !isMissing(result.descriptionText)) result.description = result.descriptionText
if (!defaults) return result
for (const key of ["text", "description", "unit", "costCentre"]) {
if (isMissing(result[key]) && !isMissing(defaults[key])) result[key] = defaults[key]
}
if (isMissing(result.price) && isMissing(result.inputPrice) && !isMissing(defaults.price)) {
result.inputPrice = Number(defaults.price)
result.price = Number(defaults.price)
}
if (isMissing(result.taxPercent) && !isMissing(defaults.taxPercent)) result.taxPercent = Number(defaults.taxPercent)
return result
})
}

View File

@@ -3,8 +3,6 @@ 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,
@@ -12,8 +10,6 @@ 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]))

View File

@@ -2,12 +2,6 @@ import { McpToolResult } from "./types"
const OMIT_ARCHIVED = Symbol("omit-archived") const OMIT_ARCHIVED = Symbol("omit-archived")
/** Attach images to a tool payload; they become MCP image content next to the JSON text. */
export const MCP_IMAGES = Symbol("mcp-images")
export type McpImage = { data: string, mimeType: string }
export const withImages = <T extends Record<string, unknown>>(payload: T, images: McpImage[]) =>
Object.assign(payload, { [MCP_IMAGES]: images })
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED { function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
@@ -38,15 +32,12 @@ export function asToolResult(payload: unknown): McpToolResult {
? resultPayload as Record<string, unknown> ? resultPayload as Record<string, unknown>
: { result: resultPayload } : { result: resultPayload }
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: JSON.stringify(resultPayload, null, 2), text: JSON.stringify(resultPayload, null, 2),
}, },
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
], ],
structuredContent, structuredContent,
} }

View File

@@ -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, inArray, or } from "drizzle-orm" import { and, desc, eq, ilike, 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,19 +22,11 @@ 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 { McpContext, McpTool } from "../types" import { McpTool } from "../types"
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus" import { outgoingStatusTools } from "./outgoingStatus"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -317,98 +309,6 @@ 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,
@@ -655,7 +555,6 @@ export const accountingTools: McpTool[] = [
}, },
async handler(context, args) { async handler(context, args) {
assertNoManualDocumentNumber(args) assertNoManualDocumentNumber(args)
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true) const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
payload.state = "Entwurf" payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type)) applyOutgoingDocumentTaxType(payload, args, String(payload.type))
@@ -724,7 +623,6 @@ export const accountingTools: McpTool[] = [
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden") if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
payload.state = "Entwurf" payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows) applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
@@ -804,69 +702,6 @@ export const accountingTools: McpTool[] = [
return { document } return { document }
}, },
}, },
{
name: "accounting.outgoing_documents.preview",
title: "Ausgangsbeleg-Vorschau",
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
},
additionalProperties: false,
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
}
const [existing] = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
const document: Record<string, any> = { ...existing }
if (args.rows !== undefined) {
const payload: Record<string, unknown> = {
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
}
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
document.rows = payload.rows
}
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
return withImages({
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
previewOnly: args.rows !== undefined,
pageCount: layout.pageCount,
imagePages: images.map((image) => image.page),
...analysis,
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
},
},
{ {
name: "accounting.outgoing_documents.finalize", name: "accounting.outgoing_documents.finalize",
title: "Ausgangsbeleg finalisieren", title: "Ausgangsbeleg finalisieren",
@@ -906,7 +741,6 @@ export const accountingTools: McpTool[] = [
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert") if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type) const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
@@ -1177,7 +1011,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 dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) 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.",
requiredPermissions: ["accounting.incoming_invoices.write"], requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: { inputSchema: {
type: "object", type: "object",
@@ -1213,14 +1047,83 @@ 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")
return uploadIncomingInvoiceFile(context, { const buffer = base64BufferArg(args, "contentBase64")
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,
buffer: base64BufferArg(args, "contentBase64"), content: buffer,
mimeType: stringArg(args, "mimeType") || "application/octet-stream", contentType: mimeType,
invoiceId: numberArg(args, "invoiceId"), },
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null, defaults.folderId,
prepare: args.prepare !== false, 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) {
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),
}
}, },
}, },
{ {
@@ -1249,62 +1152,6 @@ 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",
@@ -1382,44 +1229,6 @@ 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",
@@ -1501,26 +1310,6 @@ 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",

View File

@@ -1,150 +0,0 @@
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]

View File

@@ -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. 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, 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,
{ 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)

View File

@@ -13,10 +13,6 @@ export type McpToolResult = {
content: Array<{ content: Array<{
type: "text" type: "text"
text: string text: string
} | {
type: "image"
data: string
mimeType: string
}> }>
structuredContent?: Record<string, unknown> structuredContent?: Record<string, unknown>
isError?: boolean isError?: boolean
@@ -27,8 +23,6 @@ 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>
} }

View File

@@ -1,71 +0,0 @@
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.",
},
}
}

View File

@@ -17,7 +17,7 @@ import {
incominginvoices, incominginvoices,
} from "../../../db/schema" } from "../../../db/schema"
import { eq, and, inArray, isNull, not } from "drizzle-orm" import { eq, and, isNull, not } from "drizzle-orm"
const formatInvoiceItemDescription = (item: any) => { const formatInvoiceItemDescription = (item: any) => {
const parts = [ const parts = [
@@ -86,25 +86,8 @@ 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, fileIds?: string[]) => { const processInvoices = async (tenantId:number) => {
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
@@ -115,7 +98,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 { ...result, error: "Mandant nicht gefunden" } return
} }
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`) console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
@@ -141,7 +124,6 @@ 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
} }
@@ -155,13 +137,10 @@ 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
@@ -171,23 +150,6 @@ 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
@@ -196,8 +158,7 @@ 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.`)
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" }) continue
return
} }
const electronicInvoice = await detectElectronicInvoice(fileData, file) const electronicInvoice = await detectElectronicInvoice(fileData, file)
@@ -225,10 +186,7 @@ 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") { if (electronicInvoice.container === "xml") continue
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
return
}
} }
} }
@@ -237,86 +195,50 @@ 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}`)
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" }) continue
return
} }
itemInfo = mapGptDataToIncomingInvoice(data, tenantId) itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
} }
// --------------------------------------------------------- // ---------------------------------------------------------
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen. // 4️⃣ IncomingInvoice erstellen
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
// --------------------------------------------------------- // ---------------------------------------------------------
const newInvoice = await server.db.transaction(async (tx) => { const inserted = await server.db
const [invoice] = await tx
.insert(incominginvoices) .insert(incominginvoices)
.values(itemInfo) .values(itemInfo)
.returning() .returning()
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden") const newInvoice = inserted?.[0]
const linked = await tx if (!newInvoice) {
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: invoice.id }) .set({ incominginvoice: newInvoice.id })
.where(and(eq(files.id, file.id), isNull(files.incominginvoice))) .where(eq(files.id, file.id))
.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[] } = {}) => {
const previous = running.get(tenant) || Promise.resolve() return
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds)) }
running.set(tenant, current)
try { return {
const result = await current run: async (tenant:number) => {
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 }
} }

View File

@@ -1,126 +0,0 @@
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
}

View File

@@ -1,276 +0,0 @@
import { FastifyInstance } from "fastify"
import dayjs from "dayjs"
import { and, eq, inArray } from "drizzle-orm"
import {
authProfiles,
bankaccounts,
contacts,
contracts,
createddocuments,
customers,
letterheads,
plants,
products,
projects,
services,
tenants,
units,
} from "../../db/schema"
import { documentTemplateHandlebars } from "../utils/handlebars"
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const invoiceLikeDocumentTypes = ["invoices", "advanceInvoices", "serialInvoices", "cancellationInvoices"]
/** Same format as useCurrency in the frontend. */
export const renderCurrency = (value: any, currency = "€") =>
`${Number(value).toFixed(2).replace(".", ",")} ${currency}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
const rowSum = (row: any) => Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)
export function documentNumberTitle(type: string) {
if (invoiceLikeDocumentTypes.includes(type)) return "Rechnungsnummer"
if (quoteLikeDocumentTypes.includes(type)) return type === "costEstimates" ? "Kostenschätzungsnummer" : "Angebotsnummer"
if (deliveryNoteLikeDocumentTypes.includes(type)) return type === "packingSlips" ? "Packscheinnummer" : "Lieferscheinnummer"
if (type === "confirmationOrders") return "Auftragsbestätigungsnr."
return "Belegnummer"
}
const formatNumberLikeValue = (value: any) => {
if (value === null || value === undefined || value === "") return "-"
if (typeof value === "string" || typeof value === "number") return String(value)
const directValue = value.value ?? value.number ?? value.currentNumber ?? value.customerNumber ?? value.id
if (directValue !== null && directValue !== undefined && directValue !== "") return String(directValue)
if (value.prefix !== undefined || value.nextNumber !== undefined || value.suffix !== undefined) {
return `${value.prefix || ""}${value.nextNumber || ""}${value.suffix || ""}` || "-"
}
return "-"
}
/** Port of documentTotal from the document editor. */
export function calculateOutgoingDocumentTotals(document: any, advanceInvoices: any[]) {
let totalNet = 0, total19 = 0, totalNet19 = 0, total7 = 0, totalNet7 = 0, totalNet0 = 0
const rows = document.rows || []
rows.filter((row: any) => !row.optional && !row.alternative).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const rowPrice = Number(rowSum(row).toFixed(3))
totalNet += rowPrice
if (row.taxPercent === 19) { total19 += rowPrice * 0.19; totalNet19 += rowPrice }
else if (row.taxPercent === 7) { total7 += rowPrice * 0.07; totalNet7 += rowPrice }
else if (row.taxPercent === 0) totalNet0 += rowPrice
})
const titleSums: Record<string, number> = {}
const titleSumsTransfer: Record<string, number> = {}
let activeTitleKeys: string[] = []
let transferCounter = 0
rows.forEach((row: any) => {
if (row.mode === "title") {
const title = `${row.pos} - ${row.text}`
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
titleSums[title] = 0
if (activeTitleKeys.some(Boolean)) titleSumsTransfer[title] = transferCounter
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
activeTitleKeys[titleLevel - 1] = title
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
} else if (!["pagebreak", "text"].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
const sum = rowSum(row)
activeTitleKeys.forEach((key) => { if (key) titleSums[key] = Number(titleSums[key]) + sum })
transferCounter += sum
}
})
const totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
const totalGrossAlreadyPaid = advanceInvoices.reduce((sum, invoice) => {
const advanceRow = (invoice?.rows || []).find((row: any) => row.advanceInvoiceData)
return advanceRow ? sum + advanceRow.price * ((100 + advanceRow.taxPercent) / 100) : sum
}, 0)
const totalSumToPay = document.type === "invoices" ? totalGross - totalGrossAlreadyPaid
: document.type === "cancellationInvoices" ? totalGross + totalGrossAlreadyPaid : 0
return { titleSums, titleSumsTransfer, totalNet, total19, totalNet19, total7, totalNet7, total0: 0, totalNet0, totalGross, totalGrossAlreadyPaid, totalSumToPay }
}
export type OutgoingDocumentPdfContext = {
tenant: any
customer: any
contact?: any
contactPerson?: any
plant?: any
project?: any
contract?: any
units: any[]
products: any[]
services: any[]
bankAccounts: any[]
advanceInvoices: any[]
}
/** Port of getDocumentData from the document editor, so MCP previews match the UI preview. */
export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocumentPdfContext) {
const document = structuredClone(source)
document.rows = document.rows || []
const address = document.address || {}
const customer = data.customer || {}
const contact = data.contact || null
const contactPerson = data.contactPerson || {}
const businessInfo = data.tenant.businessInfo || {}
const paymentQrBankAccount = data.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || data.tenant.name
if ((data.tenant.extraModules || []).includes("agriculture")) {
document.rows.forEach((row: any) => {
if (row.agriculture?.dieselUsage) {
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`
}
})
}
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
const rows = document.rows.map((row: any) => {
if (!["pagebreak", "title"].includes(row.mode)) {
if (row.agriculture?.description) row.descriptionText = row.agriculture.description
else if (row.description) row.descriptionText = row.description
else delete row.descriptionText
}
if (["pagebreak", "title", "text"].includes(row.mode)) return row
if (row.mode === "normal") row.text = data.products.find((item) => item.id === row.product)?.name || ""
if (row.mode === "service") row.text = data.services.find((item) => item.id === row.service)?.name || ""
return {
...row,
rowAmount: renderCurrency(rowSum(row)),
quantity: String(row.quantity).replace(".", ","),
unit: data.units.find((item) => item.id === row.unit)?.short || "",
pos: String(row.pos),
price: renderCurrency(row.price),
discountText: `(Rabatt: ${row.discountPercent} %)`,
}
})
const context = {
lohnkosten: document.report?.totalHoursSellingPrice ? renderCurrency(document.report.totalHoursSellingPrice) : null,
anrede: contact?.salutation || customer.salutation,
titel: contact?.title || customer.title,
vorname: contact?.firstName || customer.firstname,
nachname: contact?.lastName || customer.lastname,
kundenname: customer.name,
zahlungsziel_in_tagen: document.paymentDays,
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
}
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
const recipientCountry = String(address.country ?? customer.infoData?.country ?? "").trim()
const contactTel = document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel
return {
type: document.type,
taxType: document.taxType,
adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`,
recipient: [
customer.name,
...(customer.nameAddition ? [customer.nameAddition] : []),
...(contact ? [`${contact.firstName} ${contact.lastName}`] : []),
address.street,
...(address.special ? [address.special] : []),
`${address.zip} ${address.city}`,
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
],
info: [
{ label: documentNumberTitle(document.type), content: document.documentNumber || "XXXX" },
{ label: "Kundennummer", content: formatNumberLikeValue(customer.customerNumber) },
{ label: "Belegdatum", content: formatDate(document.documentDate) },
...(document.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
label: document.deliveryDateType,
content: !["Lieferzeitraum", "Leistungszeitraum"].includes(document.deliveryDateType)
? formatDate(document.deliveryDate)
: `${formatDate(document.deliveryDate)} - ${formatDate(document.deliveryDateEnd)}`,
}] : []),
{ label: "Ansprechpartner", content: contactPerson.full_name },
...(contactTel ? [{ label: "Telefon", content: contactTel }] : []),
...(contactPerson.email ? [{ label: "E-Mail", content: contactPerson.email }] : []),
...(document.plant ? [{ label: "Objekt", content: data.plant?.name || "Objekt nicht gefunden" }] : []),
...(document.project ? [{ label: "Projekt", content: data.project?.name || "Projekt nicht gefunden" }] : []),
...(document.contract ? [{ label: "Vertrag", content: data.contract?.contractNumber || "Vertrag nicht gefunden" }] : []),
],
title: document.title,
description: document.description,
endText: documentTemplateHandlebars.compile(document.endText || "")(context),
startText: documentTemplateHandlebars.compile(document.startText || "")(context),
rows,
totalArray: [
{ label: "Nettobetrag", content: renderCurrency(totals.totalNet) },
...[19, 7, 0].flatMap((rate) => rows.find((row: any) => row.taxPercent === rate) && document.taxType !== "13b UStG" ? [{
label: `zzgl. ${rate}% USt auf ${renderCurrency((totals as any)[`totalNet${rate}`])}`,
content: renderCurrency((totals as any)[`total${rate}`]),
}] : []),
{ label: "Gesamtbetrag", content: renderCurrency(totals.totalGross) },
],
total: {
totalNet: renderCurrency(totals.totalNet),
total19: renderCurrency(totals.total19),
total0: renderCurrency(totals.total0),
totalGross: renderCurrency(totals.totalGross),
totalGrossAlreadyPaid: renderCurrency(totals.totalGrossAlreadyPaid),
totalSumToPay: renderCurrency(totals.totalSumToPay),
titleSums: formatSums(totals.titleSums),
titleSumsTransfer: formatSums(totals.titleSumsTransfer),
},
agriculture: document.agriculture,
usedAdvanceInvoices: data.advanceInvoices,
paymentQr: paymentQrIban && document.payment_type === "transfer" ? {
iban: paymentQrIban,
bic: paymentQrBic,
recipientName: paymentQrRecipient,
amount: totals.totalSumToPay,
remittance: document.documentNumber || document.title,
} : null,
}
}
const one = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
if (id === null || id === undefined) return null
const [row] = await server.db.select().from(table).where(and(eq(table.id, id as any), eq(table.tenant, tenantId))).limit(1)
return row || null
}
export async function renderOutgoingDocumentPdf(server: FastifyInstance, tenantId: number, document: any) {
if (!document.letterhead) throw Object.assign(new Error("Es ist kein Briefpapier ausgewählt"), { statusCode: 400 })
if (!document.customer) throw Object.assign(new Error("Es ist kein Kunde ausgewählt"), { statusCode: 400 })
const rows = document.rows || []
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
const [[tenant], letterhead, customer, contact, plant, project, contract, unitRows, productRows, serviceRows, bankAccountRows, advanceInvoices, [contactPerson]] = await Promise.all([
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
one(server, letterheads, document.letterhead, tenantId),
one(server, customers, document.customer, tenantId),
one(server, contacts, document.contact, tenantId),
one(server, plants, document.plant, tenantId),
one(server, projects, document.project, tenantId),
one(server, contracts, document.contract, tenantId),
server.db.select().from(units),
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
document.created_by
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, document.created_by), eq(authProfiles.tenant_id, tenantId))).limit(1)
: [null],
])
if (!letterhead?.path) throw Object.assign(new Error("Briefpapier nicht gefunden"), { statusCode: 400 })
if (!customer) throw Object.assign(new Error("Kunde nicht gefunden"), { statusCode: 400 })
const pdfData = buildOutgoingDocumentPdfData(document, {
tenant, customer, contact, contactPerson, plant, project, contract,
units: unitRows, products: productRows, services: serviceRows, bankAccounts: bankAccountRows,
advanceInvoices: advanceIds.map((id) => advanceInvoices.find((invoice: any) => invoice.id === id)).filter(Boolean),
})
const layout: InvoicePdfLayout = { pageHeightMm: 297, rows: [], pageCount: 0 }
const pdf: any = await createInvoicePDF(server, "base64", pdfData, letterhead.path, layout)
return { pdf: Buffer.from(pdf.base64, "base64"), layout }
}

View File

@@ -10,7 +10,6 @@ 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,
@@ -34,7 +33,9 @@ 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")

View File

@@ -22,7 +22,6 @@ 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";
@@ -335,26 +334,6 @@ 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) {
@@ -369,11 +348,8 @@ 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))
})
server.get('/functions/services/prepareincominginvoices', async (req) => { await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
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) => {

View File

@@ -11,11 +11,6 @@ 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",
@@ -67,13 +62,7 @@ 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 })
} }
type McpRouteOptions = { export default async function mcpRoutes(server: FastifyInstance) {
/** 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)
@@ -232,37 +221,6 @@ export default async function mcpRoutes(server: FastifyInstance, options: McpRou
} }
}) })
// 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]
@@ -312,7 +270,7 @@ export default async function mcpRoutes(server: FastifyInstance, options: McpRou
description: tool.description, description: tool.description,
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
annotations: { annotations: {
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
}, },
})), })),
})) }))

View File

@@ -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" | "restored" action: "created" | "updated" | "unchanged" | "deleted" | "archived"
oldVal?: any oldVal?: any
newVal?: any newVal?: any
text?: string text?: string
@@ -62,7 +62,6 @@ 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`
} }

View File

@@ -117,17 +117,7 @@ const getDuration = (time) => {
} }
/** Positions in mm from the top edge, recorded while rendering for layout checks (e.g. page breaks). */ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
export type InvoicePdfLayout = {
pageHeightMm: number
rows: Array<{ index: number, id?: string, pos?: string, mode: string, text: string, page: number, topMm: number, bottomMm: number }>
totals?: { page: number, topMm: number, bottomMm: number }
endText?: { page: number, topMm: number, bottomMm: number }
paymentQr?: { page: number, topMm: number, leftMm: number }
pageCount: number
}
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string, layout?: InvoicePdfLayout) => {
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const isPackingSlip = invoiceData?.type === "packingSlips" const isPackingSlip = invoiceData?.type === "packingSlips"
@@ -163,14 +153,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
- paymentQrLayout.sizeMm * mm - paymentQrLayout.sizeMm * mm
} }
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
let paymentQrOnNewPage = false
const getTextWidthBeforePaymentQr = (page: any, startX: number) => { const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
if (!paymentQrCode || paymentQrOnNewPage) return 500 if (!paymentQrCode) return 500
return Math.max( return Math.max(
240, 240,
@@ -512,7 +496,6 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
} }
invoiceData.rows.forEach((row, index) => { invoiceData.rows.forEach((row, index) => {
const layoutTop = rowHeight
if (!["pagebreak", "title", "text"].includes(row.mode)) { if (!["pagebreak", "title", "text"].includes(row.mode)) {
@@ -898,18 +881,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
} }
} }
console.log(rowHeight) console.log(rowHeight)
layout?.rows.push({
index, id: row.id, pos: row.pos, mode: row.mode, text: String(row.text || "").slice(0, 80),
// rowHeight is the baseline of the first text line; shift to the visible cap height.
page: pageCounter, topMm: row.mode === "pagebreak" ? 30 : layoutTop - 3, bottomMm: row.mode === "pagebreak" ? 30 : rowHeight - 3,
})
}) })
let endTextDiff = 35 let endTextDiff = 35
const layoutContentEnd = rowHeight
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
let amountsBottomMm = rowHeight - 3
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) { if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawLine({ pages[pageCounter - 1].drawLine({
@@ -1024,23 +999,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
} }
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1) const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
if (layout) {
const maxWidth = getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
const lines = String(invoiceData.endText || "").split("\n")
.reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0)
const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
// drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm.
layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 }
}
pages[pageCounter - 1].drawText(invoiceData.endText, { pages[pageCounter - 1].drawText(invoiceData.endText, {
...endTextPosition, ...endTextPosition,
size: 10, size: 10,
@@ -1052,16 +1012,6 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
} }
if (paymentQrCode) { if (paymentQrCode) {
// Invoice-like documents decide this before placing the end text.
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
}
if (paymentQrOnNewPage) {
// Positions or totals reach the QR area; never draw the code over amounts.
const qrPage = pdfDoc.addPage()
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
pages.push(qrPage)
}
const lastPage = pages[pages.length - 1] const lastPage = pages[pages.length - 1]
const qrSize = paymentQrLayout.sizeMm * mm const qrSize = paymentQrLayout.sizeMm * mm
const bottomMargin = paymentQrLayout.bottomMarginMm * mm const bottomMargin = paymentQrLayout.bottomMarginMm * mm
@@ -1080,12 +1030,6 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold, font: fontBold,
}) })
if (layout) layout.paymentQr = {
page: pages.length,
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
leftMm: qrX / mm,
}
lastPage.drawImage(paymentQrCode, { lastPage.drawImage(paymentQrCode, {
x: qrX, x: qrX,
y: qrY, y: qrY,
@@ -1094,11 +1038,6 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}) })
} }
if (layout) {
layout.pageCount = pages.length
layout.pageHeightMm = page1.getHeight() / mm
}
return await pdfDoc.saveAsBase64() return await pdfDoc.saveAsBase64()
} }

View File

@@ -1,30 +0,0 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
const execFileAsync = promisify(execFile)
/** Renders selected PDF pages (1-based) to PNG with poppler's pdftoppm. */
export async function renderPdfPagesAsPng(pdf: Buffer, pages: number[], dpi: number) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-preview-"))
try {
const input = path.join(dir, "document.pdf")
await fs.writeFile(input, pdf)
const images: Array<{ page: number, png: Buffer }> = []
for (const page of pages) {
const prefix = path.join(dir, `page-${page}`)
try {
await execFileAsync("pdftoppm", ["-png", "-r", String(dpi), "-f", String(page), "-l", String(page), "-singlefile", input, prefix], { timeout: 30_000 })
} catch (error: any) {
if (error?.code === "ENOENT") throw Object.assign(new Error("Die Seitenvorschau benötigt pdftoppm (poppler-utils) auf dem Server."), { statusCode: 501 })
throw error
}
images.push({ page, png: await fs.readFile(`${prefix}.png`) })
}
return images
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
}

View File

@@ -1,24 +0,0 @@
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)
})

View File

@@ -1,48 +0,0 @@
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"])
})

View File

@@ -1,45 +0,0 @@
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")
})

View File

@@ -1,99 +0,0 @@
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)
})

View File

@@ -1,106 +0,0 @@
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"/)
})

View File

@@ -1,41 +0,0 @@
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"])
})

View File

@@ -1,125 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { execFileSync } from "node:child_process"
import { Readable } from "node:stream"
import { PDFDocument } from "pdf-lib"
import { initS3, s3 } from "../src/utils/s3"
import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf"
import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service"
import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout"
import { renderPdfPagesAsPng } from "../src/utils/pdfPreview"
import { asToolResult, withImages } from "../src/mcp/result"
import { mcpToolMap } from "../src/mcp/registry"
const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })()
const document = (rows: any[]) => ({
id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28",
deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" },
startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer",
usedAdvanceInvoices: [], report: {}, rows,
})
const context = {
tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] },
customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" },
contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" },
units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [],
}
const positions = (count: number) => Array.from({ length: count }, (_, index) => ({
id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`,
description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0,
}))
test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => {
const rows = [
{ pos: "1", mode: "title", text: "Bau", titleLevel: 1 },
{ pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 },
{ pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true },
{ mode: "pagebreak" },
{ pos: "2", mode: "title", text: "Elektro", titleLevel: 1 },
{ pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 },
]
const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, [])
assert.equal(totals.totalNet, 110)
assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 })
assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 })
const data: any = buildOutgoingDocumentPdfData(document(rows), context)
assert.equal(data.startText, "Hallo Ada,")
assert.equal(data.endText, "Zahlbar in 14 Tagen.")
assert.equal(data.rows[5].text, "Kabel")
assert.equal(data.rows[5].unit, "Stk")
assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €")
assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"])
assert.equal(data.info[0].label, "Rechnungsnummer")
})
test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => {
const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [
{ index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 },
{ index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 },
{ index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 },
], totals: { page: 1, topMm: 270, bottomMm: 300 } }
const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262)
assert.equal(warnings.length, 1)
assert.equal(warnings[0].insertPagebreakBeforeIndex, 1)
assert.equal(summary.pages[0].freeMm, -38)
})
test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
const letterhead = await PDFDocument.create()
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
const background = Buffer.from(await letterhead.save())
await initS3()
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
const rows: any[] = positions(30)
rows.splice(12, 0, { mode: "pagebreak" })
const data = buildOutgoingDocumentPdfData(document(rows), context)
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout)
// Rows overflow page 2, so the payment QR code gets an extra third page.
assert.equal(layout.pageCount, 3)
assert.equal(layout.paymentQr!.page, 3)
assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2)
assert.ok(layout.endText && layout.totals)
const analysis = analyzeOutgoingDocumentLayout(layout)
assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings))
const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50)
assert.deepEqual(images.map((image) => image.page), [1, 2])
assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" }))))
assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"])
assert.deepEqual(result.structuredContent, { pageCount: 2 })
})
test("preview tool is read-only for outgoing documents", () => {
assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"])
})
test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
const letterhead = await PDFDocument.create()
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
const background = Buffer.from(await letterhead.save())
await initS3()
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
const render = async (count: number) => {
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
return layout
}
const short = await render(3)
assert.equal(short.pageCount, 1)
assert.equal(short.paymentQr!.page, 1)
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
const long = await render(6)
assert.equal(long.pageCount, 2)
assert.equal(long.paymentQr!.page, 2)
assert.ok(long.rows.every((row) => row.page === 1))
const analysis = analyzeOutgoingDocumentLayout(long)
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
assert.ok(warning, JSON.stringify(analysis.warnings))
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
})

View File

@@ -1,31 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableName } from "drizzle-orm"
import { applyOutgoingRowCatalogDefaults } from "../src/mcp/outgoingRowDefaults"
const catalog: Record<string, any[]> = {
products: [{ id: 1, name: "Kabel", description: "NYM-J 3x1,5", unit: 2, selling_price: 1.25, tax_percentage: 19 }],
services: [{ id: 5, name: "Montage", description: "Montage vor Ort", unit: 3, sellingPrice: 50, sellingPriceComposed: { total: 55 }, taxPercentage: 7, costcentre: "cc" }],
}
const context: any = { tenantId: 7, server: { db: { select: () => ({ from: (table: any) => ({ where: async () => catalog[getTableName(table)] }) }) } } }
test("selected products and services bring their description and editor defaults", async () => {
const rows: any = await applyOutgoingRowCatalogDefaults(context, [
{ mode: "normal", product: 1, quantity: 3 },
JSON.stringify({ mode: "service", service: 5 }),
{ mode: "free", text: "Frei", descriptionText: "Alt" },
])
assert.deepEqual(rows[0], { mode: "normal", product: 1, quantity: 3, text: "Kabel", description: "NYM-J 3x1,5", unit: 2, inputPrice: 1.25, price: 1.25, taxPercent: 19 })
assert.equal(rows[1].description, "Montage vor Ort")
assert.equal(rows[1].price, 55)
assert.equal(rows[1].costCentre, "cc")
assert.equal(rows[2].description, "Alt")
})
test("caller values win and unknown catalog entries are rejected", async () => {
const [row]: any = await applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 1, description: "Eigene", price: 2, taxPercent: 7 }])
assert.equal(row.description, "Eigene")
assert.equal(row.price, 2)
assert.equal(row.taxPercent, 7)
await assert.rejects(applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 9 }]), /Artikel 9 nicht gefunden/)
})

View File

@@ -40,12 +40,11 @@ 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", isAdmin: true } as any const context = { server: { db }, tenantId: 7, userId: "test" } 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, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset }) const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), 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)

View File

@@ -730,12 +730,6 @@ 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
} }
@@ -1986,24 +1980,8 @@ 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
@@ -2034,39 +2012,15 @@ const uploadGeneratedDocument = 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}`
try { await router.push(`/createDocument/show/${itemInfo.value.id}`)
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) => {

View File

@@ -110,17 +110,9 @@ 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()
@@ -290,12 +282,6 @@ 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"

View File

@@ -200,47 +200,6 @@ 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 || "-"
@@ -403,14 +362,6 @@ 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"
@@ -1109,27 +1060,6 @@ 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>

View File

@@ -64,89 +64,35 @@ 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")
} }
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt. // Funktion zum Vorbereiten der Belege
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
let preparationPollTimer = null
const showPreparationResult = (status) => {
const result = status?.result
if (result?.error && !result.created?.length) {
toast.add({
title: 'Fehler',
description: result.error,
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
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
if (notify) showPreparationResult(status)
await setupPage()
} catch (error) {
console.error(error)
pollPreparation(notify)
}
}, 3000)
}
const prepareInvoices = async () => { const prepareInvoices = async () => {
isPreparing.value = true isPreparing.value = true
try { try {
await $api(preparationEndpoint, { method: 'POST' }) await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
toast.add({ toast.add({
title: 'Belegvorbereitung gestartet', title: 'Erfolg',
description: 'Die Belege werden im Hintergrund vorbereitet.', description: 'Eingangsbelege wurden vorbereitet.',
icon: 'i-heroicons-sparkles' icon: 'i-heroicons-check-circle',
color: 'green'
}) })
pollPreparation(true)
// Liste neu laden
await setupPage()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
isPreparing.value = false
toast.add({ toast.add({
title: 'Fehler', title: 'Fehler',
description: 'Die Belegvorbereitung konnte nicht gestartet werden.', description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle', icon: 'i-heroicons-exclamation-circle',
color: 'red' color: 'red'
}) })
} finally {
isPreparing.value = false
} }
} }
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)) {