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:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user