export interface PreviousScanItem { id: string; displayName: string; quantity: number; unit: string; storageLocationId: string; bestBeforeDate?: string | null; useByDate?: string | null; } export interface NewScanObservation { displayName: string; quantity: number; unit: string; storageLocationId: string; bestBeforeDate?: string | null; useByDate?: string | null; /** Konfidens att denna observation verkligen representerar en fysisk vara. */ observationConfidence: number; } export type ScanDiffRowKind = "new_item" | "moved" | "quantity_changed" | "vanished" | "unchanged"; export interface ScanDiffRow { kind: ScanDiffRowKind; previousItemId?: string; displayName: string; unit: string; previousQuantity?: number; newQuantity?: number; previousLocationId?: string; newLocationId?: string; /** Konfidens för slutsatsen (0–1). */ confidence: number; /** Mänsklig läsbar motivering. */ reason: string; } export interface ScanDiffResult { rows: ScanDiffRow[]; /** Identifierare för varor som är försvunna men ännu inte bekräftade borta. */ pendingVanishedIds: string[]; } /** * Jämför tidigare skannade varor med nya observationer. * * Regler (§5.5): * - "new_item": ny observation som inte matchar någon tidigare vara. * - "moved": samma vara, annan plats. * - "quantity_changed": samma vara, annan mängd. * - "vanished": tidigare vara saknas bland observationerna. * - "unchanged": inget avvikande. * * ALDRIG automatiska negativa transaktioner för "vanished" — det är endast * ett förslag som kräver användarbekräftelse. */ export function diffScans( previousItems: PreviousScanItem[], newObservations: NewScanObservation[], now: Date = new Date(), ): ScanDiffResult { const rows: ScanDiffRow[] = []; const matchedPreviousIds = new Set(); const matchedObservationIndexes = new Set(); // 1. Matcha observationer mot tidigare varor (namn + plats + datummer nära) for (let i = 0; i < newObservations.length; i++) { const obs = newObservations[i]!; let bestMatch: { item: PreviousScanItem; score: number } | null = null; for (const item of previousItems) { if (matchedPreviousIds.has(item.id)) continue; const nameScore = nameSimilarity(item.displayName, obs.displayName); if (nameScore < 0.6) continue; let score = nameScore; if (item.storageLocationId === obs.storageLocationId) score += 0.2; if (datesEqual(item.bestBeforeDate, obs.bestBeforeDate)) score += 0.1; if (datesEqual(item.useByDate, obs.useByDate)) score += 0.1; if (!bestMatch || score > bestMatch.score) { bestMatch = { item, score }; } } if (bestMatch && bestMatch.score >= 0.7) { matchedPreviousIds.add(bestMatch.item.id); matchedObservationIndexes.add(i); const item = bestMatch.item; const locationChanged = item.storageLocationId !== obs.storageLocationId; const quantityChanged = Math.abs(item.quantity - obs.quantity) > 1e-6; if (locationChanged && quantityChanged) { rows.push({ kind: "moved", previousItemId: item.id, displayName: obs.displayName, unit: obs.unit, previousQuantity: item.quantity, newQuantity: obs.quantity, previousLocationId: item.storageLocationId, newLocationId: obs.storageLocationId, confidence: round2(bestMatch.score * obs.observationConfidence), reason: "Flyttad och ändrad mängd", }); } else if (locationChanged) { rows.push({ kind: "moved", previousItemId: item.id, displayName: obs.displayName, unit: obs.unit, previousQuantity: item.quantity, newQuantity: obs.quantity, previousLocationId: item.storageLocationId, newLocationId: obs.storageLocationId, confidence: round2(bestMatch.score * obs.observationConfidence), reason: "Flyttad till annan plats", }); } else if (quantityChanged) { rows.push({ kind: "quantity_changed", previousItemId: item.id, displayName: obs.displayName, unit: obs.unit, previousQuantity: item.quantity, newQuantity: obs.quantity, previousLocationId: item.storageLocationId, newLocationId: obs.storageLocationId, confidence: round2(bestMatch.score * obs.observationConfidence), reason: "Ändrad mängd", }); } else { rows.push({ kind: "unchanged", previousItemId: item.id, displayName: obs.displayName, unit: obs.unit, previousQuantity: item.quantity, newQuantity: obs.quantity, previousLocationId: item.storageLocationId, newLocationId: obs.storageLocationId, confidence: round2(bestMatch.score * obs.observationConfidence), reason: "Oförändrad", }); } } } // 2. Nya varor for (let i = 0; i < newObservations.length; i++) { if (matchedObservationIndexes.has(i)) continue; const obs = newObservations[i]!; rows.push({ kind: "new_item", displayName: obs.displayName, unit: obs.unit, newQuantity: obs.quantity, newLocationId: obs.storageLocationId, confidence: round2(obs.observationConfidence), reason: "Ny vara på bilden", }); } // 3. Försvunna varor — bara förslag, aldrig automatisk nollning const pendingVanishedIds: string[] = []; for (const item of previousItems) { if (matchedPreviousIds.has(item.id)) continue; rows.push({ kind: "vanished", previousItemId: item.id, displayName: item.displayName, unit: item.unit, previousQuantity: item.quantity, previousLocationId: item.storageLocationId, confidence: 0.6, reason: "Saknas på nya bilden — bekräfta att den är slut", }); pendingVanishedIds.push(item.id); } // Sortering: förändringar först, sedan nya, oförändrade sist const kindOrder: Record = { quantity_changed: 0, moved: 1, vanished: 2, new_item: 3, unchanged: 4, }; rows.sort((a, b) => { if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind]; return a.displayName.localeCompare(b.displayName, "sv"); }); return { rows, pendingVanishedIds }; } function nameSimilarity(a: string, b: string): number { const aa = a.toLowerCase().trim(); const bb = b.toLowerCase().trim(); if (aa === bb) return 1; if (aa.includes(bb) || bb.includes(aa)) return 0.85; const dist = levenshtein(aa, bb); const maxLen = Math.max(aa.length, bb.length); if (maxLen === 0) return 1; return Math.max(0, 1 - dist / maxLen); } function levenshtein(a: string, b: string): number { const m = a.length; const n = b.length; if (m === 0) return n; if (n === 0) return m; const prev: number[] = new Array(n + 1).fill(0).map((_, i) => i); for (let i = 1; i <= m; i++) { let curr0 = i; for (let j = 1; j <= n; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr0 = Math.min( prev[j]! + 1, // deletion curr0 + 1, // insertion prev[j - 1]! + cost, // substitution ); prev[j - 1] = curr0; } prev[n] = i; } return prev[n]!; } function datesEqual(a: string | null | undefined, b: string | null | undefined): boolean { if (!a && !b) return true; if (!a || !b) return false; return a === b; } function round2(n: number): number { return Math.round(Math.max(0, Math.min(1, n)) * 100) / 100; }