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:
Claude
2026-08-20 14:37:45 +00:00
parent cac98b8dc7
commit d75500bebd
17 changed files with 127 additions and 27 deletions
+58 -25
View File
@@ -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,