import { type InventoryItemLike, itemTrustState, TRUST_STATUS_THRESHOLDS } from "./trust.js"; export interface ReconciliationCandidate { itemId: string; displayName: string; quantity: number; unit: string; locationName: string; /** Prioritet: lägre = viktigare. */ priority: number; reasons: ReconciliationReason[]; /** Förslag baserat på planerade recept, förbrukningstakt etc. */ suggestedAction: "exists" | "depleted" | "uncertain"; suggestedQuantity?: number; } export type ReconciliationReason = | { kind: "planned_recipe"; recipeIds: string[] } | { kind: "expiring_soon"; daysLeft: number | null } | { kind: "high_value"; priceMinor: number } | { kind: "low_confidence"; confidence: number } | { kind: "stale_trust"; trustState: string } | { kind: "likely_depleted"; remainingDays: number }; export interface ReconciliationContext { /** Ingredienser som behövs i planerade recept under närmaste 7 dagarna. */ plannedRecipeIngredientIds: Map; /** Utgångsdatum per vara-id. */ daysLeftByItemId: Map; /** Estimerad förbrukning per dygn per vara (enkel heuristik). */ dailyConsumptionRate: Map; /** Pris i minor units per vara. */ priceMinorByItemId: Map; } const MS_PER_DAY = 86_400_000; /** * Bygg en prioriterad lista över varor som behöver en snabb avstämning. * * Prioritet (lägre = viktigare, §5.4): * 1. Behövs i planerade recept (inom 7 dagar) * 2. Snart utgår * 3. Högt ekonomiskt värde * 4. Låg confidence / stale trust * 5. Borde vara slut enligt förbrukningstakt * * Deterministisk, UTC, alltid samma svar för samma input. */ export function buildReconciliationCandidates( items: Array, context: ReconciliationContext, now: Date = new Date(), maxItems = 15, ): ReconciliationCandidate[] { const candidates: ReconciliationCandidate[] = []; for (const item of items) { if ((item.quantity <= 0 && item.depletedAt) || item.depletedAt) continue; const reasons: ReconciliationReason[] = []; let priorityPenalty = 0; // 1. Planerade recept const recipeIds = context.plannedRecipeIngredientIds.get(item.id) ?? []; if (recipeIds.length > 0) { reasons.push({ kind: "planned_recipe", recipeIds }); priorityPenalty += 10_000; } // 2. Snart utgår const daysLeft = context.daysLeftByItemId.get(item.id) ?? null; if (daysLeft != null && daysLeft <= 7) { reasons.push({ kind: "expiring_soon", daysLeft }); priorityPenalty += 8_000 - Math.min(7, Math.max(0, daysLeft)) * 1_000; } // 3. Ekonomiskt värde const priceMinor = context.priceMinorByItemId.get(item.id) ?? 0; if (priceMinor > 0) { reasons.push({ kind: "high_value", priceMinor }); priorityPenalty += Math.min(5_000, Math.floor(priceMinor / 10)); } // 4. Låg confidence / stale trust if (item.confidence < 0.6) { reasons.push({ kind: "low_confidence", confidence: item.confidence }); priorityPenalty += 3_000; } const trust = itemTrustState(item, now, undefined); if (trust === "stale" || trust === "decaying") { reasons.push({ kind: "stale_trust", trustState: trust }); priorityPenalty += trust === "stale" ? 4_000 : 2_000; } // 5. Borde vara slut enligt förbrukningstakt const dailyRate = context.dailyConsumptionRate.get(item.id) ?? 0; if (dailyRate > 0 && item.quantity > 0) { const remainingDays = item.quantity / dailyRate; if (remainingDays < 3) { reasons.push({ kind: "likely_depleted", remainingDays }); priorityPenalty += 2_500 - Math.min(2, Math.floor(remainingDays)) * 800; } } if (reasons.length === 0) continue; // Förslag let suggestedAction: ReconciliationCandidate["suggestedAction"] = "uncertain"; let suggestedQuantity: number | undefined; if (dailyRate > 0 && item.quantity > 0) { const remainingDays = item.quantity / dailyRate; if (remainingDays < 0.5) { suggestedAction = "depleted"; } else { suggestedQuantity = Math.max(0, Math.round(item.quantity * 10) / 10); suggestedAction = "exists"; } } else if (trust === "trusted") { suggestedAction = "exists"; } candidates.push({ itemId: item.id, displayName: item.displayName, quantity: item.quantity, unit: item.unit, locationName: item.locationName, priority: -priorityPenalty, reasons, suggestedAction, suggestedQuantity, }); } candidates.sort((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority; return a.displayName.localeCompare(b.displayName, "sv"); }); return candidates.slice(0, maxItems); }