Enhanced product matching algorithm to properly handle: - Large vs Small size variants for shelf decor products - Improved size extraction from product titles - Higher confidence thresholds for shelf decor products to prevent wrong matches - Better handling of 3D-Printed product variations - Enhanced logging for debugging matching issues This fixes the issue where packing slips would create duplicate products instead of matching to existing Large/Small variants of the same product.
354 lines
No EOL
12 KiB
TypeScript
354 lines
No EOL
12 KiB
TypeScript
// Utility functions for matching products between orders and inventory
|
||
|
||
export interface ProductMatch {
|
||
orderItem: {
|
||
title: string;
|
||
quantity: number;
|
||
price?: number;
|
||
};
|
||
matchedProduct?: {
|
||
_id: string;
|
||
title: string;
|
||
printingCost: number;
|
||
costOfGoods: number;
|
||
size?: string;
|
||
};
|
||
confidence: number; // 0-1, how confident we are in the match
|
||
}
|
||
|
||
export interface MatchingResult {
|
||
matches: ProductMatch[];
|
||
missingProducts: {
|
||
title: string;
|
||
quantity: number;
|
||
price?: number;
|
||
size?: string;
|
||
}[];
|
||
}
|
||
|
||
/**
|
||
* Match order items against existing products in the database
|
||
* Considers size as a factor but ignores color variations
|
||
* Enhanced for Modern Minimalist Shelf Decor products with Large/Small variants
|
||
*/
|
||
export const matchOrderItemsToProducts = (
|
||
orderItems: any[],
|
||
products: any[]
|
||
): MatchingResult => {
|
||
const matches: ProductMatch[] = [];
|
||
const missingProducts: any[] = [];
|
||
|
||
for (const item of orderItems) {
|
||
let bestMatch: any = null;
|
||
let bestConfidence = 0;
|
||
|
||
// Enhanced size extraction - look for "Large" or "Small" specifically
|
||
const itemTitle = item.title.toLowerCase();
|
||
let itemSize = '';
|
||
|
||
// Check for Large/Small in various patterns
|
||
if (itemTitle.includes('large') && !itemTitle.includes('small')) {
|
||
itemSize = 'large';
|
||
} else if (itemTitle.includes('small') && !itemTitle.includes('large')) {
|
||
itemSize = 'small';
|
||
}
|
||
|
||
// Also check for "Size: Large" pattern
|
||
const sizeMatch = item.title.match(/Size:\s*(Large|Small|Med|Medium)/i);
|
||
if (sizeMatch) {
|
||
itemSize = sizeMatch[1].toLowerCase();
|
||
if (itemSize === 'med' || itemSize === 'medium') itemSize = 'med';
|
||
}
|
||
|
||
// Clean item title for comparison (remove color and size info for base matching)
|
||
const cleanItemTitle = item.title
|
||
.replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '') // Remove color
|
||
.replace(/Size:\s*[^,\s-]+/i, '') // Remove explicit size
|
||
.replace(/3D-Printed/i, '') // Remove 3D-Printed as it's common
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
console.log('🔍 Matching item:', item.title);
|
||
console.log(' Clean title:', cleanItemTitle);
|
||
console.log(' Extracted size:', itemSize);
|
||
|
||
for (const product of products) {
|
||
const productTitle = product.title.toLowerCase();
|
||
let productSize = '';
|
||
|
||
// Extract size from product title
|
||
if (productTitle.includes('large') && !productTitle.includes('small')) {
|
||
productSize = 'large';
|
||
} else if (productTitle.includes('small') && !productTitle.includes('large')) {
|
||
productSize = 'small';
|
||
} else if (productTitle.includes('med')) {
|
||
productSize = 'med';
|
||
}
|
||
|
||
// Clean product title for comparison
|
||
const cleanProductTitle = product.title
|
||
.replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '')
|
||
.replace(/Size:\s*[^,\s-]+/i, '')
|
||
.replace(/3D-Printed/i, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
// Calculate title similarity (basic word matching)
|
||
const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle);
|
||
|
||
// Size matching - this is crucial for your shelf decor products
|
||
let sizeSimilarity = 0;
|
||
if (itemSize && productSize) {
|
||
sizeSimilarity = itemSize === productSize ? 1 : 0;
|
||
console.log(` Size match: ${itemSize} vs ${productSize} = ${sizeSimilarity}`);
|
||
} else if (!itemSize && !productSize) {
|
||
sizeSimilarity = 0.7; // Both have no size specified
|
||
} else if (itemSize && !productSize) {
|
||
// Item has size but product doesn't - penalize slightly
|
||
sizeSimilarity = 0.3;
|
||
} else {
|
||
// Product has size but item doesn't - penalize more
|
||
sizeSimilarity = 0.1;
|
||
}
|
||
|
||
// For shelf decor products, size is very important
|
||
const isShelfDecor = cleanItemTitle.includes('shelf decor') || cleanProductTitle.includes('shelf decor');
|
||
let confidence;
|
||
|
||
if (isShelfDecor && itemSize && productSize) {
|
||
// For shelf decor with both sizes specified, size match is critical
|
||
confidence = (titleSimilarity * 0.6) + (sizeSimilarity * 0.4);
|
||
} else if (isShelfDecor) {
|
||
// For shelf decor without clear size info, rely more on title
|
||
confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2);
|
||
} else {
|
||
// For other products, standard weighting
|
||
confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2);
|
||
}
|
||
|
||
console.log(` Product: ${product.title}`);
|
||
console.log(` Clean: ${cleanProductTitle}`);
|
||
console.log(` Product size: ${productSize}`);
|
||
console.log(` Title similarity: ${titleSimilarity.toFixed(3)}`);
|
||
console.log(` Size similarity: ${sizeSimilarity}`);
|
||
console.log(` Final confidence: ${confidence.toFixed(3)}`);
|
||
|
||
// Require higher confidence for shelf decor products to avoid wrong matches
|
||
const minConfidence = isShelfDecor ? 0.7 : 0.5;
|
||
|
||
if (confidence > bestConfidence && confidence >= minConfidence) {
|
||
bestMatch = product;
|
||
bestConfidence = confidence;
|
||
}
|
||
}
|
||
|
||
if (bestMatch) {
|
||
matches.push({
|
||
orderItem: {
|
||
title: item.title,
|
||
quantity: item.quantity,
|
||
price: item.price
|
||
},
|
||
matchedProduct: {
|
||
_id: bestMatch._id,
|
||
title: bestMatch.title,
|
||
printingCost: bestMatch.printingCost || 0,
|
||
costOfGoods: bestMatch.costOfGoods || 0,
|
||
size: bestMatch.size
|
||
},
|
||
confidence: bestConfidence
|
||
});
|
||
console.log(`✅ Matched "${item.title}" to "${bestMatch.title}" (${(bestConfidence * 100).toFixed(1)}%)`);
|
||
} else {
|
||
missingProducts.push({
|
||
title: item.title,
|
||
quantity: item.quantity,
|
||
...(item.price !== undefined && { price: item.price }),
|
||
size: itemSize
|
||
});
|
||
console.log(`❌ No match found for "${item.title}"`);
|
||
}
|
||
}
|
||
|
||
return { matches, missingProducts };
|
||
};
|
||
|
||
/**
|
||
* Calculate similarity between two product titles
|
||
* Enhanced for Modern Minimalist Shelf Decor and similar products
|
||
*/
|
||
const calculateTitleSimilarity = (title1: string, title2: string): number => {
|
||
// Split into words and filter out common stop words
|
||
const stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', '-', '–', '3d', 'printed'];
|
||
|
||
const words1 = title1.split(/\s+/).filter(word =>
|
||
word.length > 2 && !stopWords.includes(word.toLowerCase())
|
||
);
|
||
const words2 = title2.split(/\s+/).filter(word =>
|
||
word.length > 2 && !stopWords.includes(word.toLowerCase())
|
||
);
|
||
|
||
if (words1.length === 0 || words2.length === 0) return 0;
|
||
|
||
// For shelf decor products, check for key identifying words
|
||
const isShelfDecor1 = title1.includes('shelf') || title1.includes('decor');
|
||
const isShelfDecor2 = title2.includes('shelf') || title2.includes('decor');
|
||
|
||
if (isShelfDecor1 && isShelfDecor2) {
|
||
// Special handling for shelf decor products
|
||
const keyWords = ['modern', 'minimalist', 'shelf', 'decor', 'water', 'reservoir', 'bookend', 'planter'];
|
||
let keyWordMatches = 0;
|
||
|
||
for (const keyWord of keyWords) {
|
||
const inTitle1 = title1.includes(keyWord);
|
||
const inTitle2 = title2.includes(keyWord);
|
||
if (inTitle1 && inTitle2) {
|
||
keyWordMatches++;
|
||
}
|
||
}
|
||
|
||
// If most key words match, it's likely the same product family
|
||
if (keyWordMatches >= 4) {
|
||
return 0.9; // High similarity for same product family
|
||
}
|
||
}
|
||
|
||
// Count matching words with fuzzy matching
|
||
let matchingWords = 0;
|
||
for (const word1 of words1) {
|
||
for (const word2 of words2) {
|
||
if (word1.toLowerCase() === word2.toLowerCase() ||
|
||
word1.toLowerCase().includes(word2.toLowerCase()) ||
|
||
word2.toLowerCase().includes(word1.toLowerCase()) ||
|
||
levenshteinDistance(word1.toLowerCase(), word2.toLowerCase()) <= Math.max(1, Math.min(word1.length, word2.length) * 0.2)) {
|
||
matchingWords++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Calculate similarity as ratio of matching words to total unique words
|
||
const totalUniqueWords = Math.max(words1.length, words2.length);
|
||
return matchingWords / totalUniqueWords;
|
||
};
|
||
|
||
/**
|
||
* Find potential matches for a missing product
|
||
* This helps suggest existing products that might be similar
|
||
*/
|
||
export const findPotentialMatches = (
|
||
missingProductTitle: string,
|
||
existingProducts: any[],
|
||
maxSuggestions: number = 5
|
||
): Array<{product: any; confidence: number; reason: string}> => {
|
||
const suggestions: Array<{product: any; confidence: number; reason: string}> = [];
|
||
|
||
const itemTitle = missingProductTitle.toLowerCase();
|
||
let itemSize = '';
|
||
|
||
// Extract size from missing product
|
||
if (itemTitle.includes('large') && !itemTitle.includes('small')) {
|
||
itemSize = 'large';
|
||
} else if (itemTitle.includes('small') && !itemTitle.includes('large')) {
|
||
itemSize = 'small';
|
||
}
|
||
|
||
const cleanItemTitle = missingProductTitle
|
||
.replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '')
|
||
.replace(/Size:\s*[^,\s-]+/i, '')
|
||
.replace(/3D-Printed/i, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
for (const product of existingProducts) {
|
||
const productTitle = product.title.toLowerCase();
|
||
let productSize = '';
|
||
|
||
if (productTitle.includes('large') && !productTitle.includes('small')) {
|
||
productSize = 'large';
|
||
} else if (productTitle.includes('small') && !productTitle.includes('large')) {
|
||
productSize = 'small';
|
||
}
|
||
|
||
const cleanProductTitle = product.title
|
||
.replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '')
|
||
.replace(/Size:\s*[^,\s-]+/i, '')
|
||
.replace(/3D-Printed/i, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle);
|
||
|
||
let reason = '';
|
||
let confidence = titleSimilarity;
|
||
|
||
// Check if it's the same product family but different size
|
||
if (titleSimilarity > 0.8) {
|
||
if (itemSize && productSize && itemSize !== productSize) {
|
||
reason = `Same product, different size (${productSize} vs ${itemSize})`;
|
||
confidence = 0.95; // Very high confidence - likely meant to be this size variant
|
||
} else if (itemSize && !productSize) {
|
||
reason = `Same product, missing size specification`;
|
||
confidence = 0.85;
|
||
} else if (!itemSize && productSize) {
|
||
reason = `Same product, has size: ${productSize}`;
|
||
confidence = 0.8;
|
||
} else {
|
||
reason = `Very similar product`;
|
||
confidence = titleSimilarity;
|
||
}
|
||
} else if (titleSimilarity > 0.6) {
|
||
reason = `Similar product`;
|
||
confidence = titleSimilarity;
|
||
}
|
||
|
||
if (confidence > 0.6) {
|
||
suggestions.push({
|
||
product,
|
||
confidence,
|
||
reason
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort by confidence and return top suggestions
|
||
return suggestions
|
||
.sort((a, b) => b.confidence - a.confidence)
|
||
.slice(0, maxSuggestions);
|
||
};
|
||
|
||
/**
|
||
* Calculate Levenshtein distance between two strings
|
||
* Used for fuzzy string matching
|
||
*/
|
||
const levenshteinDistance = (str1: string, str2: string): number => {
|
||
const matrix = [];
|
||
|
||
for (let i = 0; i <= str2.length; i++) {
|
||
matrix[i] = [i];
|
||
}
|
||
|
||
for (let j = 0; j <= str1.length; j++) {
|
||
matrix[0][j] = j;
|
||
}
|
||
|
||
for (let i = 1; i <= str2.length; i++) {
|
||
for (let j = 1; j <= str1.length; j++) {
|
||
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
||
matrix[i][j] = matrix[i - 1][j - 1];
|
||
} else {
|
||
matrix[i][j] = Math.min(
|
||
matrix[i - 1][j - 1] + 1,
|
||
matrix[i][j - 1] + 1,
|
||
matrix[i - 1][j] + 1
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return matrix[str2.length][str1.length];
|
||
}; |