Files
Cibello-app/packages/inventory-engine/src/dedup.ts
T
2026-08-05 19:21:11 +07:00

110 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}