diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 1b85948e..8764eefa 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -26,6 +26,7 @@ */ import { Router, Request, Response } from 'express'; +import { authMiddleware } from '../auth/middleware'; import { taskService, TaskRole, @@ -1918,4 +1919,292 @@ router.get('/pools/:id', async (req: Request, res: Response) => { } }); +// ============================================================ +// INVENTORY SNAPSHOTS API +// Part of Real-Time Inventory Tracking feature +// ============================================================ + +/** + * GET /inventory-snapshots + * Get inventory snapshots with optional filters + */ +router.get('/inventory-snapshots', authMiddleware, async (req: Request, res: Response) => { + try { + const dispensaryId = req.query.dispensary_id ? parseInt(req.query.dispensary_id as string) : undefined; + const productId = req.query.product_id as string | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + const offset = req.query.offset ? parseInt(req.query.offset as string) : 0; + + let query = ` + SELECT + s.id, + s.dispensary_id, + d.name as dispensary_name, + s.product_id, + s.platform, + s.quantity_available, + s.is_below_threshold, + s.status, + s.price_rec, + s.price_med, + s.brand_name, + s.category, + s.product_name, + s.captured_at + FROM inventory_snapshots s + JOIN dispensaries d ON d.id = s.dispensary_id + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (dispensaryId) { + query += ` AND s.dispensary_id = $${paramIndex++}`; + params.push(dispensaryId); + } + + if (productId) { + query += ` AND s.product_id = $${paramIndex++}`; + params.push(productId); + } + + query += ` ORDER BY s.captured_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + params.push(limit, offset); + + const { rows } = await pool.query(query, params); + + // Get total count + let countQuery = `SELECT COUNT(*) FROM inventory_snapshots WHERE 1=1`; + const countParams: any[] = []; + let countParamIndex = 1; + + if (dispensaryId) { + countQuery += ` AND dispensary_id = $${countParamIndex++}`; + countParams.push(dispensaryId); + } + if (productId) { + countQuery += ` AND product_id = $${countParamIndex++}`; + countParams.push(productId); + } + + const { rows: countRows } = await pool.query(countQuery, countParams); + const total = parseInt(countRows[0].count); + + res.json({ + success: true, + snapshots: rows, + count: total, + limit, + offset, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * GET /inventory-snapshots/stats + * Get inventory snapshot statistics + */ +router.get('/inventory-snapshots/stats', authMiddleware, async (req: Request, res: Response) => { + try { + const { rows } = await pool.query(` + SELECT + COUNT(*) as total_snapshots, + COUNT(DISTINCT dispensary_id) as stores_tracked, + COUNT(DISTINCT product_id) as products_tracked, + MIN(captured_at) as oldest_snapshot, + MAX(captured_at) as newest_snapshot, + COUNT(*) FILTER (WHERE captured_at > NOW() - INTERVAL '24 hours') as snapshots_24h, + COUNT(*) FILTER (WHERE captured_at > NOW() - INTERVAL '1 hour') as snapshots_1h + FROM inventory_snapshots + `); + + res.json({ + success: true, + stats: rows[0], + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +// ============================================================ +// VISIBILITY EVENTS API +// Part of Real-Time Inventory Tracking feature +// ============================================================ + +/** + * GET /visibility-events + * Get visibility events with optional filters + */ +router.get('/visibility-events', authMiddleware, async (req: Request, res: Response) => { + try { + const dispensaryId = req.query.dispensary_id ? parseInt(req.query.dispensary_id as string) : undefined; + const brand = req.query.brand as string | undefined; + const eventType = req.query.event_type as string | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + const offset = req.query.offset ? parseInt(req.query.offset as string) : 0; + + let query = ` + SELECT + e.id, + e.dispensary_id, + d.name as dispensary_name, + e.product_id, + e.product_name, + e.brand_name, + e.event_type, + e.detected_at, + e.previous_quantity, + e.previous_price, + e.new_price, + e.price_change_pct, + e.platform, + e.notified, + e.acknowledged_at + FROM product_visibility_events e + JOIN dispensaries d ON d.id = e.dispensary_id + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (dispensaryId) { + query += ` AND e.dispensary_id = $${paramIndex++}`; + params.push(dispensaryId); + } + + if (brand) { + query += ` AND e.brand_name ILIKE $${paramIndex++}`; + params.push(`%${brand}%`); + } + + if (eventType) { + query += ` AND e.event_type = $${paramIndex++}`; + params.push(eventType); + } + + query += ` ORDER BY e.detected_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + params.push(limit, offset); + + const { rows } = await pool.query(query, params); + + // Get total count + let countQuery = `SELECT COUNT(*) FROM product_visibility_events WHERE 1=1`; + const countParams: any[] = []; + let countParamIndex = 1; + + if (dispensaryId) { + countQuery += ` AND dispensary_id = $${countParamIndex++}`; + countParams.push(dispensaryId); + } + if (brand) { + countQuery += ` AND brand_name ILIKE $${countParamIndex++}`; + countParams.push(`%${brand}%`); + } + if (eventType) { + countQuery += ` AND event_type = $${countParamIndex++}`; + countParams.push(eventType); + } + + const { rows: countRows } = await pool.query(countQuery, countParams); + const total = parseInt(countRows[0].count); + + res.json({ + success: true, + events: rows, + count: total, + limit, + offset, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * GET /visibility-events/stats + * Get visibility event statistics + */ +router.get('/visibility-events/stats', authMiddleware, async (req: Request, res: Response) => { + try { + const { rows } = await pool.query(` + SELECT + COUNT(*) as total_events, + COUNT(*) FILTER (WHERE event_type = 'oos') as oos_events, + COUNT(*) FILTER (WHERE event_type = 'back_in_stock') as back_in_stock_events, + COUNT(*) FILTER (WHERE event_type = 'brand_dropped') as brand_dropped_events, + COUNT(*) FILTER (WHERE event_type = 'brand_added') as brand_added_events, + COUNT(*) FILTER (WHERE event_type = 'price_change') as price_change_events, + COUNT(*) FILTER (WHERE detected_at > NOW() - INTERVAL '24 hours') as events_24h, + COUNT(*) FILTER (WHERE acknowledged_at IS NOT NULL) as acknowledged_events, + COUNT(*) FILTER (WHERE notified = TRUE) as notified_events + FROM product_visibility_events + `); + + res.json({ + success: true, + stats: rows[0], + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * POST /visibility-events/:id/acknowledge + * Acknowledge a visibility event + */ +router.post('/visibility-events/:id/acknowledge', authMiddleware, async (req: Request, res: Response) => { + try { + const eventId = parseInt(req.params.id); + const acknowledgedBy = (req as any).user?.email || 'unknown'; + + await pool.query(` + UPDATE product_visibility_events + SET acknowledged_at = NOW(), + acknowledged_by = $2 + WHERE id = $1 + `, [eventId, acknowledgedBy]); + + res.json({ + success: true, + message: 'Event acknowledged', + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * POST /visibility-events/acknowledge-bulk + * Acknowledge multiple visibility events + */ +router.post('/visibility-events/acknowledge-bulk', authMiddleware, async (req: Request, res: Response) => { + try { + const { event_ids } = req.body; + if (!event_ids || !Array.isArray(event_ids)) { + return res.status(400).json({ success: false, error: 'event_ids array required' }); + } + + const acknowledgedBy = (req as any).user?.email || 'unknown'; + + const { rowCount } = await pool.query(` + UPDATE product_visibility_events + SET acknowledged_at = NOW(), + acknowledged_by = $2 + WHERE id = ANY($1) + `, [event_ids, acknowledgedBy]); + + res.json({ + success: true, + message: `${rowCount} events acknowledged`, + count: rowCount, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + export default router; diff --git a/backend/src/services/analytics/SalesAnalyticsService.ts b/backend/src/services/analytics/SalesAnalyticsService.ts new file mode 100644 index 00000000..a05c975a --- /dev/null +++ b/backend/src/services/analytics/SalesAnalyticsService.ts @@ -0,0 +1,589 @@ +/** + * 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();