/** * Extraktions-Qualität: Regressionen aus dem Wechsel auf openai/gpt-5.6-luna. * * Deckt drei Befunde ab, die live an den Demo-Belegen reproduziert wurden: * 1. Platzhalter-Händlernamen ("Nicht lesbar") kamen mit hoher Confidence durch * und landeten ungeprüft als Händlername in der Excel. * 2. Trinkgeld auf Bewirtungsbelegen darf nicht gegen den Bruttobetrag gerechnet * werden — sonst meldet Check 5 bei jedem Beleg mit Tip eine Abweichung. * 3. Das Modell-Schema darf `validation` nicht enthalten (wird lokal berechnet) * und muss für OpenAI-strict jedes Feld in `required` führen. */ import { describe, test, expect } from "./runner"; import { validateReceiptMath, isPlaceholderMerchantName, isTipLineItem, CONFIDENCE_THRESHOLDS, } from "../../src/lib/ai/mathValidator"; import ExcelJS from "exceljs"; import { ProcessedReceipt, ReceiptData, ReceiptExtractionModelSchema, LineItemViewBatchModelSchema, LineItemVerificationModelSchema, PENDING_VALIDATION, grossWithTip, } from "../../src/lib/schema/receipt"; import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator"; import { generateAccountingCsv } from "../../src/lib/export/csvGenerator"; import { chooseBetterExtraction, extractionQualityScore, mergeLineItemViews, } from "../../src/lib/ai/extractor"; /** Extraktionsergebnis zu einem gespeicherten Beleg aufwerten. */ function stored(data: ReceiptData): ProcessedReceipt { return { ...data, id: "rcpt_test", imageHash: "hash_test", originalFileName: "test.jpg", fileSizeBytes: 1024, createdAt: "2026-08-12T10:00:00.000Z", updatedAt: "2026-08-12T10:00:00.000Z", status: "ready", }; } function receipt(overrides: Partial = {}): ReceiptData { return { merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 }, date: { isoDate: "2026-08-12", time: null, confidence: 0.95 }, documentType: "KASSENBON", receiptNumber: null, currency: "EUR", totalAmount: { value: 11.9, confidence: 0.98 }, netAmount: 10.0, taxBreakdown: [{ ratePercent: 19, taxAmount: 1.9, netAmount: 10.0 }], lineItems: [ { description: "Testartikel", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19 }, ], suggestedCategory: "Sonstiges", validation: { ...PENDING_VALIDATION }, ...overrides, } as ReceiptData; } describe("Extraktion: Platzhalter-Händlernamen", () => { const placeholders = [ "Nicht lesbar", "nicht lesbar", "Taxiunternehmen (Name unleserlich)", "Unbekannter Händler", "Unknown", "n/a", "N/A", "—", "-", " ", "", ]; for (const name of placeholders) { test(`EQ-1: "${name || "(leer)"}" gilt als Platzhalter`, () => { expect(isPlaceholderMerchantName(name)).toBe(true); }); } const realNames = [ "REWE", "Aral Tankstelle Station", "BISTRO AM MARKT", "Apotheke am Stadtpark", "Trattoria Bella Vista", "MediaMarkt", "Deutsche Bahn AG", ]; for (const name of realNames) { test(`EQ-2: "${name}" gilt NICHT als Platzhalter`, () => { expect(isPlaceholderMerchantName(name)).toBe(false); }); } test("EQ-3: Platzhalter wird trotz hoher Confidence zur Prüfung markiert", () => { const r = validateReceiptMath( receipt({ merchant: { name: "Nicht lesbar", address: null, taxId: null, confidence: 0.99 }, }) ); expect(r.needsUserReview).toBe(true); expect(r.reviewField).toBe("merchant"); }); test("EQ-4: Echter Händlername mit hoher Confidence bleibt ungeflaggt", () => { const r = validateReceiptMath(receipt()); expect(r.needsUserReview).toBe(false); }); test("EQ-5: Niedrige Confidence flaggt weiterhin unabhängig vom Namen", () => { const r = validateReceiptMath( receipt({ merchant: { name: "REWE", address: null, taxId: null, confidence: CONFIDENCE_THRESHOLDS.merchant - 0.01, }, }) ); expect(r.needsUserReview).toBe(true); expect(r.reviewField).toBe("merchant"); }); }); describe("Extraktion: Trinkgeld auf Bewirtungsbelegen", () => { test("EQ-6: Trinkgeld-Positionen werden erkannt", () => { expect(isTipLineItem("Trinkgeld")).toBe(true); expect(isTipLineItem("trinkgeld")).toBe(true); expect(isTipLineItem("Tip")).toBe(true); expect(isTipLineItem("Gratuity")).toBe(true); }); test("EQ-7: Normale Positionen sind kein Trinkgeld", () => { expect(isTipLineItem("Pizza Margherita")).toBe(false); expect(isTipLineItem("San Pellegrino 0.75l")).toBe(false); expect(isTipLineItem("Tiramisu")).toBe(false); expect(isTipLineItem(null)).toBe(false); }); test("EQ-8: Trattoria-Fall — Tip zählt nicht gegen den Bruttobetrag", () => { // Realer Beleg: Total 31,80 (Netto 26,72 + 19% 5,08), handschriftlich // Trinkgeld 5,00 und Gesamtbetrag 36,80. const r = validateReceiptMath( receipt({ merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 }, documentType: "BEWIRTUNGSBELEG", totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }], lineItems: [ { description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 }, { description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 }, { description: "Trinkgeld", quantity: 1, price: 5.0, unitPrice: null, taxRate: 0 }, ], }) ); expect(r.isMathValid).toBe(true); expect(r.needsUserReview).toBe(false); expect(r.issues.some((i) => i.field === "lineItems")).toBe(false); }); test("EQ-9: Ohne Tip-Ausnahme bliebe eine echte Artikel-Abweichung erkennbar", () => { const r = validateReceiptMath( receipt({ totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }], lineItems: [ { description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 }, { description: "Dessert", quantity: 1, price: 11.8, unitPrice: null, taxRate: 19 }, ], }) ); expect(r.issues.some((i) => i.field === "lineItems")).toBe(true); }); }); describe("Trinkgeld: tipAmount-Feld", () => { const trattoria = () => receipt({ merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 }, documentType: "BEWIRTUNGSBELEG", totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, tipAmount: 5.0, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }], lineItems: [ { description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 }, { description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 }, ], }); test("EQ-15: grossWithTip addiert das Trinkgeld auf den Rechnungsbetrag", () => { expect(grossWithTip(trattoria())).toBe(36.8); }); test("EQ-16: ohne Trinkgeld bleibt grossWithTip der Bruttobetrag", () => { expect(grossWithTip(receipt())).toBe(11.9); expect(grossWithTip(receipt({ tipAmount: null }))).toBe(11.9); }); test("EQ-17: Trinkgeld verfälscht die Netto/MwSt-Gegenprobe nicht", () => { const r = validateReceiptMath(trattoria()); expect(r.isMathValid).toBe(true); expect(r.needsUserReview).toBe(false); }); test("EQ-18: negatives Trinkgeld ist ein Fehler", () => { const r = validateReceiptMath(receipt({ tipAmount: -2 })); expect(r.isMathValid).toBe(false); expect(r.needsUserReview).toBe(true); }); test("EQ-19: Trinkgeld über dem Rechnungsbetrag wird zur Prüfung markiert", () => { // Typischer Lesefehler: handschriftlicher Gesamtbetrag als Tip erfasst. const r = validateReceiptMath( receipt({ totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, tipAmount: 36.8, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }] }) ); expect(r.needsUserReview).toBe(true); }); test("EQ-20: Excel führt Trinkgeld- und Gesamt-gezahlt-Spalte nur bei Bedarf", async () => { const withTip = await generateDualSheetExcel([stored(trattoria())]); const withoutTip = await generateDualSheetExcel([stored(receipt())]); const headerOf = async (buf: Buffer) => { const wb = new ExcelJS.Workbook(); await wb.xlsx.load(buf as unknown as ArrayBuffer); const row = wb.worksheets[0].getRow(1); const out: string[] = []; row.eachCell((c) => out.push(String(c.value ?? ""))); return out; }; const h1 = await headerOf(withTip); expect(h1.some((h) => h.startsWith("Trinkgeld"))).toBe(true); expect(h1.some((h) => h.startsWith("Gesamt gezahlt"))).toBe(true); const h2 = await headerOf(withoutTip); expect(h2.some((h) => h.startsWith("Trinkgeld"))).toBe(false); }); test("EQ-21: CSV enthält Trinkgeld und Gesamt gezahlt", () => { const csv = generateAccountingCsv([stored(trattoria())]); const [header, row] = csv.replace(/^/, "").split("\r\n"); expect(header.includes("Trinkgeld")).toBe(true); expect(header.includes("Gesamt gezahlt")).toBe(true); // Rechnungsbetrag bleibt 31,80, gezahlt wurden 36,80. expect(row.includes('"31,80"')).toBe(true); expect(row.includes('"5,00"')).toBe(true); expect(row.includes('"36,80"')).toBe(true); }); test("EQ-22: CSV ohne Trinkgeld führt die Spalten nicht", () => { const csv = generateAccountingCsv([stored(receipt())]); expect(csv.split("\r\n")[0].includes("Trinkgeld")).toBe(false); }); }); describe("Extraktion: Modell-Schema für OpenAI strict mode", () => { test("EQ-10: validation ist nicht Teil des Modell-Schemas", () => { const keys = Object.keys(ReceiptExtractionModelSchema.shape); expect(keys.includes("validation")).toBe(false); expect(keys.includes("merchant")).toBe(true); expect(keys.includes("totalAmount")).toBe(true); expect(keys.includes("taxBreakdown")).toBe(true); }); test("EQ-11: kein Feld ist optional (strict verlangt required für jeden Key)", () => { // .optional() erzeugt eine Lücke in `required` -> HTTP 400 invalid_json_schema. const optional = Object.entries(ReceiptExtractionModelSchema.shape) .filter(([, v]) => (v as { isOptional?: () => boolean }).isOptional?.()) .map(([k]) => k); expect(optional.length).toBe(0); }); test("EQ-12: Modell-Ausgabe ohne validation ist gültig", () => { const sample = { merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 }, date: { isoDate: "2026-08-12", time: "14:32", confidence: 0.95 }, documentType: "KASSENBON", receiptNumber: "2026-004871", currency: "EUR", totalAmount: { value: 9.06, confidence: 0.98 }, netAmount: 8.18, taxBreakdown: [ { ratePercent: 7, taxAmount: 0.4, netAmount: 5.67 }, { ratePercent: 19, taxAmount: 0.48, netAmount: 2.51 }, ], lineItems: [ { description: "Vollmilch", quantity: 2, price: 2.58, unitPrice: 1.29, taxRate: 7, confidence: 0.96, sourceView: null, rowOrder: null, }, ], suggestedCategory: "Verpflegungsmehraufwand", hospitality: null, tipAmount: null, paymentMethod: null, }; expect(ReceiptExtractionModelSchema.safeParse(sample).success).toBe(true); }); test("EQ-14: tipAmount ist gegenüber dem Modell Pflicht (nullable, nicht optional)", () => { // Fehlt das Feld, wäre es nicht in `required` -> HTTP 400 im strict mode. const shape = ReceiptExtractionModelSchema.shape as Record boolean }>; expect("tipAmount" in shape).toBe(true); expect(shape.tipAmount.isOptional?.() ?? false).toBe(false); }); test("EQ-13: PENDING_VALIDATION ist neutral (flaggt nichts vor)", () => { expect(PENDING_VALIDATION.needsUserReview).toBe(false); expect(PENDING_VALIDATION.isMathValid).toBe(true); expect(PENDING_VALIDATION.reviewField).toBe("none"); }); test("EQ-25: fokussierter OCR-Retry hat ein kleines strict-kompatibles Schema", () => { const parsed = LineItemVerificationModelSchema.safeParse({ lineItems: [ { description: "Bio Gurken", quantity: 1, price: 1.29, unitPrice: null, taxRate: 7, confidence: 0.94, sourceView: null, rowOrder: null, }, ], }); expect(parsed.success).toBe(true); expect(Object.keys(LineItemVerificationModelSchema.shape)).toEqual(["lineItems"]); }); test("EQ-26: Kachel-Batch verlangt Ausschnitt, Reihenfolge und Confidence", () => { const parsed = LineItemViewBatchModelSchema.safeParse({ views: [ { sourceView: 2, lineItems: [ { description: "Bio Gurken", quantity: 1, price: 1.29, unitPrice: null, taxRate: 7, confidence: 0.93, sourceView: 2, rowOrder: 1, }, ], }, ], }); expect(parsed.success).toBe(true); }); }); describe("Extraktion: Qualitätsauswahl beim Reasoning-Retry", () => { test("EQ-23: rechnerisch vollständige Positionen gewinnen gegen eine Warnung", () => { const incomplete = receipt({ totalAmount: { value: 3.72, confidence: 0.99 }, lineItems: [ { description: "Artikel A", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 }, { description: "Artikel B", quantity: 1, price: 1.13, unitPrice: null, taxRate: 7 }, ], validation: { ...PENDING_VALIDATION, needsUserReview: true, issues: [{ field: "lineItems", severity: "warning", message: "Sum mismatch" }], }, }); const complete = receipt({ totalAmount: { value: 3.72, confidence: 0.99 }, lineItems: [ { description: "Artikel A", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 }, { description: "Artikel B", quantity: 1, price: 1.73, unitPrice: null, taxRate: 7 }, ], validation: { ...PENDING_VALIDATION }, }); expect(extractionQualityScore(complete)).toBeLessThan(extractionQualityScore(incomplete)); expect(chooseBetterExtraction(incomplete, complete)).toBe(complete); }); test("EQ-24: ein schlechterer Retry überschreibt die Erstextraktion nicht", () => { const first = receipt({ totalAmount: { value: 1.99, confidence: 0.99 }, lineItems: [ { description: "Bio Artikel", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 }, ], validation: { ...PENDING_VALIDATION }, }); const worse = receipt({ totalAmount: { value: 1.99, confidence: 0.99 }, lineItems: [], validation: { ...PENDING_VALIDATION, needsUserReview: true, issues: [{ field: "lineItems", severity: "warning", message: "Missing" }], }, }); expect(chooseBetterExtraction(first, worse)).toBe(first); }); }); describe("Extraktion: deterministische Kachel-Zusammenführung", () => { const item = ( description: string, price: number, sourceView: number, rowOrder: number, confidence: number = 0.9 ) => ({ description, quantity: 1, price, unitPrice: null, taxRate: 7, confidence, sourceView, rowOrder, }); test("EQ-27: überlappende Zeilen erscheinen nur einmal", () => { const merged = mergeLineItemViews([ { sourceView: 1, lineItems: [item("Artikel A", 1.29, 1, 1), item("Bio Heidelbeeren", 1.99, 1, 2)], }, { sourceView: 2, lineItems: [ item("Bio Heidelbeer.", 1.99, 2, 1, 0.96), item("Bio Gurken", 1.29, 2, 2), ], }, ]); expect(merged.map((line) => line.price)).toEqual([1.29, 1.99, 1.29]); expect(merged[1].description).toBe("Bio Heidelbeer."); }); test("EQ-28: identische Produkte auf getrennten Zeilen bleiben erhalten", () => { const merged = mergeLineItemViews([ { sourceView: 1, lineItems: [item("Wasser", 0.99, 1, 1), item("Wasser", 0.99, 1, 2)], }, { sourceView: 2, lineItems: [item("Wasser", 0.99, 2, 1), item("Brot", 2.49, 2, 2)], }, ]); expect(merged.map((line) => line.description)).toEqual(["Wasser", "Wasser", "Brot"]); }); test("EQ-29: sehr lange Artikellisten erhalten keine wachsende Euro-Toleranz", () => { const value = receipt({ totalAmount: { value: 100, confidence: 0.99 }, lineItems: Array.from({ length: 100 }, (_, index) => ({ description: `Artikel ${index + 1}`, quantity: 1, price: index === 99 ? 0.9 : 1, unitPrice: null, taxRate: 7, })), }); const validation = validateReceiptMath(value); expect(validation.issues.some((issue) => issue.field === "lineItems")).toBe(true); }); test("EQ-30: Gesamtbetrag ohne erkannte Artikel wird markiert", () => { const value = receipt({ totalAmount: { value: 12.34, confidence: 0.99 }, lineItems: [] }); const validation = validateReceiptMath(value); expect(validation.issues.some((issue) => issue.field === "lineItems")).toBe(true); }); });