106 lines
3.4 KiB
TypeScript
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.',
|
|
};
|
|
}
|