diff --git a/App.tsx b/App.tsx index 110f826..2befa0e 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,7 @@ import Dashboard from './components/Dashboard'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processBSRExcel, filterBsrData } from './services/dataProcessor'; +import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processBSRExcel, filterBsrData, isAllowedCustomer, isRealSale } from './services/dataProcessor'; import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; @@ -537,18 +537,11 @@ const App: React.FC = () => { const filteredData = useMemo(() => { const baseFiltered = filterData(rawData, deferredFilters, stockMap, vendorStockMap, top50Mode); - // Safety check: Final redundancy to ensure marketing/ad spend records never reach the dashboard cards. - // This is a "double-lock" to prevent revenue inflation even if the service layer fails to update. + // DEFINITIVE REVENUE FILTER (BRUTE FORCE): + // This is the absolute final safeguard against revenue inflation. + // It enforces the strict isRealSale and isAllowedCustomer rules at the UI layer. return baseFiltered.filter(r => { - if (r.units === 0) return false; - - // Marketplace check - const normC = r.customer?.trim().toLowerCase(); - const isAmazon = normC.startsWith('amazon') || normC === 'pan-eu'; - const isOnlyCountry = ['uk', 'de', 'it', 'fr', 'es', 'nl', 'se', 'pl', 'be'].includes(normC); - - if (isOnlyCountry && !normC.startsWith('amazon')) return false; - return isAmazon; + return isAllowedCustomer(r.customer) && isRealSale(r); }); }, [rawData, deferredFilters, stockMap, vendorStockMap, top50Mode]); console.timeEnd('filteredData'); diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index b1ac171..8c4964e 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -307,16 +307,48 @@ const isAllowedCustomer = (customer: string): boolean => { if (!customer) return false; const normCustomer = customer.trim().toLowerCase(); - // Strict business rule: Only records from recognized Amazon marketplaces are included in Sell-Out revenue. - // This excludes marketing spend, financial adjustments, and non-Amazon channels. - const isAmazon = normCustomer.startsWith('amazon') || normCustomer === 'pan-eu'; + // STRICT WHITELIST: Only these Exact Names are valid for Sell-Out Revenue. + const whitelist = [ + 'amazon de', 'amazon uk', 'amazon fr', 'amazon es', 'amazon it', + 'amazon nl', 'amazon pl', 'amazon be', 'amazon se', 'amazon sc', + 'pan-eu' + ]; - // Additional check: Ensure it's not JUST a country code (often used in ad-spend records) - const isOnlyCountryCode = ['uk', 'de', 'it', 'fr', 'es', 'nl', 'se', 'pl', 'be'].includes(normCustomer); + if (!whitelist.includes(normCustomer)) return false; + return true; +}; - if (isOnlyCountryCode && !normCustomer.startsWith('amazon')) return false; +/** + * Validates if a record is a real sale or a marketing/spend entry. + * Marketing entries often have placeholder units (1) or missing ASINs. + */ +export const isRealSale = (record: any): boolean => { + const asin = String(record.asin || '').toUpperCase().trim(); + const sku = String(record.sku || '').toUpperCase().trim(); + const title = String(record.title || '').toUpperCase().trim(); + const line = String(record.line || '').toUpperCase().trim(); - return isAmazon; + // 1. Strict ASIN Format: Amazon ASINs are 10 letters/numbers + if (!asin || asin.length < 5 || asin.length > 20) return false; + + // 2. Negative Keywords: Exclude records that suggest marketing or spend instead of a product + const negativeKeywords = [ + 'SPEND', 'MARKETING', 'ADVERTISING', 'AD-SPEND', 'AD_SPEND', + 'SUMMARY', 'TOTAL', 'ADJUSTMENT', 'FINANCIAL', 'CREDIT', 'FEE' + ]; + + for (const kw of negativeKeywords) { + if (asin.includes(kw)) return false; + if (sku.includes(kw)) return false; + if (title.includes(kw)) return false; + if (line.includes(kw)) return false; + } + + // 3. Units check: Real sales must have units. + // Records with 0 units or negative units (unless it's a return, but usually returns have an ASIN) + if (!record.units || record.units <= 0) return false; + + return true; }; // --- SALES / SELL OUT MAPPING --- @@ -1165,7 +1197,7 @@ export const filterData = ( // These filters ensure that only valid Amazon sales are aggregated, // excluding ad spend and marketing records that might be present in the raw source. if (!isAllowedCustomer(item.customer)) return false; - if (item.units === 0) return false; + if (!isRealSale(item)) return false; // 1. Month Logic: Handle "Apr-23" matching "Apr" filter const recordMonth = item.month; // e.g. "Apr-23"