Fix product matching for Modern Minimalist Shelf Decor variants

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.
This commit is contained in:
dlawler489 2026-05-01 15:02:46 +10:00
parent ec1d204c36
commit 461e424e5e

View file

@ -29,6 +29,7 @@ export interface MatchingResult {
/** /**
* Match order items against existing products in the database * Match order items against existing products in the database
* Considers size as a factor but ignores color variations * Considers size as a factor but ignores color variations
* Enhanced for Modern Minimalist Shelf Decor products with Large/Small variants
*/ */
export const matchOrderItemsToProducts = ( export const matchOrderItemsToProducts = (
orderItems: any[], orderItems: any[],
@ -41,44 +42,103 @@ export const matchOrderItemsToProducts = (
let bestMatch: any = null; let bestMatch: any = null;
let bestConfidence = 0; let bestConfidence = 0;
// Extract size from item title if present // Enhanced size extraction - look for "Large" or "Small" specifically
const itemSizeMatch = item.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i); const itemTitle = item.title.toLowerCase();
const itemSize = itemSizeMatch ? itemSizeMatch[1].trim().toLowerCase() : ''; let itemSize = '';
// Clean item title for comparison (remove color but keep size info) // 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 const cleanItemTitle = item.title
.replace(/Colour:\s*[^,\s]+(?:\s+[^,\s]+)*/i, '') // Remove color .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, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
.toLowerCase(); .toLowerCase();
console.log('Matching item:', cleanItemTitle, 'Size:', itemSize); console.log('🔍 Matching item:', item.title);
console.log(' Clean title:', cleanItemTitle);
console.log(' Extracted size:', itemSize);
for (const product of products) { 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 const cleanProductTitle = product.title
.replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '')
.replace(/Size:\s*[^,\s-]+/i, '')
.replace(/3D-Printed/i, '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
.toLowerCase(); .toLowerCase();
const productSize = product.size ? product.size.toLowerCase() : '';
// Calculate title similarity (basic word matching) // Calculate title similarity (basic word matching)
const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle); const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle);
// Size matching bonus // Size matching - this is crucial for your shelf decor products
let sizeSimilarity = 0; let sizeSimilarity = 0;
if (itemSize && productSize) { if (itemSize && productSize) {
sizeSimilarity = itemSize === productSize ? 1 : 0; sizeSimilarity = itemSize === productSize ? 1 : 0;
console.log(` Size match: ${itemSize} vs ${productSize} = ${sizeSimilarity}`);
} else if (!itemSize && !productSize) { } else if (!itemSize && !productSize) {
sizeSimilarity = 0.5; // Neutral if neither has size 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;
} }
// Combined confidence score // For shelf decor products, size is very important
const confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2); const isShelfDecor = cleanItemTitle.includes('shelf decor') || cleanProductTitle.includes('shelf decor');
let confidence;
console.log(`Product: ${cleanProductTitle}, Title sim: ${titleSimilarity}, Size sim: ${sizeSimilarity}, Confidence: ${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);
}
if (confidence > bestConfidence && confidence >= 0.5) { // Fixed: >= instead of > for exact threshold matches 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; bestMatch = product;
bestConfidence = confidence; bestConfidence = confidence;
} }
@ -117,29 +177,52 @@ export const matchOrderItemsToProducts = (
/** /**
* Calculate similarity between two product titles * Calculate similarity between two product titles
* Uses word matching and common substring analysis * Enhanced for Modern Minimalist Shelf Decor and similar products
*/ */
const calculateTitleSimilarity = (title1: string, title2: string): number => { const calculateTitleSimilarity = (title1: string, title2: string): number => {
// Split into words and filter out common stop words // 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', '-', '']; 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 => const words1 = title1.split(/\s+/).filter(word =>
word.length > 2 && !stopWords.includes(word) word.length > 2 && !stopWords.includes(word.toLowerCase())
); );
const words2 = title2.split(/\s+/).filter(word => const words2 = title2.split(/\s+/).filter(word =>
word.length > 2 && !stopWords.includes(word) word.length > 2 && !stopWords.includes(word.toLowerCase())
); );
if (words1.length === 0 || words2.length === 0) return 0; if (words1.length === 0 || words2.length === 0) return 0;
// Count matching words // 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; let matchingWords = 0;
for (const word1 of words1) { for (const word1 of words1) {
for (const word2 of words2) { for (const word2 of words2) {
if (word1 === word2 || if (word1.toLowerCase() === word2.toLowerCase() ||
word1.includes(word2) || word1.toLowerCase().includes(word2.toLowerCase()) ||
word2.includes(word1) || word2.toLowerCase().includes(word1.toLowerCase()) ||
levenshteinDistance(word1, word2) <= Math.max(1, Math.min(word1.length, word2.length) * 0.2)) { levenshteinDistance(word1.toLowerCase(), word2.toLowerCase()) <= Math.max(1, Math.min(word1.length, word2.length) * 0.2)) {
matchingWords++; matchingWords++;
break; break;
} }
@ -151,6 +234,93 @@ const calculateTitleSimilarity = (title1: string, title2: string): number => {
return matchingWords / totalUniqueWords; 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 * Calculate Levenshtein distance between two strings
* Used for fuzzy string matching * Used for fuzzy string matching