/** * SalesAnalyticsService * * Market intelligence and sales velocity analytics using materialized views. * Provides fast queries for dashboards with pre-computed metrics. * * Data Sources: * - mv_daily_sales_estimates: Daily sales from inventory deltas * - mv_brand_market_share: Brand penetration by state * - mv_sku_velocity: SKU velocity rankings * - mv_store_performance: Dispensary performance rankings * - mv_category_weekly_trends: Weekly category trends * - mv_product_intelligence: Per-product Hoodie-style metrics */ import { pool } from '../../db/pool'; import { TimeWindow, DateRange, getDateRangeFromWindow } from './types'; // ============================================================ // TYPES // ============================================================ export interface DailySalesEstimate { dispensary_id: number; product_id: string; brand_name: string | null; category: string | null; sale_date: string; avg_price: number | null; units_sold: number; units_restocked: number; revenue_estimate: number; snapshot_count: number; } export interface BrandMarketShare { brand_name: string; state_code: string; stores_carrying: number; total_stores: number; penetration_pct: number; sku_count: number; in_stock_skus: number; avg_price: number | null; } export interface SkuVelocity { product_id: string; brand_name: string | null; category: string | null; dispensary_id: number; dispensary_name: string; state_code: string; total_units_30d: number; total_revenue_30d: number; days_with_sales: number; avg_daily_units: number; avg_price: number | null; velocity_tier: 'hot' | 'steady' | 'slow' | 'stale'; } export interface StorePerformance { dispensary_id: number; dispensary_name: string; city: string | null; state_code: string; total_revenue_30d: number; total_units_30d: number; total_skus: number; in_stock_skus: number; unique_brands: number; unique_categories: number; avg_price: number | null; last_updated: string | null; } export interface CategoryWeeklyTrend { category: string; state_code: string; week_start: string; sku_count: number; store_count: number; total_units: number; total_revenue: number; avg_price: number | null; } export interface ProductIntelligence { dispensary_id: number; dispensary_name: string; state_code: string; city: string | null; sku: string; product_name: string | null; brand: string | null; category: string | null; is_in_stock: boolean; stock_status: string | null; stock_quantity: number | null; price: number | null; first_seen: string | null; last_seen: string | null; stock_diff_120: number; days_since_oos: number | null; days_until_stock_out: number | null; avg_daily_units: number | null; } export interface ViewRefreshResult { view_name: string; rows_affected: number; } // ============================================================ // SERVICE CLASS // ============================================================ export class SalesAnalyticsService { /** * Get daily sales estimates with filters */ async getDailySalesEstimates(options: { stateCode?: string; brandName?: string; category?: string; dispensaryId?: number; dateRange?: DateRange; limit?: number; } = {}): Promise { const { stateCode, brandName, category, dispensaryId, dateRange, limit = 100 } = options; const params: (string | number | Date)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`d.state = $${paramIdx++}`); params.push(stateCode); } if (brandName) { conditions.push(`dse.brand_name ILIKE $${paramIdx++}`); params.push(`%${brandName}%`); } if (category) { conditions.push(`dse.category = $${paramIdx++}`); params.push(category); } if (dispensaryId) { conditions.push(`dse.dispensary_id = $${paramIdx++}`); params.push(dispensaryId); } if (dateRange) { conditions.push(`dse.sale_date >= $${paramIdx++}`); params.push(dateRange.start); conditions.push(`dse.sale_date <= $${paramIdx++}`); params.push(dateRange.end); } params.push(limit); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT dse.* FROM mv_daily_sales_estimates dse JOIN dispensaries d ON d.id = dse.dispensary_id ${whereClause} ORDER BY dse.sale_date DESC, dse.revenue_estimate DESC LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ dispensary_id: row.dispensary_id, product_id: row.product_id, brand_name: row.brand_name, category: row.category, sale_date: row.sale_date?.toISOString().split('T')[0] || '', avg_price: row.avg_price ? parseFloat(row.avg_price) : null, units_sold: parseInt(row.units_sold) || 0, units_restocked: parseInt(row.units_restocked) || 0, revenue_estimate: parseFloat(row.revenue_estimate) || 0, snapshot_count: parseInt(row.snapshot_count) || 0, })); } /** * Get brand market share by state */ async getBrandMarketShare(options: { stateCode?: string; brandName?: string; minPenetration?: number; limit?: number; } = {}): Promise { const { stateCode, brandName, minPenetration = 0, limit = 100 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`state_code = $${paramIdx++}`); params.push(stateCode); } if (brandName) { conditions.push(`brand_name ILIKE $${paramIdx++}`); params.push(`%${brandName}%`); } if (minPenetration > 0) { conditions.push(`penetration_pct >= $${paramIdx++}`); params.push(minPenetration); } params.push(limit); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT * FROM mv_brand_market_share ${whereClause} ORDER BY penetration_pct DESC, stores_carrying DESC LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ brand_name: row.brand_name, state_code: row.state_code, stores_carrying: parseInt(row.stores_carrying) || 0, total_stores: parseInt(row.total_stores) || 0, penetration_pct: parseFloat(row.penetration_pct) || 0, sku_count: parseInt(row.sku_count) || 0, in_stock_skus: parseInt(row.in_stock_skus) || 0, avg_price: row.avg_price ? parseFloat(row.avg_price) : null, })); } /** * Get SKU velocity rankings */ async getSkuVelocity(options: { stateCode?: string; brandName?: string; category?: string; dispensaryId?: number; velocityTier?: 'hot' | 'steady' | 'slow' | 'stale'; limit?: number; } = {}): Promise { const { stateCode, brandName, category, dispensaryId, velocityTier, limit = 100 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`state_code = $${paramIdx++}`); params.push(stateCode); } if (brandName) { conditions.push(`brand_name ILIKE $${paramIdx++}`); params.push(`%${brandName}%`); } if (category) { conditions.push(`category = $${paramIdx++}`); params.push(category); } if (dispensaryId) { conditions.push(`dispensary_id = $${paramIdx++}`); params.push(dispensaryId); } if (velocityTier) { conditions.push(`velocity_tier = $${paramIdx++}`); params.push(velocityTier); } params.push(limit); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT * FROM mv_sku_velocity ${whereClause} ORDER BY total_units_30d DESC LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ product_id: row.product_id, brand_name: row.brand_name, category: row.category, dispensary_id: row.dispensary_id, dispensary_name: row.dispensary_name, state_code: row.state_code, total_units_30d: parseInt(row.total_units_30d) || 0, total_revenue_30d: parseFloat(row.total_revenue_30d) || 0, days_with_sales: parseInt(row.days_with_sales) || 0, avg_daily_units: parseFloat(row.avg_daily_units) || 0, avg_price: row.avg_price ? parseFloat(row.avg_price) : null, velocity_tier: row.velocity_tier, })); } /** * Get dispensary performance rankings */ async getStorePerformance(options: { stateCode?: string; sortBy?: 'revenue' | 'units' | 'brands' | 'skus'; limit?: number; } = {}): Promise { const { stateCode, sortBy = 'revenue', limit = 100 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`state_code = $${paramIdx++}`); params.push(stateCode); } params.push(limit); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const orderByMap: Record = { revenue: 'total_revenue_30d DESC', units: 'total_units_30d DESC', brands: 'unique_brands DESC', skus: 'total_skus DESC', }; const orderBy = orderByMap[sortBy] || orderByMap.revenue; const result = await pool.query(` SELECT * FROM mv_store_performance ${whereClause} ORDER BY ${orderBy} LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ dispensary_id: row.dispensary_id, dispensary_name: row.dispensary_name, city: row.city, state_code: row.state_code, total_revenue_30d: parseFloat(row.total_revenue_30d) || 0, total_units_30d: parseInt(row.total_units_30d) || 0, total_skus: parseInt(row.total_skus) || 0, in_stock_skus: parseInt(row.in_stock_skus) || 0, unique_brands: parseInt(row.unique_brands) || 0, unique_categories: parseInt(row.unique_categories) || 0, avg_price: row.avg_price ? parseFloat(row.avg_price) : null, last_updated: row.last_updated?.toISOString() || null, })); } /** * Get category weekly trends */ async getCategoryTrends(options: { stateCode?: string; category?: string; weeks?: number; } = {}): Promise { const { stateCode, category, weeks = 12 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`state_code = $${paramIdx++}`); params.push(stateCode); } if (category) { conditions.push(`category = $${paramIdx++}`); params.push(category); } conditions.push(`week_start >= CURRENT_DATE - INTERVAL '${weeks} weeks'`); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT * FROM mv_category_weekly_trends ${whereClause} ORDER BY week_start DESC, total_revenue DESC `, params); return result.rows.map((row: any) => ({ category: row.category, state_code: row.state_code, week_start: row.week_start?.toISOString().split('T')[0] || '', sku_count: parseInt(row.sku_count) || 0, store_count: parseInt(row.store_count) || 0, total_units: parseInt(row.total_units) || 0, total_revenue: parseFloat(row.total_revenue) || 0, avg_price: row.avg_price ? parseFloat(row.avg_price) : null, })); } /** * Get product intelligence (Hoodie-style per-product metrics) */ async getProductIntelligence(options: { stateCode?: string; brandName?: string; category?: string; dispensaryId?: number; inStockOnly?: boolean; lowStock?: boolean; // days_until_stock_out <= 7 recentOOS?: boolean; // days_since_oos <= 7 limit?: number; } = {}): Promise { const { stateCode, brandName, category, dispensaryId, inStockOnly, lowStock, recentOOS, limit = 100 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; if (stateCode) { conditions.push(`state_code = $${paramIdx++}`); params.push(stateCode); } if (brandName) { conditions.push(`brand ILIKE $${paramIdx++}`); params.push(`%${brandName}%`); } if (category) { conditions.push(`category = $${paramIdx++}`); params.push(category); } if (dispensaryId) { conditions.push(`dispensary_id = $${paramIdx++}`); params.push(dispensaryId); } if (inStockOnly) { conditions.push(`is_in_stock = TRUE`); } if (lowStock) { conditions.push(`days_until_stock_out IS NOT NULL AND days_until_stock_out <= 7`); } if (recentOOS) { conditions.push(`days_since_oos IS NOT NULL AND days_since_oos <= 7`); } params.push(limit); const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT * FROM mv_product_intelligence ${whereClause} ORDER BY CASE WHEN days_until_stock_out IS NOT NULL THEN 0 ELSE 1 END, days_until_stock_out ASC NULLS LAST, stock_quantity DESC LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ dispensary_id: row.dispensary_id, dispensary_name: row.dispensary_name, state_code: row.state_code, city: row.city, sku: row.sku, product_name: row.product_name, brand: row.brand, category: row.category, is_in_stock: row.is_in_stock, stock_status: row.stock_status, stock_quantity: row.stock_quantity ? parseInt(row.stock_quantity) : null, price: row.price ? parseFloat(row.price) : null, first_seen: row.first_seen?.toISOString() || null, last_seen: row.last_seen?.toISOString() || null, stock_diff_120: parseInt(row.stock_diff_120) || 0, days_since_oos: row.days_since_oos ? parseInt(row.days_since_oos) : null, days_until_stock_out: row.days_until_stock_out ? parseInt(row.days_until_stock_out) : null, avg_daily_units: row.avg_daily_units ? parseFloat(row.avg_daily_units) : null, })); } /** * Get top selling brands by revenue */ async getTopBrands(options: { stateCode?: string; window?: TimeWindow; limit?: number; } = {}): Promise> { const { stateCode, window = '30d', limit = 50 } = options; const params: (string | number)[] = []; let paramIdx = 1; const conditions: string[] = []; const dateRange = getDateRangeFromWindow(window); conditions.push(`dse.sale_date >= $${paramIdx++}`); params.push(dateRange.start.toISOString().split('T')[0]); if (stateCode) { conditions.push(`d.state = $${paramIdx++}`); params.push(stateCode); } params.push(limit); const whereClause = `WHERE ${conditions.join(' AND ')}`; const result = await pool.query(` SELECT dse.brand_name, SUM(dse.revenue_estimate) AS total_revenue, SUM(dse.units_sold) AS total_units, COUNT(DISTINCT dse.dispensary_id) AS store_count, COUNT(DISTINCT dse.product_id) AS sku_count, AVG(dse.avg_price) AS avg_price FROM mv_daily_sales_estimates dse JOIN dispensaries d ON d.id = dse.dispensary_id ${whereClause} AND dse.brand_name IS NOT NULL GROUP BY dse.brand_name ORDER BY total_revenue DESC LIMIT $${paramIdx} `, params); return result.rows.map((row: any) => ({ brand_name: row.brand_name, total_revenue: parseFloat(row.total_revenue) || 0, total_units: parseInt(row.total_units) || 0, store_count: parseInt(row.store_count) || 0, sku_count: parseInt(row.sku_count) || 0, avg_price: row.avg_price ? parseFloat(row.avg_price) : null, })); } /** * Refresh all materialized views */ async refreshViews(): Promise { try { const result = await pool.query('SELECT * FROM refresh_sales_analytics_views()'); return result.rows.map((row: any) => ({ view_name: row.view_name, rows_affected: parseInt(row.rows_affected) || 0, })); } catch (error: any) { // If function doesn't exist yet (migration not run), return empty if (error.code === '42883') { console.warn('[SalesAnalytics] refresh_sales_analytics_views() not found - run migration 121'); return []; } throw error; } } /** * Get view statistics (row counts) */ async getViewStats(): Promise> { const views = [ 'mv_daily_sales_estimates', 'mv_brand_market_share', 'mv_sku_velocity', 'mv_store_performance', 'mv_category_weekly_trends', 'mv_product_intelligence', ]; const stats: Record = {}; for (const view of views) { try { const result = await pool.query(`SELECT COUNT(*) FROM ${view}`); stats[view] = parseInt(result.rows[0].count) || 0; } catch { stats[view] = -1; // View doesn't exist yet } } return stats; } } export default new SalesAnalyticsService();