feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -22,11 +22,18 @@ 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 {
@@ -52,7 +59,9 @@ function receipt(overrides: Partial<ReceiptData> = {}): ReceiptData {
totalAmount: { value: 11.9, confidence: 0.98 },
netAmount: 10.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.9, netAmount: 10.0 }],
lineItems: [],
lineItems: [
{ description: "Testartikel", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19 },
],
suggestedCategory: "Sonstiges",
validation: { ...PENDING_VALIDATION },
...overrides,
@@ -291,7 +300,16 @@ describe("Extraktion: Modell-Schema für OpenAI strict mode", () => {
{ ratePercent: 19, taxAmount: 0.48, netAmount: 2.51 },
],
lineItems: [
{ description: "Vollmilch", quantity: 2, price: 2.58, unitPrice: 1.29, taxRate: 7 },
{
description: "Vollmilch",
quantity: 2,
price: 2.58,
unitPrice: 1.29,
taxRate: 7,
confidence: 0.96,
sourceView: null,
rowOrder: null,
},
],
suggestedCategory: "Verpflegungsmehraufwand",
hospitality: null,
@@ -313,4 +331,165 @@ describe("Extraktion: Modell-Schema für OpenAI strict mode", () => {
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);
});
});