KI-AGENT: ZUGFeRD- und E-Rechnungserkennung ergänzen
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||||
|
import { loadFileBuffer } from "../../utils/fileBuffer"
|
||||||
|
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import {
|
import {
|
||||||
@@ -90,7 +92,32 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
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) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
|
|||||||
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal 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
|
||||||
|
}
|
||||||
26
backend/src/utils/fileBuffer.ts
Normal file
26
backend/src/utils/fileBuffer.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
import { s3 } from "./s3"
|
||||||
|
import { secrets } from "./secrets"
|
||||||
|
|
||||||
|
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
|
||||||
|
const response = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: path,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!response.Body) {
|
||||||
|
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamToBuffer(response.Body)
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ import dayjs from "dayjs";
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
import { s3 } from "./s3";
|
|
||||||
import { secrets } from "./secrets";
|
|
||||||
import { storeExtractedTextForFile } from "./documentText";
|
import { storeExtractedTextForFile } from "./documentText";
|
||||||
|
import { loadFileBuffer } from "./fileBuffer";
|
||||||
|
import { secrets } from "./secrets";
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import { vendors, accounts, tenants } from "../../db/schema";
|
import { vendors, accounts, tenants } from "../../db/schema";
|
||||||
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// STREAM → BUFFER
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
async function streamToBuffer(stream: any): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
stream.on("error", reject);
|
|
||||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// GPT RESPONSE FORMAT (Zod Schema)
|
// GPT RESPONSE FORMAT (Zod Schema)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
|
|||||||
export const getInvoiceDataFromGPT = async function (
|
export const getInvoiceDataFromGPT = async function (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
file: any,
|
file: any,
|
||||||
tenantId: number
|
tenantId: number,
|
||||||
|
suppliedFileData?: Buffer,
|
||||||
) {
|
) {
|
||||||
await initOpenAi();
|
await initOpenAi();
|
||||||
|
|
||||||
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
let fileData: Buffer;
|
let fileData: Buffer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({
|
fileData = suppliedFileData || await loadFileBuffer(file.path);
|
||||||
Bucket: secrets.S3_BUCKET,
|
|
||||||
Key: file.path,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: any = await s3.send(command);
|
|
||||||
fileData = await streamToBuffer(response.Body);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user