fix(streckkod+kvitto): sparad-bekraftelse, fettvarden, kvitto-antal
Streckkod / produktbidrag: - Tydlig bekraftelse 'Sparad i delade katalogen - tack!' visas nu efter att en fotad produkt lasts av, sa man ser att bidraget gick fram (forut bara 'Skanna igen' utan aterkoppling). - Naringsetiketter laser nu ocksa enkelomattat och fleromattat fett (t.ex. olivolja visar tre fettrader). Nya valfria falt monounsaturatedFatG/ polyunsaturatedFatG i NutritionValues + READ_NUTRITION_LABEL + prompt + produktpanelen. Obligatoriska EU-falt oforandrade. Kvitto: - Rader som ar samma vara (samma normaliserade namn + enhet) slas nu ihop till EN vara med ratt antal - '2 x mjolk' pa kvittot blir en vara a 2 st i stallet for tva rader a 1 st. Priser summeras, rabatter dras som forut. En kvittorad raknas som minst 1 exemplar aven om antal saknas. Verifierat: typecheck hela repot gront, nutrition-engine 19/19, ai-contracts 24/24, worker 23/23, api 104/104, samt repro mot testdatabas (2 mjolk-rader -> 1 vara a 2; olivolja sparar fett/mattat/enkel-/fleromattat).
This commit is contained in:
@@ -83,33 +83,64 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
|
||||
discountByName.set(k, (discountByName.get(k) ?? 0) + l.totalPriceMinor); // negativt belopp
|
||||
}
|
||||
}
|
||||
(output as Record<string, unknown>).items = lines
|
||||
.filter((l) => l.isDiscount !== true && (l.normalizedName ?? l.rawText))
|
||||
.map((l, idx) => {
|
||||
const qty = typeof l.quantity === "number" ? l.quantity : null;
|
||||
const gross =
|
||||
typeof l.totalPriceMinor === "number"
|
||||
? l.totalPriceMinor
|
||||
: typeof l.unitPriceMinor === "number" && qty != null
|
||||
? Math.round(l.unitPriceMinor * qty)
|
||||
: null;
|
||||
const disc = l.normalizedName
|
||||
? (discountByName.get(String(l.normalizedName).toLowerCase()) ?? 0)
|
||||
: 0;
|
||||
const priceMinor = gross != null ? Math.max(0, gross + disc) : null;
|
||||
return {
|
||||
tempId: String(idx),
|
||||
detectedName: String(l.normalizedName ?? l.rawText ?? ""),
|
||||
// Slå ihop rader som är SAMMA vara (samma normaliserade namn + enhet) så att
|
||||
// t.ex. "2 st mjölk" på kvittot blir EN vara med antal 2, inte två rader à 1 st.
|
||||
// (Många butiker skriver ut varje köpt exemplar på egen rad.)
|
||||
type MergedLine = {
|
||||
detectedName: string;
|
||||
canonicalIngredientId: string | null;
|
||||
unit: string | null;
|
||||
quantity: number;
|
||||
gross: number | null;
|
||||
confidence: number;
|
||||
};
|
||||
const mergedItems = new Map<string, MergedLine>();
|
||||
for (const l of lines) {
|
||||
if (l.isDiscount === true) continue;
|
||||
const name = (l.normalizedName ?? l.rawText) as string | undefined;
|
||||
if (!name) continue;
|
||||
const unit = (l.unit as string | null) ?? null;
|
||||
const key = `${String(name).toLowerCase().trim()}|${unit ?? ""}`;
|
||||
// En kvittorad är minst 1 exemplar även om antal saknas.
|
||||
const qty = typeof l.quantity === "number" && l.quantity > 0 ? l.quantity : 1;
|
||||
const lineGross =
|
||||
typeof l.totalPriceMinor === "number"
|
||||
? l.totalPriceMinor
|
||||
: typeof l.unitPriceMinor === "number"
|
||||
? Math.round(l.unitPriceMinor * qty)
|
||||
: null;
|
||||
const cur = mergedItems.get(key);
|
||||
if (cur) {
|
||||
cur.quantity += qty;
|
||||
cur.gross =
|
||||
cur.gross == null && lineGross == null ? null : (cur.gross ?? 0) + (lineGross ?? 0);
|
||||
} else {
|
||||
mergedItems.set(key, {
|
||||
detectedName: String(name),
|
||||
canonicalIngredientId: (l.canonicalIngredientId as string | null) ?? null,
|
||||
brand: null,
|
||||
estimatedQuantity: qty,
|
||||
unit: (l.unit as string | null) ?? null,
|
||||
bestBeforeDate: null,
|
||||
unit,
|
||||
quantity: qty,
|
||||
gross: lineGross,
|
||||
confidence: typeof l.confidence === "number" ? l.confidence : 0.5,
|
||||
requiresConfirmation: true,
|
||||
priceMinor, // kvittots (netto)pris i ören, förs vidare till lagret vid bekräftelse
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
(output as Record<string, unknown>).items = [...mergedItems.values()].map((m, idx) => {
|
||||
const disc = discountByName.get(m.detectedName.toLowerCase()) ?? 0;
|
||||
const priceMinor = m.gross != null ? Math.max(0, m.gross + disc) : null;
|
||||
return {
|
||||
tempId: String(idx),
|
||||
detectedName: m.detectedName,
|
||||
canonicalIngredientId: m.canonicalIngredientId,
|
||||
brand: null,
|
||||
estimatedQuantity: m.quantity,
|
||||
unit: m.unit,
|
||||
bestBeforeDate: null,
|
||||
confidence: m.confidence,
|
||||
requiresConfirmation: true,
|
||||
priceMinor, // kvittots (netto)pris i ören, förs vidare till lagret vid bekräftelse
|
||||
};
|
||||
});
|
||||
output = await mapDetectedItemsToCanonical(ctx, output);
|
||||
}
|
||||
|
||||
@@ -142,6 +173,8 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
|
||||
carbsG: v.carbsG ?? 0,
|
||||
fatG: v.fatG ?? 0,
|
||||
saturatedFatG: v.saturatedFatG ?? 0,
|
||||
monounsaturatedFatG: v.monounsaturatedFatG ?? undefined,
|
||||
polyunsaturatedFatG: v.polyunsaturatedFatG ?? undefined,
|
||||
fiberG: v.fiberG ?? 0,
|
||||
sugarG: v.sugarG ?? 0,
|
||||
saltG: v.saltG ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user