mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:45:24 +02:00
feat: Integrate PapaParse for CSV handling
Adds papaparse as a dependency and updates the data processing service to use it for more robust CSV file parsing. This replaces manual CSV parsing logic with a dedicated library, improving reliability and handling of various CSV formats. Also renames the `MoversIcon` to `TrendingIcon` to better reflect its usage in indicating trending performance metrics.
This commit is contained in:
+325
-55
@@ -1,12 +1,13 @@
|
||||
import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
||||
import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
|
||||
const parseCurrency = (value: string): number => {
|
||||
if (!value) return 0;
|
||||
|
||||
// Remove currency symbol and whitespace
|
||||
let clean = value.replace(/[€\s]/g, '').trim();
|
||||
let clean = value.replace(/[€$£\s]/g, '').trim();
|
||||
|
||||
// HEURISTIC:
|
||||
// If it contains a comma, we assume it's likely European format (Decimal separator)
|
||||
@@ -14,17 +15,27 @@ const parseCurrency = (value: string): number => {
|
||||
// But given the context (DE data), comma is usually decimal.
|
||||
|
||||
// Case A: European Format (e.g., "277.179,09" or "50,00")
|
||||
if (clean.includes(',')) {
|
||||
// If it has dots (thousands), remove them
|
||||
clean = clean.replace(/\./g, '');
|
||||
// Replace decimal comma with dot
|
||||
if (clean.includes(',') && !clean.includes('.') && clean.indexOf(',') > clean.length - 4) {
|
||||
clean = clean.replace(',', '.');
|
||||
return parseFloat(clean);
|
||||
}
|
||||
else if (clean.includes(',') && clean.includes('.')) {
|
||||
// Mixed: 1.234,56
|
||||
if (clean.indexOf(',') > clean.indexOf('.')) {
|
||||
clean = clean.replace(/\./g, '').replace(',', '.');
|
||||
} else {
|
||||
// 1,234.56
|
||||
clean = clean.replace(/,/g, '');
|
||||
}
|
||||
return parseFloat(clean);
|
||||
}
|
||||
else if (clean.includes(',')) {
|
||||
// Likely EU decimal
|
||||
clean = clean.replace(',', '.');
|
||||
return parseFloat(clean);
|
||||
}
|
||||
|
||||
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
|
||||
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
|
||||
// and parse.
|
||||
clean = clean.replace(/,/g, '');
|
||||
const num = parseFloat(clean);
|
||||
|
||||
@@ -41,12 +52,38 @@ const parseUnits = (value: string): number => {
|
||||
|
||||
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// Mapping for Spanish Month Names
|
||||
const SPANISH_MONTHS: Record<string, string> = {
|
||||
'ene': 'Jan', 'enero': 'Jan',
|
||||
'feb': 'Feb', 'febrero': 'Feb',
|
||||
'mar': 'Mar', 'marzo': 'Mar',
|
||||
'abr': 'Apr', 'abril': 'Apr',
|
||||
'may': 'May', 'mayo': 'May',
|
||||
'jun': 'Jun', 'junio': 'Jun',
|
||||
'jul': 'Jul', 'julio': 'Jul',
|
||||
'ago': 'Aug', 'agosto': 'Aug',
|
||||
'sep': 'Sep', 'septiembre': 'Sep', 'set': 'Sep', 'setiembre': 'Sep',
|
||||
'oct': 'Oct', 'octubre': 'Oct',
|
||||
'nov': 'Nov', 'noviembre': 'Nov',
|
||||
'dic': 'Dec', 'diciembre': 'Dec'
|
||||
};
|
||||
|
||||
// Robust Month Normalizer
|
||||
const normalizeMonth = (rawMonth: string): string => {
|
||||
if (!rawMonth) return '';
|
||||
let m = rawMonth.trim();
|
||||
|
||||
// Handle numeric months "01", "1", "01-2023" (start with digits)
|
||||
// If it's a full date string like "2023-04-01" or "01/04/2023"
|
||||
if (m.includes('/') || m.includes('-')) {
|
||||
const date = new Date(m);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const monthIdx = date.getMonth();
|
||||
const yearShort = date.getFullYear().toString().slice(2);
|
||||
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
|
||||
}
|
||||
}
|
||||
|
||||
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1]);
|
||||
@@ -55,26 +92,37 @@ const normalizeMonth = (rawMonth: string): string => {
|
||||
|
||||
// Handle text months "Apr-23", "Apr 23", "April"
|
||||
// Extract first sequence of letters
|
||||
const alphaMatch = m.match(/([a-zA-Z]+)/);
|
||||
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); // Include accented chars for Spanish
|
||||
if (alphaMatch) {
|
||||
m = alphaMatch[1];
|
||||
let alpha = alphaMatch[1].toLowerCase();
|
||||
|
||||
// Check Spanish mapping first
|
||||
if (SPANISH_MONTHS[alpha]) {
|
||||
m = SPANISH_MONTHS[alpha];
|
||||
} else {
|
||||
// Default to first 3 chars capitalize (English)
|
||||
if (alpha.length > 3) alpha = alpha.substring(0, 3);
|
||||
m = alpha.charAt(0).toUpperCase() + alpha.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Take first 3 characters
|
||||
if (m.length > 3) {
|
||||
m = m.substring(0, 3);
|
||||
// Try to grab year from original string to append (e.g. "Apr-23")
|
||||
const yearMatch = rawMonth.match(/(\d{2,4})/);
|
||||
if (yearMatch) {
|
||||
let y = yearMatch[1];
|
||||
if (y.length === 4) y = y.slice(2);
|
||||
// Only append if year is not part of the month name logic
|
||||
if (!m.includes('-')) {
|
||||
return `${m}-${y}`;
|
||||
}
|
||||
}
|
||||
// Capitalize first letter, lowercase rest
|
||||
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
|
||||
|
||||
|
||||
return m;
|
||||
};
|
||||
|
||||
// Robust CSV Column Value Extractor
|
||||
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
|
||||
const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
const rowKeys = Object.keys(row);
|
||||
// Create a map of normalized keys in the row to the actual keys
|
||||
const normalizedRowKeys: Record<string, string> = {};
|
||||
rowKeys.forEach(k => {
|
||||
normalizedRowKeys[k.trim().toLowerCase()] = k;
|
||||
@@ -87,8 +135,6 @@ const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
const val = row[actualKey];
|
||||
if (val !== undefined && val !== null) {
|
||||
const strVal = String(val).trim();
|
||||
// CRITICAL FIX: Only return if the value is NOT empty.
|
||||
// This allows falling back to the next alias if the first matching column exists but is empty.
|
||||
if (strVal.length > 0) {
|
||||
return strVal;
|
||||
}
|
||||
@@ -98,37 +144,38 @@ const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Extracted Mapping Function
|
||||
// --- SALES / SELL OUT MAPPING ---
|
||||
|
||||
const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
||||
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
|
||||
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
||||
const year = parseInt(yearStr) || 0;
|
||||
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
||||
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
||||
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
|
||||
const month = normalizeMonth(monthStr);
|
||||
|
||||
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
||||
if (year === 0 && month.includes('-')) {
|
||||
const parts = month.split('-');
|
||||
if (parts.length === 2) {
|
||||
const yPart = parts[1];
|
||||
// assume 20xx for 2 digits
|
||||
if (yPart.length === 2) year = 2000 + parseInt(yPart);
|
||||
else if (yPart.length === 4) year = parseInt(yPart);
|
||||
}
|
||||
}
|
||||
|
||||
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
|
||||
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
|
||||
const week = isNaN(weekNum) ? undefined : weekNum;
|
||||
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
|
||||
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Unassigned';
|
||||
|
||||
// Updated ASIN priority list based on user feedback
|
||||
const asin = getColumnValue(row, [
|
||||
'CUSTOMER REFERENCE',
|
||||
'AMAZON ASIN',
|
||||
'ASIN',
|
||||
'Asin',
|
||||
'PRODUCT ID',
|
||||
'ITEM IDENTIFIER',
|
||||
'ASIN NO.',
|
||||
'Product ASIN',
|
||||
'IDENTIFIER'
|
||||
'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER'
|
||||
]);
|
||||
|
||||
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
|
||||
|
||||
// Prioritize 'Title' column, fallback to 'Article Name' columns
|
||||
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
|
||||
|
||||
// Legacy/Backup field
|
||||
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
|
||||
|
||||
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
|
||||
@@ -152,24 +199,24 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
||||
|
||||
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// @ts-ignore - PapaParse is loaded globally via CDN
|
||||
// @ts-ignore
|
||||
Papa.parse(fileOrContent, {
|
||||
header: true,
|
||||
// delimiter: ";", // Allow auto-detect
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => {
|
||||
try {
|
||||
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
||||
return mapRowToRecord(row, index);
|
||||
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
|
||||
})
|
||||
// Relaxed filtering: Only exclude rows with absolutely no year info even after backfill
|
||||
.filter((r: SalesRecord) => r.year > 0);
|
||||
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
error: (error: any) => {
|
||||
reject(error);
|
||||
}
|
||||
error: (error: any) => reject(error)
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -180,15 +227,13 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
||||
const workbook = XLSX.read(arrayBuffer);
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convert to JSON
|
||||
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
|
||||
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
||||
|
||||
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
||||
return mapRowToRecord(row, index);
|
||||
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
|
||||
})
|
||||
// Relaxed filtering
|
||||
.filter((r: SalesRecord) => r.year > 0);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -197,14 +242,234 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADS DATA MAPPING ---
|
||||
|
||||
const mapCountryToMarketplace = (country: string): string => {
|
||||
const c = country.toLowerCase().trim();
|
||||
if (c.includes('germany') || c.includes('deutschland')) return 'AMAZON DE';
|
||||
if (c.includes('spain') || c.includes('espana') || c.includes('españa')) return 'AMAZON ES';
|
||||
if (c.includes('france')) return 'AMAZON FR';
|
||||
if (c.includes('italy') || c.includes('italia')) return 'AMAZON IT';
|
||||
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'AMAZON UK';
|
||||
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland')) return 'AMAZON NL';
|
||||
if (c.includes('sweden')) return 'AMAZON SE';
|
||||
if (c.includes('poland')) return 'AMAZON PL';
|
||||
if (c.includes('belgium')) return 'AMAZON BE';
|
||||
if (c.includes('turkey')) return 'AMAZON TR';
|
||||
return country.toUpperCase(); // Fallback
|
||||
};
|
||||
|
||||
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// @ts-ignore
|
||||
Papa.parse(file, {
|
||||
header: false, // Index-based mapping (A=0, B=1...)
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => {
|
||||
try {
|
||||
const data: AdsRecord[] = [];
|
||||
const rows = results.data;
|
||||
const len = rows.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const row = rows[i];
|
||||
if (!Array.isArray(row) || row.length < 12) continue;
|
||||
|
||||
// Check header row (Column A: Country)
|
||||
const c0 = String(row[0]).trim();
|
||||
if (c0.toLowerCase() === 'country' || c0.toLowerCase() === 'marketplace') continue;
|
||||
|
||||
// Map by Column Index (A=0, B=1... L=11)
|
||||
const countryRaw = row[0];
|
||||
const monthRaw = row[1];
|
||||
const asin = row[2];
|
||||
const costRaw = row[3];
|
||||
const clicksRaw = row[4];
|
||||
const impressionsRaw = row[5];
|
||||
// G, H, I, J unused/calculated
|
||||
const unitsRaw = row[10]; // K
|
||||
const salesRaw = row[11]; // L
|
||||
|
||||
if (!asin || !countryRaw) continue;
|
||||
|
||||
data.push({
|
||||
country: mapCountryToMarketplace(String(countryRaw)),
|
||||
month: normalizeMonth(String(monthRaw)),
|
||||
asin: String(asin).trim(),
|
||||
cost: parseCurrency(String(costRaw)),
|
||||
clicks: parseUnits(String(clicksRaw)),
|
||||
impressions: parseUnits(String(impressionsRaw)),
|
||||
attributedSales30d: parseCurrency(String(salesRaw)),
|
||||
attributedUnits30d: parseUnits(String(unitsRaw)),
|
||||
});
|
||||
}
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
error: (error: any) => reject(error)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer);
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Use header: 'A' to strictly map columns by index letter as requested
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" });
|
||||
|
||||
const data: AdsRecord[] = jsonData.map((row: any) => {
|
||||
// Check if it's a header row
|
||||
if (row['A'] === 'Country' && (row['C'] === 'ASIN' || row['C'] === 'Asin')) return null;
|
||||
|
||||
// Map by Column Letter as requested
|
||||
// A: Country, B: Month, C: ASIN, D: Cost, E: Clicks, F: Impressions
|
||||
// G: CPC, H: CTR, I: ACOS, J: Conversions
|
||||
// K: Units, L: Sales
|
||||
const countryRaw = row['A'];
|
||||
const monthRaw = row['B'];
|
||||
const asin = row['C'];
|
||||
const costRaw = row['D'];
|
||||
const clicksRaw = row['E'];
|
||||
const impressionsRaw = row['F'];
|
||||
const unitsRaw = row['K'];
|
||||
const salesRaw = row['L'];
|
||||
|
||||
if (!asin || !countryRaw) return null;
|
||||
|
||||
return {
|
||||
country: mapCountryToMarketplace(String(countryRaw)),
|
||||
month: normalizeMonth(String(monthRaw)),
|
||||
asin: String(asin).trim(),
|
||||
cost: parseCurrency(String(costRaw)),
|
||||
clicks: parseUnits(String(clicksRaw)),
|
||||
impressions: parseUnits(String(impressionsRaw)),
|
||||
attributedSales30d: parseCurrency(String(salesRaw)),
|
||||
attributedUnits30d: parseUnits(String(unitsRaw)),
|
||||
};
|
||||
}).filter((r): r is AdsRecord => r !== null);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error processing Ads Excel:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- DATA MERGING ---
|
||||
|
||||
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
||||
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month
|
||||
const adsMap = new Map<string, AdsRecord>();
|
||||
|
||||
adsData.forEach(ad => {
|
||||
const key = `${ad.asin.toUpperCase()}|${ad.country.toUpperCase()}|${ad.month}`;
|
||||
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
|
||||
if (adsMap.has(key)) {
|
||||
const existing = adsMap.get(key)!;
|
||||
existing.cost += ad.cost;
|
||||
existing.clicks += ad.clicks;
|
||||
existing.impressions += ad.impressions;
|
||||
existing.attributedSales30d += ad.attributedSales30d;
|
||||
existing.attributedUnits30d += ad.attributedUnits30d;
|
||||
} else {
|
||||
adsMap.set(key, { ...ad });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Iterate Sales Data and merge
|
||||
const mergedData: CombinedKPIs[] = salesData.map(sale => {
|
||||
const key = `${sale.asin.toUpperCase()}|${sale.customer.toUpperCase()}|${sale.month}`;
|
||||
const adData = adsMap.get(key) || {
|
||||
country: sale.customer,
|
||||
month: sale.month,
|
||||
asin: sale.asin,
|
||||
cost: 0,
|
||||
clicks: 0,
|
||||
impressions: 0,
|
||||
attributedSales30d: 0,
|
||||
attributedUnits30d: 0
|
||||
};
|
||||
|
||||
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 unitsAds = adData.attributedUnits30d;
|
||||
const unitsOrganic = Math.max(0, unitsTotal - unitsAds);
|
||||
|
||||
// KPIs
|
||||
const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0;
|
||||
const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0;
|
||||
const roas = adData.cost > 0 ? salesAds / adData.cost : 0;
|
||||
const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0;
|
||||
const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0;
|
||||
// CVR (Units / Clicks)
|
||||
const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0;
|
||||
|
||||
const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0;
|
||||
const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0;
|
||||
|
||||
return {
|
||||
id: sale.id,
|
||||
marketplace: sale.customer,
|
||||
month: sale.month,
|
||||
year: sale.year,
|
||||
asin: sale.asin,
|
||||
title: sale.title,
|
||||
line: sale.line,
|
||||
sku: sale.sku,
|
||||
|
||||
salesTotal,
|
||||
unitsTotal,
|
||||
|
||||
salesAds,
|
||||
unitsAds,
|
||||
cost: adData.cost,
|
||||
clicks: adData.clicks,
|
||||
impressions: adData.impressions,
|
||||
|
||||
salesOrganic,
|
||||
unitsOrganic,
|
||||
|
||||
paidSalesShare,
|
||||
organicSalesShare,
|
||||
|
||||
acos,
|
||||
tacos,
|
||||
roas,
|
||||
ctr,
|
||||
cpc,
|
||||
cvrUnits
|
||||
};
|
||||
});
|
||||
|
||||
return mergedData;
|
||||
};
|
||||
|
||||
|
||||
// --- EXISTING HELPERS ---
|
||||
|
||||
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
|
||||
return data.filter(item => {
|
||||
// Item month is already normalized
|
||||
const recordMonth = item.month;
|
||||
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
|
||||
const recordMonth = item.month; // e.g. "Apr-23"
|
||||
const pureMonth = recordMonth.split('-')[0]; // "Apr"
|
||||
|
||||
// 2. Filter Checks
|
||||
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
|
||||
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
|
||||
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
|
||||
|
||||
// Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve
|
||||
const monthMatch = filters.month.length === 0 || filters.month.includes(pureMonth) || filters.month.includes(recordMonth);
|
||||
|
||||
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
|
||||
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
|
||||
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
|
||||
@@ -227,17 +492,22 @@ const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPo
|
||||
|
||||
data.forEach(record => {
|
||||
const monthName = record.month;
|
||||
// Extract year from record.month if it's in Format "Mon-YY", else use record.year
|
||||
// record.year is numeric, record.month is "Apr-23".
|
||||
const yearStr = record.year.toString();
|
||||
yearsSet.add(yearStr);
|
||||
|
||||
// We need to match month name purely (Jan, Feb) for the X Axis, ignoring year
|
||||
const pureMonth = monthName.split('-')[0];
|
||||
|
||||
if (seasonalityMap.has(monthName)) {
|
||||
if (seasonalityMap.has(pureMonth)) {
|
||||
// Sell Out
|
||||
const entrySO = seasonalityMap.get(monthName)!;
|
||||
const entrySO = seasonalityMap.get(pureMonth)!;
|
||||
const currentValSO = (entrySO[yearStr] as number) || 0;
|
||||
entrySO[yearStr] = currentValSO + record.sellOut;
|
||||
|
||||
// Units
|
||||
const entryUnits = seasonalityUnitsMap.get(monthName)!;
|
||||
const entryUnits = seasonalityUnitsMap.get(pureMonth)!;
|
||||
const currentValUnits = (entryUnits[yearStr] as number) || 0;
|
||||
entryUnits[yearStr] = currentValUnits + record.units;
|
||||
}
|
||||
@@ -629,7 +899,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit
|
||||
}
|
||||
|
||||
const row = map.get(key)!;
|
||||
const monthPart = record.month;
|
||||
const monthPart = record.month.split('-')[0]; // Handle "Apr-23" -> "Apr"
|
||||
const monthIdx = MONTH_ORDER.indexOf(monthPart);
|
||||
const yearStr = record.year.toString();
|
||||
|
||||
|
||||
+55
-46
@@ -2,39 +2,49 @@
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { AggregatedData } from "../types";
|
||||
|
||||
// Declare process to avoid TypeScript errors without causing aggressive bundler shims
|
||||
declare const process: any;
|
||||
|
||||
const SYSTEM_INSTRUCTION = `
|
||||
You are an expert Data Analyst Assistant for "Craze Analytix".
|
||||
You have access to a structured dataset of sales performance including Revenue (Sell Out), Units, Product Lines, and Seasonality.
|
||||
You are a senior data analyst assistant for a retail dashboard called "Craze Analytix".
|
||||
You have access to a detailed report of the currently filtered sales data.
|
||||
The data includes Sell Out (Revenue in €), Units Sold, Product Lines, Customers/Markets, and Seasonality trends.
|
||||
|
||||
Your Capabilities:
|
||||
1. **Analyze Trends**: Use the provided Seasonality and Yearly Breakdown data.
|
||||
2. **Perform Calculations**: You have access to detailed Product Line totals. You MUST calculate growth percentages, market shares, and sums dynamically if the user asks.
|
||||
3. **Compare**: Compare performance between years (e.g., 2024 vs 2025).
|
||||
|
||||
Rules:
|
||||
- If the user asks for a calculation (e.g., "What is the % share of Line X?"), perform the math using the provided numbers.
|
||||
- Always format currency as € (e.g., €1,200) and units with 'u' or 'units' (e.g., 500 units).
|
||||
- Be concise but insightful. Point out significant growth or decline.
|
||||
- If data is missing for a specific query, state clearly that it is not in the current filtered view.
|
||||
Your goal is to answer user questions specific to the provided data.
|
||||
- If asked about "Trends" or "Seasonality", look at the Monthly Seasonality section.
|
||||
- If asked about "Growth" or "Decline", look at the Top/Bottom Movers sections.
|
||||
- If asked about specific Product Lines, look at the Product Line Breakdown.
|
||||
- Always format numbers clearly (e.g., "€1.2M", "€5,200", "15k units").
|
||||
- When comparing years, calculate the percentage difference if not explicitly provided.
|
||||
- Keep answers professional, concise, and business-focused.
|
||||
`;
|
||||
|
||||
// Helper to get API key safely
|
||||
const getApiKey = (): string | undefined => {
|
||||
try {
|
||||
return process.env.API_KEY;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (val: number) => `€${val.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
|
||||
const formatUnits = (val: number) => `${val.toLocaleString()} units`;
|
||||
|
||||
export const queryGemini = async (
|
||||
apiKey: string,
|
||||
question: string,
|
||||
context: AggregatedData,
|
||||
filteredRecordCount: number
|
||||
): Promise<string> => {
|
||||
|
||||
const apiKey = getApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return "Please provide your Gemini API Key in the settings to enable the AI assistant.";
|
||||
return "API Key is missing. Please configure your environment variables (API_KEY) or check your .env file.";
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure the key is clean of whitespace
|
||||
const ai = new GoogleGenAI({ apiKey: apiKey.trim() });
|
||||
const ai = new GoogleGenAI({ apiKey });
|
||||
|
||||
// --- CONTEXT GENERATION ---
|
||||
// We construct a structured report mirroring the dashboard charts
|
||||
@@ -46,62 +56,64 @@ export const queryGemini = async (
|
||||
.join('\n');
|
||||
|
||||
// 2. Seasonality (Line Chart Data)
|
||||
// We simplify this to a CSV-like list for the AI to parse trends
|
||||
const seasonalitySummary = context.seasonality.map(p => {
|
||||
// Extract values for each year in the point
|
||||
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
|
||||
return ` - ${p.name}: [${yearValues}]`;
|
||||
}).join('\n');
|
||||
|
||||
// 3. Growth/Decline
|
||||
// 3. Top Movers (Growth Table) - Limit to Top 10
|
||||
const growthSummary = context.topMovers.slice(0, 10).map(m =>
|
||||
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
|
||||
).join('\n');
|
||||
|
||||
// 4. Declining Movers (Decline Table) - Limit to Top 10
|
||||
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
|
||||
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
|
||||
).join('\n');
|
||||
|
||||
// 4. DETAILED BREAKDOWN (For Calculations)
|
||||
// We provide a JSON-like structure of ALL top product lines so the AI can compute shares/totals.
|
||||
// We limit this to top 100 to avoid token limits, which covers most relevant data.
|
||||
const detailedLines = context.byLine.slice(0, 100).map(l => ({
|
||||
name: l.name,
|
||||
revenue: l.value,
|
||||
units: l.units
|
||||
}));
|
||||
// 5. Product Lines Overview (Bar Charts) - Limit to Top 50 to save tokens but give depth
|
||||
const topLinesSummary = context.byLine.slice(0, 50).map((l, i) =>
|
||||
` ${i+1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}`
|
||||
).join('\n');
|
||||
|
||||
// 6. Customer Distribution (Customer Chart)
|
||||
const customerSummary = context.byCustomer.map(c =>
|
||||
` - ${c.name}: ${formatCurrency(c.value)}`
|
||||
).join('\n');
|
||||
|
||||
const fullReport = `
|
||||
REPORT CONTEXT (Based on Current Filters):
|
||||
------------------------------------------
|
||||
GLOBAL METRICS:
|
||||
REPORT CONTEXT:
|
||||
----------------
|
||||
GLOBAL TOTALS:
|
||||
Total Sell Out: ${formatCurrency(context.totalSellOut)}
|
||||
Total Units: ${formatUnits(context.totalUnits)}
|
||||
Records Analyzed: ${filteredRecordCount}
|
||||
Years Available: ${context.availableYears.join(', ')}
|
||||
|
||||
YEARLY TOTALS:
|
||||
YEARLY BREAKDOWN:
|
||||
${yearlySummary}
|
||||
|
||||
MONTHLY TRENDS (Seasonality):
|
||||
MONTHLY SEASONALITY (Revenue Trends):
|
||||
${seasonalitySummary}
|
||||
|
||||
TOP PERFORMERS (Growth YoY):
|
||||
FASTEST GROWING LINES (Year-over-Year):
|
||||
${growthSummary}
|
||||
|
||||
WORST PERFORMERS (Decline YoY):
|
||||
DECLINING LINES (Year-over-Year):
|
||||
${declineSummary}
|
||||
|
||||
DETAILED PRODUCT LINE DATA (Use this for specific calculations):
|
||||
${JSON.stringify(detailedLines, null, 2)}
|
||||
TOP PRODUCT LINES (Revenue & Units):
|
||||
${topLinesSummary}
|
||||
|
||||
PERFORMANCE BY CUSTOMER:
|
||||
${customerSummary}
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview', // Updated to the latest capable model for complex reasoning
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: `Context Data:\n${fullReport}\n\nUser Question: ${question}` }]
|
||||
}
|
||||
],
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: `Context Data:\n${fullReport}\n\nUser Question: ${question}`,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_INSTRUCTION,
|
||||
}
|
||||
@@ -111,11 +123,8 @@ ${JSON.stringify(detailedLines, null, 2)}
|
||||
} catch (error: any) {
|
||||
console.error("Gemini API Error:", error);
|
||||
|
||||
if (error.message && error.message.includes("403")) {
|
||||
return "Error 403: Invalid API Key. Please check your key in the settings.";
|
||||
}
|
||||
if (error.message && error.message.includes("429")) {
|
||||
return "Error 429: Quota exceeded. You are sending too many requests.";
|
||||
if (error.message && error.message.includes("Not implemented on this platform")) {
|
||||
return "System Error: The AI SDK detected a platform mismatch.";
|
||||
}
|
||||
|
||||
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;
|
||||
|
||||
Reference in New Issue
Block a user