const ProductService = require("./product"); const ProgressService = require("./progress"); /** * Tag Analysis Service * Provides comprehensive analysis of product tags for price update operations * Requirements: 7.1, 7.2, 7.3, 7.4 */ class TagAnalysisService { constructor() { this.productService = new ProductService(); this.progressService = new ProgressService(); this.cache = new Map(); this.cacheExpiry = 5 * 60 * 1000; // 5 minutes } /** * Get comprehensive tag analysis for the store * @param {number} limit - Maximum number of products to analyze (default: 250) * @returns {Promise} Tag analysis results */ async getTagAnalysis(limit = 250) { const cacheKey = `tag_analysis_${limit}`; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.cacheExpiry) { return cached.data; } try { await this.progressService.info("Starting tag analysis..."); // Fetch products for analysis const products = await this.productService.debugFetchAllProductTags( limit ); if (!products || products.length === 0) { throw new Error("No products found for tag analysis"); } // Analyze tags const analysis = this.analyzeProductTags(products); // Cache the results this.cache.set(cacheKey, { data: analysis, timestamp: Date.now(), }); await this.progressService.info( `Tag analysis completed for ${products.length} products` ); return analysis; } catch (error) { await this.progressService.error(`Tag analysis failed: ${error.message}`); throw error; } } /** * Analyze product tags and generate insights * @param {Array} products - Array of products to analyze * @returns {Object} Analysis results */ analyzeProductTags(products) { const tagCounts = new Map(); const tagPrices = new Map(); const tagVariantCounts = new Map(); const tagTotalValues = new Map(); const totalProducts = products.length; // Count tags and collect price data products.forEach((product) => { if (!product.tags || !Array.isArray(product.tags)) return; product.tags.forEach((tag) => { // Count occurrences tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); // Initialize collections if not exists if (!tagPrices.has(tag)) { tagPrices.set(tag, []); tagVariantCounts.set(tag, 0); tagTotalValues.set(tag, 0); } // Get prices from variants and calculate statistics if (product.variants && Array.isArray(product.variants)) { product.variants.forEach((variant) => { if (variant.price) { const price = parseFloat(variant.price); if (!isNaN(price)) { tagPrices.get(tag).push(price); tagVariantCounts.set(tag, tagVariantCounts.get(tag) + 1); tagTotalValues.set(tag, tagTotalValues.get(tag) + price); } } }); } }); }); // Convert to sorted arrays with enhanced statistics const tagCountsArray = Array.from(tagCounts.entries()) .map(([tag, count]) => ({ tag, count, percentage: (count / totalProducts) * 100, variantCount: tagVariantCounts.get(tag) || 0, totalValue: tagTotalValues.get(tag) || 0, })) .sort((a, b) => b.count - a.count); // Calculate price ranges with enhanced statistics const priceRanges = {}; tagPrices.forEach((prices, tag) => { if (prices.length > 0) { const sortedPrices = prices.sort((a, b) => a - b); const totalValue = tagTotalValues.get(tag) || 0; const variantCount = tagVariantCounts.get(tag) || 0; priceRanges[tag] = { min: sortedPrices[0], max: sortedPrices[sortedPrices.length - 1], average: prices.reduce((sum, price) => sum + price, 0) / prices.length, count: prices.length, variantCount: variantCount, totalValue: totalValue, median: this.calculateMedian(sortedPrices), }; } }); // Generate recommendations const recommendations = this.generateRecommendations( tagCountsArray, priceRanges ); return { totalProducts, tagCounts: tagCountsArray, priceRanges, recommendations, analyzedAt: new Date().toISOString(), }; } /** * Generate recommendations based on tag analysis * @param {Array} tagCounts - Array of tag count objects * @param {Object} priceRanges - Price range data by tag * @returns {Array} Array of recommendation objects */ generateRecommendations(tagCounts, priceRanges) { const recommendations = []; const totalProducts = tagCounts.reduce((sum, tag) => sum + tag.count, 0); // High-impact tags (many products) const highImpactTags = tagCounts .filter( (tag) => tag.count >= Math.max(20, totalProducts * 0.1) && tag.percentage >= 10 ) .slice(0, 3) .map((tag) => ({ tag: tag.tag, count: tag.count, percentage: tag.percentage, impact: this.calculateImpactScore(tag, priceRanges[tag.tag]), })); if (highImpactTags.length > 0) { recommendations.push({ type: "high_impact", title: "High-Impact Tags", description: "Tags with many products that would benefit most from price updates", tags: highImpactTags.map((t) => t.tag), details: highImpactTags, reason: "These tags have the highest product counts and are most likely to need price adjustments", priority: "high", actionable: true, estimatedImpact: `${highImpactTags.reduce( (sum, t) => sum + t.count, 0 )} products affected`, }); } // High-value tags (expensive products) const highValueTags = tagCounts .filter((tag) => { const priceData = priceRanges[tag.tag]; return priceData && priceData.average > 100 && tag.count >= 5; }) .sort( (a, b) => (priceRanges[b.tag]?.average || 0) - (priceRanges[a.tag]?.average || 0) ) .slice(0, 3) .map((tag) => ({ tag: tag.tag, count: tag.count, averagePrice: priceRanges[tag.tag]?.average || 0, potentialRevenue: (priceRanges[tag.tag]?.average || 0) * tag.count, })); if (highValueTags.length > 0) { const totalRevenue = highValueTags.reduce( (sum, t) => sum + t.potentialRevenue, 0 ); recommendations.push({ type: "high_value", title: "High-Value Tags", description: "Tags with products having higher average prices", tags: highValueTags.map((t) => t.tag), details: highValueTags, reason: "These tags contain premium products where price adjustments have the most financial impact", priority: "medium", actionable: true, estimatedImpact: `$${totalRevenue.toFixed(2)} total product value`, }); } // Optimal target tags (balanced impact and value) const optimalTags = this.findOptimalTargetTags(tagCounts, priceRanges); if (optimalTags.length > 0) { recommendations.push({ type: "optimal", title: "Recommended Target Tags", description: "Best balance of product count and pricing for bulk operations", tags: optimalTags.map((t) => t.tag), details: optimalTags, reason: "These tags offer the best combination of reach and value for price update operations", priority: "high", actionable: true, estimatedImpact: `${optimalTags.reduce( (sum, t) => sum + t.count, 0 )} products with balanced impact`, }); } // Sale/discount related tags (use caution) const cautionTags = tagCounts .filter((tag) => { const tagLower = tag.tag.toLowerCase(); return ( (tagLower.includes("sale") || tagLower.includes("discount") || tagLower.includes("clearance") || tagLower.includes("new") || tagLower.includes("seasonal") || tagLower.includes("promo")) && tag.count >= 3 ); }) .slice(0, 4) .map((tag) => ({ tag: tag.tag, count: tag.count, riskLevel: this.assessRiskLevel(tag.tag), })); if (cautionTags.length > 0) { recommendations.push({ type: "caution", title: "Use Caution", description: "Tags that may require special handling", tags: cautionTags.map((t) => t.tag), details: cautionTags, reason: "These tags may have products with special pricing strategies that shouldn't be automatically adjusted", priority: "low", actionable: false, estimatedImpact: "Manual review recommended before bulk operations", }); } // Price consistency analysis const consistencyIssues = this.findPriceConsistencyIssues( tagCounts, priceRanges ); if (consistencyIssues.length > 0) { recommendations.push({ type: "consistency", title: "Price Consistency Issues", description: "Tags with unusual price variations that may need attention", tags: consistencyIssues.map((t) => t.tag), details: consistencyIssues, reason: "These tags show unusual price ranges that might indicate pricing errors or inconsistencies", priority: "medium", actionable: true, estimatedImpact: "Review and standardize pricing", }); } // Low-count tags (might be test or special products) const lowCountTags = tagCounts .filter((tag) => tag.count <= 2 && tag.count >= 1) .slice(0, 5) .map((tag) => ({ tag: tag.tag, count: tag.count, suggestion: tag.count === 1 ? "Consider if this is a test product" : "Verify these are not test items", })); if (lowCountTags.length > 0) { recommendations.push({ type: "low_count", title: "Low-Count Tags", description: "Tags with very few products - verify before bulk operations", tags: lowCountTags.map((t) => t.tag), details: lowCountTags, reason: "These tags have very few products and might be test items or special cases", priority: "info", actionable: false, estimatedImpact: "Manual verification recommended", }); } // Sort recommendations by priority const priorityOrder = { high: 3, medium: 2, low: 1, info: 0 }; return recommendations.sort( (a, b) => priorityOrder[b.priority] - priorityOrder[a.priority] ); } /** * Get sample products for a specific tag * @param {string} tag - Tag to get samples for * @param {number} limit - Maximum number of samples (default: 5) * @returns {Promise} Array of sample products */ async getSampleProductsForTag(tag, limit = 5) { try { await this.progressService.info( `Fetching sample products for tag: ${tag}` ); const products = await this.productService.fetchProductsByTag(tag); // Return limited sample with essential info return products.slice(0, limit).map((product) => ({ id: product.id, title: product.title, tags: product.tags, variants: product.variants.slice(0, 3).map((variant) => ({ id: variant.id, title: variant.title, price: variant.price, compareAtPrice: variant.compareAtPrice, })), })); } catch (error) { await this.progressService.error( `Failed to fetch sample products for tag ${tag}: ${error.message}` ); throw error; } } /** * Clear the analysis cache */ clearCache() { this.cache.clear(); } /** * Get cache statistics * @returns {Object} Cache statistics */ getCacheStats() { return { size: this.cache.size, keys: Array.from(this.cache.keys()), oldestEntry: this.cache.size > 0 ? Math.min(...Array.from(this.cache.values()).map((v) => v.timestamp)) : null, }; } /** * Calculate median value from sorted array * @param {Array} sortedArray - Sorted array of numbers * @returns {number} Median value */ calculateMedian(sortedArray) { if (sortedArray.length === 0) return 0; const mid = Math.floor(sortedArray.length / 2); if (sortedArray.length % 2 === 0) { return (sortedArray[mid - 1] + sortedArray[mid]) / 2; } else { return sortedArray[mid]; } } /** * Calculate impact score for a tag * @param {Object} tagInfo - Tag information * @param {Object} priceData - Price range data * @returns {number} Impact score */ calculateImpactScore(tagInfo, priceData) { const countWeight = 0.6; const priceWeight = 0.4; const normalizedCount = Math.min(tagInfo.count / 100, 1); // Normalize to 0-1 const normalizedPrice = priceData ? Math.min((priceData.average || 0) / 200, 1) : 0; return ( (normalizedCount * countWeight + normalizedPrice * priceWeight) * 100 ); } /** * Find optimal target tags based on balanced criteria * @param {Array} tagCounts - Array of tag count objects * @param {Object} priceRanges - Price range data by tag * @returns {Array} Array of optimal tag objects */ findOptimalTargetTags(tagCounts, priceRanges) { return tagCounts .filter((tag) => { const priceData = priceRanges[tag.tag]; // Filter for tags with reasonable count and price data return ( tag.count >= 5 && tag.count <= 100 && priceData && priceData.average > 10 && priceData.average < 500 ); }) .map((tag) => ({ tag: tag.tag, count: tag.count, percentage: tag.percentage, averagePrice: priceRanges[tag.tag]?.average || 0, score: this.calculateOptimalScore(tag, priceRanges[tag.tag]), })) .sort((a, b) => b.score - a.score) .slice(0, 3); } /** * Calculate optimal score for tag selection * @param {Object} tagInfo - Tag information * @param {Object} priceData - Price range data * @returns {number} Optimal score */ calculateOptimalScore(tagInfo, priceData) { if (!priceData) return 0; // Factors: count (30%), price range (20%), consistency (25%), market position (25%) const countScore = Math.min(tagInfo.count / 50, 1) * 30; const priceScore = Math.min(priceData.average / 100, 1) * 20; const consistencyScore = (1 - Math.min((priceData.max - priceData.min) / priceData.average, 1)) * 25; const marketScore = priceData.average > 20 && priceData.average < 200 ? 25 : 10; return countScore + priceScore + consistencyScore + marketScore; } /** * Assess risk level for a tag * @param {string} tagName - Tag name * @returns {string} Risk level */ assessRiskLevel(tagName) { const tagLower = tagName.toLowerCase(); if (tagLower.includes("sale") || tagLower.includes("clearance")) return "high"; if (tagLower.includes("new") || tagLower.includes("seasonal")) return "medium"; if (tagLower.includes("discount") || tagLower.includes("promo")) return "high"; return "low"; } /** * Find price consistency issues * @param {Array} tagCounts - Array of tag count objects * @param {Object} priceRanges - Price range data by tag * @returns {Array} Array of tags with consistency issues */ findPriceConsistencyIssues(tagCounts, priceRanges) { return tagCounts .filter((tag) => { const priceData = priceRanges[tag.tag]; if (!priceData || tag.count < 3) return false; // Check for unusual price variations const priceRange = priceData.max - priceData.min; const averagePrice = priceData.average; const variationRatio = priceRange / averagePrice; // Flag if price variation is more than 200% of average return variationRatio > 2.0; }) .slice(0, 3) .map((tag) => ({ tag: tag.tag, count: tag.count, priceRange: priceRanges[tag.tag], issue: "High price variation", variationRatio: ( (priceRanges[tag.tag].max - priceRanges[tag.tag].min) / priceRanges[tag.tag].average ).toFixed(2), })); } } module.exports = TagAnalysisService;