From 15e48c8704c34cb52f7f9f83880965b21a79281e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:18:01 +0000 Subject: [PATCH] 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. --- apps/api/src/routes/scans.ts | 6 ++- apps/api/src/routes/shopping.ts | 72 +++++++++++++++++++++++++---- apps/worker/src/processors/scans.ts | 45 +++++++++++++----- 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts index ec6d28f..90897aa 100644 --- a/apps/api/src/routes/scans.ts +++ b/apps/api/src/routes/scans.ts @@ -212,7 +212,7 @@ export async function scanRoutes(app: FastifyInstance) { sublocation: item.sublocation ?? null, bestBeforeDate: item.bestBeforeDate ?? null, useByDate: item.useByDate ?? null, - priceMinor: item.priceMinor ?? null, + priceMinor: item.priceMinor ?? proposal?.priceMinor ?? null, purchasedAt: new Date().toISOString().slice(0, 10), source: scanSource(job.scanType), confidence: item.action === "accept" ? 0.9 : 1, @@ -242,7 +242,7 @@ export async function scanRoutes(app: FastifyInstance) { refType: "scan", refId: job.id, actorUserId: req.userId, - valueMinor: item.priceMinor ?? null, + valueMinor: item.priceMinor ?? proposal?.priceMinor ?? null, }); await emitEvent(app.db, { type: "PRODUCT_ADDED", @@ -486,6 +486,7 @@ type ProposalItem = { bestBeforeDate?: string | null; confidence?: number; requiresConfirmation?: boolean; + priceMinor?: number | null; }; function extractProposals(job: { result: unknown }): ProposalItem[] { @@ -502,6 +503,7 @@ function extractProposals(job: { result: unknown }): ProposalItem[] { confidence: typeof it.confidence === "number" ? it.confidence : null, requiresConfirmation: typeof it.requiresConfirmation === "boolean" ? it.requiresConfirmation : null, + priceMinor: typeof it.priceMinor === "number" ? it.priceMinor : null, })); } diff --git a/apps/api/src/routes/shopping.ts b/apps/api/src/routes/shopping.ts index e3d9834..2b72c7b 100644 --- a/apps/api/src/routes/shopping.ts +++ b/apps/api/src/routes/shopping.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { schema } from "@app/database"; import type { StoreSection } from "@app/shared-types"; import { @@ -32,6 +32,57 @@ const CATEGORY_TO_SECTION: Record = { brod: "brod", }; +/** + * Marknadspris per kg: blandar observerade kvittopriser (median av pris/kg från + * lagerposter med satt pris) med katalogens schablon. Robust mot extremer – + * medianen klampas till [0,2x, 5x] av schablonen, och ju fler observationer + * desto mer vikt åt verkligheten. Ger ett "realtidsnära" pris utan att en enda + * dyr eller billig butik drar iväg snittet (spec: kvittopris → prisuppskattning). + */ +async function marketPriceMinorPerKg( + app: FastifyInstance, + ingredientId: string, + schablonPerKg: number | null, + info: { densityGPerMl?: number | null; gramsPerPiece?: number | null }, +): Promise { + const rows = await app.db + .select({ + priceMinor: schema.inventoryItems.priceMinor, + quantity: schema.inventoryItems.quantity, + unit: schema.inventoryItems.unit, + }) + .from(schema.inventoryItems) + .where( + and( + eq(schema.inventoryItems.canonicalIngredientId, ingredientId), + gt(schema.inventoryItems.priceMinor, 0), + gt(schema.inventoryItems.quantity, 0), + ), + ) + .orderBy(desc(schema.inventoryItems.purchasedAt)) + .limit(50); + + const perKg: number[] = []; + for (const r of rows) { + if (r.priceMinor == null) continue; + const grams = convert(r.quantity, r.unit, "GRAM", info); + if (grams == null || grams <= 0) continue; + perKg.push(r.priceMinor / (grams / 1000)); + } + if (perKg.length === 0) return schablonPerKg; + + perKg.sort((a, b) => a - b); + const median = perKg[Math.floor(perKg.length / 2)]!; + if (schablonPerKg == null || schablonPerKg <= 0) return Math.round(median); + + // Klampa medianen mot schablonen så vilda avläsningar inte skenar, och vikta + // in verkligheten mer ju fler observationer som finns. + const clamped = Math.min(Math.max(median, schablonPerKg * 0.2), schablonPerKg * 5); + const n = Math.min(perKg.length, 10); + const weight = n / (n + 3); + return Math.round(weight * clamped + (1 - weight) * schablonPerKg); +} + export async function shoppingRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; @@ -105,13 +156,17 @@ export async function shoppingRoutes(app: FastifyInstance) { .limit(1); if (ing) { section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt"; - if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) { + if (estimatedPrice == null) { + const perKg = await marketPriceMinorPerKg(app, ing.id, ing.defaultPriceMinorPerKg, { + densityGPerMl: ing.densityGPerMl, + gramsPerPiece: ing.gramsPerPiece, + }); const grams = convert(input.quantity, input.unit, "GRAM", { densityGPerMl: ing.densityGPerMl, gramsPerPiece: ing.gramsPerPiece, }); - if (grams != null) - estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10; + if (perKg != null && grams != null) + estimatedPrice = Math.round((grams / 1000) * perKg * 10) / 10; } } } @@ -418,10 +473,11 @@ async function generateItemsFromPlan( gramsPerPiece: info?.gramsPerPiece, }) ?? need.grams; const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10; - const estimatedPrice = - info?.defaultPriceMinorPerKg != null - ? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg) - : null; + const perKg = await marketPriceMinorPerKg(app, ingredientId, info?.defaultPriceMinorPerKg ?? null, { + densityGPerMl: info?.densityGPerMl, + gramsPerPiece: info?.gramsPerPiece, + }); + const estimatedPrice = perKg != null ? Math.round((need.grams / 1000) * perKg) : null; await app.db.insert(schema.shoppingListItems).values({ shoppingListId: listId, diff --git a/apps/worker/src/processors/scans.ts b/apps/worker/src/processors/scans.ts index 78b024a..d6a6bd3 100644 --- a/apps/worker/src/processors/scans.ts +++ b/apps/worker/src/processors/scans.ts @@ -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> }).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(); + 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).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); }