almeira-web/sneaky-app/src/lib/deduplication-engine.ts

106 lines
3.4 KiB
TypeScript

import { Product } from '@/types';
/**
* Normaliza un string para comparación (minúsculas, sin acentos, sin caracteres especiales)
*/
export function normalizeText(text: string): string {
if (!text) return '';
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Calcula la similitud de Jaccard / Coeficiente de Dados entre dos textos
* Devuelve un valor entre 0 (totalmente diferente) y 1.0 (idéntico)
*/
export function calculateTextSimilarity(textA: string, textB: string): number {
const normA = normalizeText(textA);
const normB = normalizeText(textB);
if (!normA || !normB) return 0;
if (normA === normB) return 1.0;
// Si uno contiene al otro por completo
if (normA.includes(normB) || normB.includes(normA)) {
const ratio = Math.min(normA.length, normB.length) / Math.max(normA.length, normB.length);
if (ratio > 0.7) return 0.92;
}
const tokensA = new Set(normA.split(' ').filter((w) => w.length > 2));
const tokensB = new Set(normB.split(' ').filter((w) => w.length > 2));
if (tokensA.size === 0 || tokensB.size === 0) return 0;
const intersection = new Set([...tokensA].filter((t) => tokensB.has(t)));
const union = new Set([...tokensA, ...tokensB]);
return intersection.size / union.size;
}
export interface DuplicateCheckResult {
isDuplicate: boolean;
similarityScore: number;
existingProduct?: Product;
recommendation: 'create_new' | 'update_stock_and_sizes' | 'skip_duplicate';
message: string;
}
/**
* Verifica si un producto entrante ya existe en el catálogo para evitar duplicados
*/
export function checkProductDuplicate(
incoming: {
name: string;
brand: string;
sizes: string[];
supplierPrice: number;
},
existingCatalog: Product[],
threshold = 0.75
): DuplicateCheckResult {
let highestScore = 0;
let bestMatch: Product | undefined;
for (const product of existingCatalog) {
// 1. Si la marca no coincide, penalizar
const brandMatch = normalizeText(incoming.brand) === normalizeText(product.brand);
const score = calculateTextSimilarity(incoming.name, product.name);
const finalScore = brandMatch ? score : score * 0.5;
if (finalScore > highestScore) {
highestScore = finalScore;
bestMatch = product;
}
}
// Si la similitud supera el umbral (75%)
if (highestScore >= threshold && bestMatch) {
// Verificar si aporta nuevas tallas
const incomingSizesSet = new Set(incoming.sizes);
const existingSizesSet = new Set(bestMatch.sizes);
const hasNewSizes = incoming.sizes.some((s) => !existingSizesSet.has(s));
return {
isDuplicate: true,
similarityScore: Math.round(highestScore * 100) / 100,
existingProduct: bestMatch,
recommendation: hasNewSizes ? 'update_stock_and_sizes' : 'skip_duplicate',
message: hasNewSizes
? `Producto ya registrado ("${bestMatch.name}"). Se detectaron nuevas tallas para actualizar.`
: `Producto ya registrado ("${bestMatch.name}") con similitud del ${Math.round(highestScore * 100)}%. Se previene duplicación en la tienda y Facebook.`,
};
}
return {
isDuplicate: false,
similarityScore: Math.round(highestScore * 100) / 100,
recommendation: 'create_new',
message: 'Producto nuevo verificado sin duplicados. Listo para publicar.',
};
}