110 lines
2.8 KiB
TypeScript
110 lines
2.8 KiB
TypeScript
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;
|
||
}
|