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,
|
||||
|
||||
Reference in New Issue
Block a user