KI-AGENT: ZUGFeRD- und E-Rechnungserkennung ergänzen

This commit is contained in:
2026-07-21 19:59:44 +02:00
parent 430ab74522
commit 6c613857fa
4 changed files with 200 additions and 24 deletions

View File

@@ -1,6 +1,8 @@
import { FastifyInstance } from "fastify"
import dayjs from "dayjs"
import { getInvoiceDataFromGPT } from "../../utils/gpt"
import { loadFileBuffer } from "../../utils/fileBuffer"
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
// Drizzle schema
import {
@@ -90,7 +92,32 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
const data = await getInvoiceDataFromGPT(server,file, 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)
if (electronicInvoice) {
server.log.info({
fileId: file.id,
container: electronicInvoice.container,
syntax: electronicInvoice.syntax,
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
if (electronicInvoice.container === "xml") {
server.log.warn({ fileId: file.id }, "Die Auswertung eigenständiger E-Rechnungs-XML folgt in Etappe 2.")
continue
}
}
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)

View File

@@ -0,0 +1,141 @@
import {
decodePDFRawStream,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFRawStream,
PDFString,
} from "pdf-lib"
export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note"
export type ElectronicInvoiceDetection = {
container: "pdf" | "xml"
syntax: ElectronicInvoiceSyntax
xml: Buffer
attachmentName: string | null
}
type FileLike = {
name?: string | null
path?: string | null
mimeType?: string | null
}
const decodePdfText = (value: unknown): string | null => {
if (value instanceof PDFString || value instanceof PDFHexString) {
return value.decodeText()
}
return null
}
const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8")
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) {
return "cii"
}
if (
/<Invoice(?:\s|>)/i.test(sample)
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
) {
return "ubl-invoice"
}
if (
/<CreditNote(?:\s|>)/i.test(sample)
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
) {
return "ubl-credit-note"
}
return null
}
const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => {
const context = fileSpec.context
const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict)
if (!embeddedFiles) return null
const stream = context.lookup(
embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")),
)
if (!(stream instanceof PDFRawStream)) return null
const name = decodePdfText(
context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))),
)
const contents = Buffer.from(decodePDFRawStream(stream).decode())
return { name, xml: contents }
}
const detectEmbeddedInvoice = async (pdf: Buffer): Promise<ElectronicInvoiceDetection | null> => {
let document: PDFDocument
try {
document = await PDFDocument.load(pdf, {
ignoreEncryption: true,
updateMetadata: false,
})
} catch {
return null
}
for (const [, object] of document.context.enumerateIndirectObjects()) {
if (!(object instanceof PDFDict)) continue
const type = object.get(PDFName.of("Type"))
if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue
const embedded = extractXmlFromFileSpec(object)
if (!embedded) continue
const syntax = detectXmlSyntax(embedded.xml)
if (!syntax) continue
return {
container: "pdf",
syntax,
xml: embedded.xml,
attachmentName: embedded.name,
}
}
return null
}
const hasPdfFileType = (file: FileLike, data: Buffer) => {
const filename = String(file.name || file.path || "").toLowerCase()
const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim()
return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-"
}
export const detectElectronicInvoice = async (
data: Buffer,
file: FileLike,
): Promise<ElectronicInvoiceDetection | null> => {
if (hasPdfFileType(file, data)) {
return detectEmbeddedInvoice(data)
}
const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<")
const syntax = beginsLikeXml ? detectXmlSyntax(data) : null
if (syntax) {
return {
container: "xml",
syntax,
xml: data,
attachmentName: file.name || file.path?.split("/").pop() || null,
}
}
return null
}