Files
PriceUpdaterAppv2/src/services/tagAnalysis.js
2025-08-15 15:39:28 -05:00

541 lines
15 KiB
JavaScript

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<Object>} 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>} 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;