mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:45:24 +02:00
fix: correct ad spend over-calculation in Weekly Grid
- Refactored mergeSalesAndAdsData to aggregate by ASIN+Customer+Year+Week - Each ASIN/Week now produces exactly one output record - Updated pivotWeeklySalesData to use ASIN as primary key - Fixed double-counting when same ASIN had multiple SKUs - Added support for ads-only records (ASINs with spend but no sales) - Changed App.tsx to use filtered ads data for accurate totals
This commit is contained in:
@@ -248,8 +248,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Combine Sales & Ads Data dynamically based on current filters
|
// Combine Sales & Ads Data dynamically based on current filters
|
||||||
const combinedAdsData = useMemo(() => {
|
const combinedAdsData = useMemo(() => {
|
||||||
return mergeSalesAndAdsData(filteredData, adsData);
|
return mergeSalesAndAdsData(filteredData, filteredAdsData);
|
||||||
}, [filteredData, adsData]);
|
}, [filteredData, filteredAdsData]);
|
||||||
|
|
||||||
// Derive Context Data (Product Line Context when drilling down)
|
// Derive Context Data (Product Line Context when drilling down)
|
||||||
const contextAggregatedData = useMemo(() => {
|
const contextAggregatedData = useMemo(() => {
|
||||||
|
|||||||
+107
-75
@@ -442,14 +442,61 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
|
|||||||
// --- DATA MERGING ---
|
// --- DATA MERGING ---
|
||||||
|
|
||||||
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
||||||
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Year + Week
|
// Key for both sales and ads: ASIN|Customer|Year|Week
|
||||||
|
const createKey = (asin: string, customer: string, year: number, week: number) =>
|
||||||
|
`${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`;
|
||||||
|
|
||||||
|
// 1. Aggregate Sales by ASIN|Customer|Year|Week (combine all SKUs)
|
||||||
|
const salesMap = new Map<string, {
|
||||||
|
sellOut: number;
|
||||||
|
units: number;
|
||||||
|
sku: string;
|
||||||
|
title: string;
|
||||||
|
line: string;
|
||||||
|
asin: string;
|
||||||
|
customer: string;
|
||||||
|
year: number;
|
||||||
|
week: number;
|
||||||
|
month: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
salesData.forEach(sale => {
|
||||||
|
const weekNum = sale.week || 0;
|
||||||
|
if (weekNum === 0) return; // Skip records without week data for weekly analysis
|
||||||
|
|
||||||
|
const key = createKey(sale.asin, sale.customer, sale.year, weekNum);
|
||||||
|
|
||||||
|
if (salesMap.has(key)) {
|
||||||
|
const existing = salesMap.get(key)!;
|
||||||
|
existing.sellOut += sale.sellOut;
|
||||||
|
existing.units += sale.units;
|
||||||
|
// Keep the best metadata (longest title, first non-empty SKU)
|
||||||
|
if (sale.title && sale.title.length > (existing.title?.length || 0)) {
|
||||||
|
existing.title = sale.title;
|
||||||
|
}
|
||||||
|
if (sale.sku && !existing.sku) {
|
||||||
|
existing.sku = sale.sku;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
salesMap.set(key, {
|
||||||
|
sellOut: sale.sellOut,
|
||||||
|
units: sale.units,
|
||||||
|
sku: sale.sku,
|
||||||
|
title: sale.title,
|
||||||
|
line: sale.line,
|
||||||
|
asin: sale.asin,
|
||||||
|
customer: sale.customer,
|
||||||
|
year: sale.year,
|
||||||
|
week: weekNum,
|
||||||
|
month: sale.month
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Aggregate Ads by ASIN|Customer|Year|Week
|
||||||
const adsMap = new Map<string, AdsRecord>();
|
const adsMap = new Map<string, AdsRecord>();
|
||||||
|
|
||||||
adsData.forEach(ad => {
|
adsData.forEach(ad => {
|
||||||
// Case-insensitive key using ASIN + Country + Year + Week
|
const key = createKey(ad.asin, ad.country, ad.year, ad.week);
|
||||||
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
|
||||||
|
|
||||||
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
|
|
||||||
if (adsMap.has(key)) {
|
if (adsMap.has(key)) {
|
||||||
const existing = adsMap.get(key)!;
|
const existing = adsMap.get(key)!;
|
||||||
existing.cost += ad.cost;
|
existing.cost += ad.cost;
|
||||||
@@ -463,107 +510,88 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Iterate Sales Data and merge
|
const mergedData: CombinedKPIs[] = [];
|
||||||
const mergedData: CombinedKPIs[] = salesData.map(sale => {
|
const processedKeys = new Set<string>();
|
||||||
// Use week from sales record if available
|
|
||||||
const weekNum = sale.week || 0;
|
// 3. Create ONE record per ASIN/Customer/Year/Week from sales, attach ads if available
|
||||||
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
|
salesMap.forEach((sale, key) => {
|
||||||
const adData = adsMap.get(key) || {
|
processedKeys.add(key);
|
||||||
country: sale.customer,
|
const ad = adsMap.get(key);
|
||||||
year: sale.year,
|
|
||||||
week: weekNum,
|
const adCost = ad?.cost || 0;
|
||||||
asin: sale.asin,
|
const adClicks = ad?.clicks || 0;
|
||||||
cost: 0,
|
const adImpressions = ad?.impressions || 0;
|
||||||
clicks: 0,
|
const adSales = ad?.attributedSales30d || 0;
|
||||||
impressions: 0,
|
const adUnits = ad?.attributedUnits30d || 0;
|
||||||
cpc: 0,
|
|
||||||
ctr: 0,
|
|
||||||
acos: 0,
|
|
||||||
conversions: 0,
|
|
||||||
attributedSales30d: 0,
|
|
||||||
attributedUnits30d: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const salesTotal = sale.sellOut;
|
const salesTotal = sale.sellOut;
|
||||||
const salesAds = adData.attributedSales30d;
|
|
||||||
// Logic: Organic = Total - Ads. Max(0) to avoid negative if attribution window logic differs vs finance dates
|
|
||||||
const salesOrganic = Math.max(0, salesTotal - salesAds);
|
|
||||||
|
|
||||||
const unitsTotal = sale.units;
|
const unitsTotal = sale.units;
|
||||||
const unitsAds = adData.attributedUnits30d;
|
const salesOrganic = Math.max(0, salesTotal - adSales);
|
||||||
const unitsOrganic = Math.max(0, unitsTotal - unitsAds);
|
const unitsOrganic = Math.max(0, unitsTotal - adUnits);
|
||||||
|
|
||||||
// KPIs
|
// KPIs
|
||||||
const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0;
|
const acos = adSales > 0 ? (adCost / adSales) * 100 : 0;
|
||||||
const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0;
|
const tacos = salesTotal > 0 ? (adCost / salesTotal) * 100 : 0;
|
||||||
const roas = adData.cost > 0 ? salesAds / adData.cost : 0;
|
const roas = adCost > 0 ? adSales / adCost : 0;
|
||||||
const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0;
|
const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0;
|
||||||
const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0;
|
const cpc = adClicks > 0 ? adCost / adClicks : 0;
|
||||||
// CVR (Units / Clicks)
|
const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0;
|
||||||
const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0;
|
|
||||||
|
|
||||||
const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0;
|
mergedData.push({
|
||||||
const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0;
|
id: `merged-${key}`,
|
||||||
|
|
||||||
return {
|
|
||||||
id: sale.id,
|
|
||||||
marketplace: sale.customer,
|
marketplace: sale.customer,
|
||||||
customer: sale.customer,
|
customer: sale.customer,
|
||||||
month: sale.month,
|
month: sale.month,
|
||||||
week: sale.week || 0, // Preserve week info
|
week: sale.week,
|
||||||
year: sale.year,
|
year: sale.year,
|
||||||
asin: sale.asin,
|
asin: sale.asin,
|
||||||
title: sale.title,
|
title: sale.title,
|
||||||
line: sale.line,
|
line: sale.line,
|
||||||
sku: sale.sku,
|
sku: sale.sku,
|
||||||
|
|
||||||
salesTotal,
|
salesTotal,
|
||||||
unitsTotal,
|
unitsTotal,
|
||||||
|
salesAds: adSales,
|
||||||
salesAds,
|
unitsAds: adUnits,
|
||||||
unitsAds,
|
cost: adCost,
|
||||||
cost: adData.cost,
|
clicks: adClicks,
|
||||||
clicks: adData.clicks,
|
impressions: adImpressions,
|
||||||
impressions: adData.impressions,
|
|
||||||
|
|
||||||
salesOrganic,
|
salesOrganic,
|
||||||
unitsOrganic,
|
unitsOrganic,
|
||||||
|
paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0,
|
||||||
paidSalesShare,
|
organicSalesShare: salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0,
|
||||||
organicSalesShare,
|
|
||||||
|
|
||||||
acos,
|
acos,
|
||||||
tacos,
|
tacos,
|
||||||
roas,
|
roas,
|
||||||
ctr,
|
ctr,
|
||||||
cpc,
|
cpc,
|
||||||
cvrUnits
|
cvrUnits
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Include ads-only records (ASINs with ads but no sales)
|
// 4. Add ads-only records (ASINs with ads but no sales in the filtered data)
|
||||||
const usedAdsKeys = new Set<string>();
|
adsMap.forEach((ad, key) => {
|
||||||
salesData.forEach(sale => {
|
if (!processedKeys.has(key)) {
|
||||||
const weekNum = sale.week || 0;
|
// Look up metadata from sales data for this ASIN (any week)
|
||||||
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
|
let meta: { sku: string; title: string; line: string } | undefined;
|
||||||
usedAdsKeys.add(key);
|
salesMap.forEach((sale, saleKey) => {
|
||||||
});
|
if (saleKey.startsWith(ad.asin.trim().toUpperCase() + '|' + ad.country.trim().toUpperCase())) {
|
||||||
|
if (!meta || sale.title?.length > meta.title?.length) {
|
||||||
|
meta = { sku: sale.sku, title: sale.title, line: sale.line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adsData.forEach(ad => {
|
|
||||||
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
|
||||||
if (!usedAdsKeys.has(key)) {
|
|
||||||
// Create a CombinedKPIs record for ads-only data
|
|
||||||
mergedData.push({
|
mergedData.push({
|
||||||
id: `ads-${key}`,
|
id: `ads-only-${key}`,
|
||||||
marketplace: ad.country,
|
marketplace: ad.country,
|
||||||
customer: ad.country,
|
customer: ad.country,
|
||||||
month: '',
|
month: 'N/A',
|
||||||
week: ad.week,
|
week: ad.week,
|
||||||
year: ad.year,
|
year: ad.year,
|
||||||
asin: ad.asin,
|
asin: ad.asin,
|
||||||
title: '',
|
title: meta?.title || ad.asin,
|
||||||
line: '',
|
line: meta?.line || 'Unassigned',
|
||||||
sku: '',
|
sku: meta?.sku || '',
|
||||||
salesTotal: 0,
|
salesTotal: 0,
|
||||||
unitsTotal: 0,
|
unitsTotal: 0,
|
||||||
salesAds: ad.attributedSales30d,
|
salesAds: ad.attributedSales30d,
|
||||||
@@ -589,6 +617,7 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- EXISTING HELPERS ---
|
// --- EXISTING HELPERS ---
|
||||||
|
|
||||||
// Filter Ads Data by Country, Year, Week, and ASIN
|
// Filter Ads Data by Country, Year, Week, and ASIN
|
||||||
@@ -1327,7 +1356,8 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|||||||
const map = new Map<string, WeeklyPivotRow>();
|
const map = new Map<string, WeeklyPivotRow>();
|
||||||
|
|
||||||
data.forEach(record => {
|
data.forEach(record => {
|
||||||
const key = record.sku || record.asin || `${record.title}-${record.line}`;
|
// Use ASIN as primary key since mergeSalesAndAdsData outputs one record per ASIN/week
|
||||||
|
const key = record.asin || record.sku || `${record.title}-${record.line}`;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
@@ -1347,6 +1377,8 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|||||||
if (record.week) {
|
if (record.week) {
|
||||||
const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`;
|
const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`;
|
||||||
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal;
|
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal;
|
||||||
|
// Only add cost if we haven't already added it for this ASIN/week
|
||||||
|
// Since mergeSalesAndAdsData now outputs one record per ASIN/week, this should be clean
|
||||||
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
|
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user