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:
parent
ec1d204c36
commit
461e424e5e
1 changed files with 193 additions and 23 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue