import { FastifyInstance } from "fastify" import dayjs from "dayjs" import { getInvoiceDataFromGPT } from "../../utils/gpt" import { loadFileBuffer } from "../../utils/fileBuffer" import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice" import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice" import { findElectronicInvoiceVendor, mapElectronicInvoiceToIncomingInvoice, } from "../einvoice/mapElectronicInvoice" // Drizzle schema import { tenants, files, filetags, incominginvoices, } from "../../../db/schema" import { eq, and, inArray, isNull, not } from "drizzle-orm" const formatInvoiceItemDescription = (item: any) => { const parts = [ typeof item.description === "string" ? item.description.trim() : "", item.quantity !== null && item.quantity !== undefined ? [item.quantity, item.unit].filter(Boolean).join(" ") : (typeof item.unit === "string" ? item.unit.trim() : ""), typeof item.total === "number" ? `${item.total.toFixed(2)} EUR` : "", ].filter(Boolean) return parts.join(" - ") } const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { const itemInfo: any = { tenant: tenantId, state: "Vorbereitet", preparationSource: "gpt", } if (data.invoice_number) itemInfo.reference = data.invoice_number if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString() if (data.issuer?.id) itemInfo.vendor = data.issuer.id if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString() const mapPayment: Record = { "Direct Debit": "Einzug", "Transfer": "Überweisung", "Credit Card": "Kreditkarte", "Other": "Sonstiges", } if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms if (data.invoice_items?.length > 0) { itemInfo.accounts = data.invoice_items .filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null) .map((item: any) => { const total = typeof item.total === "number" ? item.total : null const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null const amountTax = total !== null && totalWithoutTax !== null ? Number((total - totalWithoutTax).toFixed(2)) : null return { account: item.account_id, description: item.description, amountNet: totalWithoutTax, amountTax, taxType: item.tax_rate !== null ? String(item.tax_rate) : null, amountGross: total, costCentre: null, quantity: item.quantity, } }) } let description = "" if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n` if (data.reference) description += `Referenz: ${data.reference}\n` for (const item of data.invoice_items || []) { const line = formatInvoiceItemDescription(item) if (line) description += `${line}\n` } itemInfo.description = description.trim() 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) { const processInvoices = async (tenantId:number, fileIds?: string[]) => { const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] } console.log("▶ Starting Incoming Invoice Preparation") const tenantsRes = await server.db .select() .from(tenants) .where(eq(tenants.id, tenantId)) .orderBy(tenants.id) if (!tenantsRes.length) { console.log("No tenants with autoPrepareIncomingInvoices = true") return { ...result, error: "Mandant nicht gefunden" } } console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`) // ------------------------------------------------------------- // 2️⃣ Jeden Tenant einzeln verarbeiten // ------------------------------------------------------------- for (const tenant of tenantsRes) { const tenantId = tenant.id // 2.1 Datei-Tags holen für incoming invoices const tagRes = await server.db .select() .from(filetags) .where( and( eq(filetags.tenant, tenantId), eq(filetags.incomingDocumentType, "invoices") ) ) .limit(1) const invoiceFileTag = tagRes?.[0]?.id if (!invoiceFileTag) { server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`) result.error = "Dateityp für Eingangsrechnungen fehlt" continue } // 2.2 Alle Dateien laden, die als Invoice markiert sind aber NOCH keine incominginvoice haben const filesRes = await server.db .select() .from(files) .where( and( eq(files.tenant, tenantId), eq(files.type, invoiceFileTag), isNull(files.incominginvoice), eq(files.archived, false), not(isNull(files.path)), ...(fileIds ? [inArray(files.id, fileIds)] : []) ) ) result.filesFound += filesRes.length if (!filesRes.length) { console.log(`No invoice files for tenant ${tenantId}`) continue } // ------------------------------------------------------------- // 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden // ------------------------------------------------------------- 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}`) let fileData: Buffer try { fileData = await loadFileBuffer(file.path!) } catch (error) { 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" }) return } const electronicInvoice = await detectElectronicInvoice(fileData, file) let itemInfo: any = null if (electronicInvoice) { server.log.info({ fileId: file.id, container: electronicInvoice.container, syntax: electronicInvoice.syntax, attachmentName: electronicInvoice.attachmentName, }, "Strukturierte E-Rechnung erkannt.") try { const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax) const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice) itemInfo = mapElectronicInvoiceToIncomingInvoice( parsedInvoice, tenantId, vendor?.id || null, electronicInvoice.container, electronicInvoice.attachmentName, ) } catch (error) { server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) if (electronicInvoice.container === "xml") { result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" }) return } } } if (!itemInfo) { const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) if (!data) { server.log.warn(`GPT returned no data for file ${file.id}`) result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" }) return } itemInfo = mapGptDataToIncomingInvoice(data, tenantId) } // --------------------------------------------------------- // 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen. // Die Verknüpfung greift nur, solange die Datei noch frei ist; // sonst wird der Beleg verworfen, damit keine Dubletten entstehen. // --------------------------------------------------------- const newInvoice = await server.db.transaction(async (tx) => { const [invoice] = await tx .insert(incominginvoices) .values(itemInfo) .returning() if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden") const linked = await tx .update(files) .set({ incominginvoice: invoice.id }) .where(and(eq(files.id, file.id), isNull(files.incominginvoice))) .returning({ id: files.id }) if (!linked.length) throw new FileAlreadyPreparedError() return invoice }) result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null }) console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`) } // Serialize runs per tenant so concurrent triggers cannot prepare the same file twice. const running = new Map>() const backgroundRuns = new Map() const run = async (tenant:number, options: { fileIds?: string[] } = {}) => { const previous = running.get(tenant) || Promise.resolve() const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds)) running.set(tenant, current) try { const result = await current 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 } }