Files
FEDEO/backend/src/modules/cron/prepareIncomingInvoices.ts

245 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => {
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
}
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}`)
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))
)
)
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) {
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.`)
continue
}
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") continue
}
}
if (!itemInfo) {
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue
}
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice erstellen
// ---------------------------------------------------------
const inserted = await server.db
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0]
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)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
}
return
}
return {
run: async (tenant:number) => {
await processInvoices(tenant)
console.log("Incoming Invoice Preparation Completed.")
}
}
}