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
+4 -2
View File
@@ -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,
}));
}
+64 -8
View File
@@ -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<string, StoreSection> = {
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<number | null> {
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,
+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);
}