541 lines
15 KiB
JavaScript
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;
|