import type { InventorySource } from "@app/shared-types"; export interface DedupCandidateInput { canonicalIngredientId?: string | null; displayName: string; brand?: string | null; quantity: number; source: InventorySource; createdAt: string; } export interface DedupExistingItem extends DedupCandidateInput { id: string; } export interface DedupCandidate { itemId: string; score: number; reasons: string[]; } const WINDOW_MS = 3 * 86_400_000; // 3 dygn /** * Dubblettkandidater mellan kvitto, streckkod och bilder (spec §9). * Ren heuristik – användaren fattar alltid beslutet. AI-baserad * dedup (DEDUPLICATE_INVENTORY-jobbet) kan förfina men aldrig auto-slå ihop * utan bekräftelse. */ export function findDuplicateCandidates( incoming: DedupCandidateInput, existing: DedupExistingItem[], ): DedupCandidate[] { const incomingTime = Date.parse(incoming.createdAt); const results: DedupCandidate[] = []; for (const item of existing) { const reasons: string[] = []; let score = 0; const sameCanonical = incoming.canonicalIngredientId != null && incoming.canonicalIngredientId === item.canonicalIngredientId; const nameMatch = normalizedEquals(incoming.displayName, item.displayName); if (sameCanonical) { score += 0.45; reasons.push("samma ingrediens"); } else if (nameMatch) { score += 0.3; reasons.push("liknande namn"); } else { continue; } const dt = Math.abs(Date.parse(item.createdAt) - incomingTime); if (dt <= WINDOW_MS) { score += 0.25; reasons.push("registrerad inom 3 dygn"); } if (incoming.source !== item.source) { score += 0.15; reasons.push(`olika källor (${incoming.source} + ${item.source})`); } const qtyRatio = item.quantity > 0 && incoming.quantity > 0 ? Math.min(incoming.quantity, item.quantity) / Math.max(incoming.quantity, item.quantity) : 0; if (qtyRatio >= 0.7) { score += 0.15; reasons.push("liknande mängd"); } if ( incoming.brand && item.brand && incoming.brand.toLowerCase().trim() === item.brand.toLowerCase().trim() ) { score += 0.1; reasons.push("samma varumärke"); } if (score >= 0.5) { results.push({ itemId: item.id, score: Math.min(1, round2(score)), reasons }); } } return results.sort((a, b) => b.score - a.score); } function normalizedEquals(a: string, b: string): boolean { const na = normalize(a); const nb = normalize(b); if (na === nb) return true; return na.length > 3 && nb.length > 3 && (na.includes(nb) || nb.includes(na)); } function normalize(s: string): string { return s .toLowerCase() .replace(/[^a-zåäö0-9 ]/gi, "") .replace(/\s+/g, " ") .trim(); } function round2(v: number): number { return Math.round(v * 100) / 100; }