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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user