Just a whole lot of crap
This commit is contained in:
509
src/services/tagAnalysis.js
Normal file
509
src/services/tagAnalysis.js
Normal file
@@ -0,0 +1,509 @@
|
||||
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 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);
|
||||
|
||||
// Collect price data
|
||||
if (!tagPrices.has(tag)) {
|
||||
tagPrices.set(tag, []);
|
||||
}
|
||||
|
||||
// Get prices from variants
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to sorted arrays
|
||||
const tagCountsArray = Array.from(tagCounts.entries())
|
||||
.map(([tag, count]) => ({
|
||||
tag,
|
||||
count,
|
||||
percentage: (count / totalProducts) * 100,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Calculate price ranges
|
||||
const priceRanges = {};
|
||||
tagPrices.forEach((prices, tag) => {
|
||||
if (prices.length > 0) {
|
||||
const sortedPrices = prices.sort((a, b) => a - b);
|
||||
priceRanges[tag] = {
|
||||
min: sortedPrices[0],
|
||||
max: sortedPrices[sortedPrices.length - 1],
|
||||
average:
|
||||
prices.reduce((sum, price) => sum + price, 0) / prices.length,
|
||||
count: prices.length,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 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 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;
|
||||
Reference in New Issue
Block a user