60 lines
1.8 KiB
TypeScript
60 lines
1.8 KiB
TypeScript
import type { Unit } from "@app/shared-types";
|
||
import { toGrams, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||
|
||
export interface CostableIngredient {
|
||
canonicalIngredientId: string;
|
||
quantity: number;
|
||
unit: Unit;
|
||
optional: boolean;
|
||
}
|
||
|
||
export interface PriceInfo extends IngredientUnitInfo {
|
||
/** SEK per kg – från kvittohistorik, användardata eller standardvärde (spec §26). */
|
||
pricePerKgMinor: number;
|
||
source: "receipt_history" | "user" | "default";
|
||
}
|
||
|
||
export interface CostEstimate {
|
||
totalMinor: number | null;
|
||
perPortionMinor: number | null;
|
||
/** Andel av ingredienserna (viktat) som hade prisdata. */
|
||
priceCoverage: number;
|
||
sourcesUsed: PriceInfo["source"][];
|
||
}
|
||
|
||
/** Kostnadsuppskattning per portion – alltid märkt som uppskattning. */
|
||
export function estimateCost(
|
||
ingredients: CostableIngredient[],
|
||
portions: number,
|
||
prices: Map<string, PriceInfo>,
|
||
): CostEstimate {
|
||
let total = 0;
|
||
let pricedCount = 0;
|
||
let mandatoryCount = 0;
|
||
const sources = new Set<PriceInfo["source"]>();
|
||
|
||
for (const ing of ingredients) {
|
||
if (ing.optional) continue;
|
||
mandatoryCount += 1;
|
||
const price = prices.get(ing.canonicalIngredientId);
|
||
if (!price) continue;
|
||
const grams = toGrams(ing.quantity, ing.unit, price);
|
||
if (grams == null) continue;
|
||
total += (grams / 1000) * price.pricePerKgMinor;
|
||
pricedCount += 1;
|
||
sources.add(price.source);
|
||
}
|
||
|
||
if (pricedCount === 0 || mandatoryCount === 0) {
|
||
return { totalMinor: null, perPortionMinor: null, priceCoverage: 0, sourcesUsed: [] };
|
||
}
|
||
|
||
const coverage = pricedCount / mandatoryCount;
|
||
return {
|
||
totalMinor: Math.round(total),
|
||
perPortionMinor: portions > 0 ? Math.round(total / portions) : null,
|
||
priceCoverage: Math.round(coverage * 100) / 100,
|
||
sourcesUsed: [...sources],
|
||
};
|
||
}
|