- „Belege vorbereiten“ startet den Lauf im Hintergrund (202) und fragt den Status ab; ein laufender Vorgang wird nicht erneut gestartet und beim Wiederöffnen der Seite weiterverfolgt - Eingangsbeleg und Dateiverknüpfung werden in einer Transaktion angelegt; ist die Datei bereits verknüpft, wird der neue Beleg verworfen - Fehler einzelner Dateien brechen den Lauf nicht mehr ab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
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<string, string> = {
|
||
"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<number, Promise<unknown>>()
|
||
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
|
||
|
||
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 }
|
||
|
||
}
|