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);
});
});

View File

@@ -6,6 +6,8 @@
import { describe, test, expect } from "./runner";
import {
AI_IMAGE_LONG_EDGE_PX,
computeReceiptTileRegions,
MAX_RECEIPT_DETAIL_TILES,
MAX_VISION_PAGES,
selectVisionPages,
} from "../../src/lib/image/processor";
@@ -14,6 +16,8 @@ import {
PROVIDER_TIMEOUT_MS,
isRateLimitError,
shouldRetryWithReasoning,
shouldVerifyLineItems,
selectLineItemViewsForVerification,
} from "../../src/lib/ai/extractor";
import { PENDING_VALIDATION, ReceiptData } from "../../src/lib/schema/receipt";
@@ -48,6 +52,35 @@ describe("Sprint B — Vision-Seiten", () => {
test("7 Seiten überlappen sich nicht doppelt", () => {
expect(selectVisionPages([1, 2, 3, 4, 5, 6, 7])).toEqual([1, 2, 3, 4, 5, 6, 7]);
});
test("langer Thermobon wird in überlappende Detailansichten geteilt", () => {
const regions = computeReceiptTileRegions(1000, 3600);
expect(regions.length).toBeGreaterThan(1);
expect(regions[0].top).toBe(0);
expect(regions[regions.length - 1].top + regions[regions.length - 1].height).toBe(3600);
expect(regions[1].top).toBeLessThan(regions[0].top + regions[0].height);
});
test("normales Dokument wird nicht unnötig gekachelt", () => {
expect(computeReceiptTileRegions(1200, 1600)).toEqual([]);
});
for (const aspect of [8, 12, 20]) {
test(`${aspect}:1-Thermobon bleibt vollständig und lückenlos`, () => {
const width = 1000;
const height = width * aspect;
const regions = computeReceiptTileRegions(width, height);
expect(regions.length).toBeGreaterThan(5);
expect(regions.length).toBeLessThanOrEqual(MAX_RECEIPT_DETAIL_TILES);
expect(regions[0].top).toBe(0);
expect(regions[regions.length - 1].top + regions[regions.length - 1].height).toBe(height);
for (let index = 1; index < regions.length; index++) {
expect(regions[index].top).toBeLessThanOrEqual(
regions[index - 1].top + regions[index - 1].height
);
}
});
}
});
describe("Sprint B — Reasoning on-demand", () => {
@@ -55,9 +88,99 @@ describe("Sprint B — Reasoning on-demand", () => {
expect(shouldRetryWithReasoning(receipt(false))).toBe(true);
});
test("Positionswarnung löst trotz valider Steuerrechnung einen Retry aus", () => {
const value = receipt(true);
value.validation = {
...value.validation,
needsUserReview: true,
issues: [{ field: "lineItems", severity: "warning", message: "Sum mismatch" }],
};
expect(shouldRetryWithReasoning(value)).toBe(true);
expect(shouldVerifyLineItems(value)).toBe(true);
});
test("echter Netto-/Steuerfehler bleibt ein Reasoning-Fall", () => {
const value = receipt(false);
value.validation = {
...value.validation,
needsUserReview: true,
issues: [{ field: "taxBreakdown", severity: "error", message: "Tax mismatch" }],
};
expect(shouldVerifyLineItems(value)).toBe(false);
});
test("valide Math braucht kein Reasoning", () => {
expect(shouldRetryWithReasoning(receipt(true))).toBe(false);
});
test("extremer oder schwieriger Bon erzwingt fokussierte Zeilen-OCR", () => {
expect(shouldVerifyLineItems(receipt(true), true)).toBe(true);
});
test("niedrige Zeilen-Confidence löst gezielte Prüfung aus", () => {
const value = receipt(true);
value.lineItems = [
{
description: "Schwer lesbar",
quantity: 1,
price: 11.9,
unitPrice: null,
taxRate: 19,
confidence: 0.55,
},
];
expect(shouldVerifyLineItems(value)).toBe(true);
});
test("nur unsicherer Ausschnitt plus direkte Nachbarn werden erneut gelesen", () => {
const value = receipt(true);
value.lineItems = [
{
description: "Artikel",
quantity: 1,
price: 11.9,
unitPrice: null,
taxRate: 19,
confidence: 0.92,
sourceView: 3,
rowOrder: 1,
},
];
const views = Array.from({ length: 6 }, (_, index) => ({
buffer: Buffer.from([index]),
sourceView: index + 1,
qualityScore: index === 3 ? 0.2 : 0.9,
difficult: index === 3,
}));
expect(
selectLineItemViewsForVerification(value, views, "detail_views").map(
(view) => view.sourceView
)
).toEqual([3, 4, 5]);
});
test("Extrembon prüft trotz lokaler Auswahl alle lückenlosen Ausschnitte", () => {
const value = receipt(true);
value.lineItems = [
{
description: "Artikel",
quantity: 1,
price: 11.9,
unitPrice: null,
taxRate: 19,
confidence: 0.9,
sourceView: 1,
rowOrder: 1,
},
];
const views = Array.from({ length: 8 }, (_, index) => ({
buffer: Buffer.from([index]),
sourceView: index + 1,
qualityScore: 0.9,
difficult: false,
}));
expect(selectLineItemViewsForVerification(value, views, "long_receipt_summary")).toHaveLength(8);
});
});
describe("Sprint B — Rate-Limit & Timeout", () => {
@@ -67,8 +190,8 @@ describe("Sprint B — Rate-Limit & Timeout", () => {
expect(isRateLimitError(new Error("timeout"))).toBe(false);
});
test("Provider-Timeout ist 12s", () => {
expect(PROVIDER_TIMEOUT_MS).toBe(12_000);
test("Provider-Timeout lässt Detailansichten genug Zeit", () => {
expect(PROVIDER_TIMEOUT_MS).toBe(20_000);
});
});

View File

@@ -3,6 +3,7 @@
*/
import { describe, test, expect } from "./runner";
import sharp from "sharp";
import {
AI_IMAGE_LONG_EDGE_PX,
AI_IMAGE_MIN_WIDTH_PX,
@@ -10,10 +11,12 @@ import {
} from "../../src/lib/image/resize";
import {
contentBoundingBox,
detectPaperQuadrilateral,
estimateSkewDegrees,
needsContrastBoost,
paperReceiptBoundingBox,
} from "../../src/lib/image/enhance";
import { processReceiptImage } from "../../src/lib/image/processor";
import { assessReceiptDetailQuality, processReceiptImage } from "../../src/lib/image/processor";
describe("Sprint C — Mindestbreite", () => {
test("Querformat bleibt am 1536-Long-Edge", () => {
@@ -86,6 +89,74 @@ describe("Sprint C — Crop", () => {
const data = new Uint8Array(40 * 40).fill(200);
expect(contentBoundingBox(data, 40, 40)).toBeNull();
});
test("heller Thermobon wird auf strukturiertem farbigem Untergrund erkannt", () => {
const w = 100;
const h = 140;
const data = new Uint8Array(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 3;
const onPaper = x >= 32 && x < 68;
data[i] = onPaper ? 200 : 180;
data[i + 1] = onPaper ? 207 : 132;
data[i + 2] = onPaper ? 214 : 82;
}
}
const box = paperReceiptBoundingBox(data, w, h, 3);
expect(box).not.toBeNull();
expect(box!.left).toBeLessThanOrEqual(32);
expect(box!.left + box!.width).toBeGreaterThanOrEqual(68);
expect(box!.width).toBeLessThan(60);
});
test("Perspektive, Schatten, Falte und gemusterter Untergrund werden konservativ erkannt", () => {
const w = 120;
const h = 180;
const data = new Uint8Array(w * h * 3);
for (let y = 0; y < h; y++) {
const progress = y / (h - 1);
const left = Math.round(32 - progress * 10);
const right = Math.round(88 + progress * 10);
for (let x = 0; x < w; x++) {
const offset = (y * w + x) * 3;
const onPaper = x >= left && x <= right;
const folded = Math.abs(y - 92) < 2;
const shadow = 165 + Math.round(progress * 65);
data[offset] = onPaper ? (folded ? 125 : shadow) : 115 + ((x + y) % 18);
data[offset + 1] = onPaper ? (folded ? 125 : shadow) : 72 + ((x * 3 + y) % 14);
data[offset + 2] = onPaper ? (folded ? 125 : shadow) : 35 + ((x + y * 2) % 11);
}
}
const quad = detectPaperQuadrilateral(data, w, h, 3);
expect(quad).not.toBeNull();
expect(quad!.topLeft.x).toBeGreaterThan(quad!.bottomLeft.x);
expect(quad!.topRight.x).toBeLessThan(quad!.bottomRight.x);
expect(quad!.confidence).toBeGreaterThanOrEqual(0.72);
});
test("unsichere helle Vollfläche wird nicht als Perspektivkorrektur behandelt", () => {
const data = new Uint8Array(100 * 140 * 3).fill(220);
expect(detectPaperQuadrilateral(data, 100, 140, 3)).toBeNull();
});
});
describe("Sprint C — lokale Lesbarkeit", () => {
test("Unschärfe erhält einen niedrigeren Qualitätswert als klare Druckzeilen", async () => {
const width = 200;
const height = 300;
const raw = Buffer.alloc(width * height, 235);
for (let y = 12; y < height - 12; y += 18) {
for (let yy = y; yy < y + 3; yy++) {
raw.fill(25, yy * width + 18, yy * width + width - 18);
}
}
const crisp = await sharp(raw, { raw: { width, height, channels: 1 } }).jpeg().toBuffer();
const blurred = await sharp(crisp).blur(4).jpeg().toBuffer();
expect(await assessReceiptDetailQuality(crisp)).toBeGreaterThan(
await assessReceiptDetailQuality(blurred)
);
});
});
describe("Sprint C — Deskew", () => {

View File

@@ -810,6 +810,7 @@ describe("Tier 1: Feature 12 — Math Determinism Engine (Netto + MwSt 7%/19% =
netAmount: 37.82,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.18, netAmount: 37.82 }],
date: { isoDate: "2026-08-15", time: null, confidence: 0.65 },
lineItems: [{ description: "Book", quantity: 1, price: 45.0, taxRate: 19 }],
};
const res = validateReceiptMath(receipt);
@@ -824,6 +825,7 @@ describe("Tier 1: Feature 12 — Math Determinism Engine (Netto + MwSt 7%/19% =
netAmount: 37.82,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.18, netAmount: 37.82 }],
merchant: { name: "Blurry Merchant", address: null, taxId: null, confidence: 0.6 },
lineItems: [{ description: "Book", quantity: 1, price: 45.0, taxRate: 19 }],
};
const res = validateReceiptMath(receipt);