etsy-finance-tracker/client/src/utils/productMatcher.ts
dlawler489 461e424e5e 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.
2026-05-01 15:02:46 +10:00

354 lines
No EOL
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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];
};