feat(priser): kvittopris -> lager + blandat marknadspris per ingrediens

Bekraftade kvittovaror far sitt netto-pris (efter rabatt) i lagret. Prisupp-
skattningar anvander nu ett robust marknadssnitt: median av observerade pris/kg
fran lagret, klampat mot schablonen och viktat efter antal observationer -> ett
'realtidsnara' pris dar en extremt dyr/billig butik inte drar ivag snittet.
This commit is contained in:
Claude
2026-08-19 20:18:01 +00:00
parent c13edae2fa
commit 15e48c8704
3 changed files with 102 additions and 21 deletions
+34 -11
View File
@@ -74,19 +74,42 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
// till items och kör samma kanoniska namnmatchning som för foton.
if (job.jobType === "READ_RECEIPT" && Array.isArray((output as { lines?: unknown }).lines)) {
const lines = (output as { lines: Array<Record<string, unknown>> }).lines;
// Nettopris per vara: dra av matchande rabattrader (matchas på normalizedName)
// så lagret får det pris användaren faktiskt betalade, inte listpriset.
const discountByName = new Map<string, number>();
for (const l of lines) {
if (l.isDiscount === true && l.normalizedName && typeof l.totalPriceMinor === "number") {
const k = String(l.normalizedName).toLowerCase();
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) => ({
tempId: String(idx),
detectedName: String(l.normalizedName ?? l.rawText ?? ""),
canonicalIngredientId: (l.canonicalIngredientId as string | null) ?? null,
brand: null,
estimatedQuantity: typeof l.quantity === "number" ? l.quantity : null,
unit: (l.unit as string | null) ?? null,
bestBeforeDate: null,
confidence: typeof l.confidence === "number" ? l.confidence : 0.5,
requiresConfirmation: true,
}));
.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 ?? ""),
canonicalIngredientId: (l.canonicalIngredientId as string | null) ?? null,
brand: null,
estimatedQuantity: qty,
unit: (l.unit as string | null) ?? null,
bestBeforeDate: null,
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 = await mapDetectedItemsToCanonical(ctx, output);
}