From 461e424e5e8ccb723e4d4e83b32580562cd8857e Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Fri, 1 May 2026 15:02:46 +1000 Subject: [PATCH] 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. --- client/src/utils/productMatcher.ts | 216 ++++++++++++++++++++++++++--- 1 file changed, 193 insertions(+), 23 deletions(-) diff --git a/client/src/utils/productMatcher.ts b/client/src/utils/productMatcher.ts index e4df356..66053bf 100644 --- a/client/src/utils/productMatcher.ts +++ b/client/src/utils/productMatcher.ts @@ -29,6 +29,7 @@ export interface MatchingResult { /** * 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[], @@ -41,44 +42,103 @@ export const matchOrderItemsToProducts = ( let bestMatch: any = null; let bestConfidence = 0; - // Extract size from item title if present - const itemSizeMatch = item.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i); - const itemSize = itemSizeMatch ? itemSizeMatch[1].trim().toLowerCase() : ''; + // Enhanced size extraction - look for "Large" or "Small" specifically + const itemTitle = item.title.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 - .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, ' ') .trim() .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) { + 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(); - const productSize = product.size ? product.size.toLowerCase() : ''; - // Calculate title similarity (basic word matching) const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle); - // Size matching bonus + // 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.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 - const confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2); + // For shelf decor products, size is very important + 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; bestConfidence = confidence; } @@ -117,29 +177,52 @@ export const matchOrderItemsToProducts = ( /** * 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 => { // 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 => - word.length > 2 && !stopWords.includes(word) + word.length > 2 && !stopWords.includes(word.toLowerCase()) ); 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; - // 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; for (const word1 of words1) { for (const word2 of words2) { - if (word1 === word2 || - word1.includes(word2) || - word2.includes(word1) || - levenshteinDistance(word1, word2) <= Math.max(1, Math.min(word1.length, word2.length) * 0.2)) { + 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; } @@ -151,6 +234,93 @@ const calculateTitleSimilarity = (title1: string, title2: string): number => { 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