feat: add iOS support and harden receipt scanning
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user