fix: Add missing imports and type annotations
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add authMiddleware import to tasks.ts - Fix pool import in SalesAnalyticsService.ts - Add type annotations to map callbacks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { authMiddleware } from '../auth/middleware';
|
||||||
import {
|
import {
|
||||||
taskService,
|
taskService,
|
||||||
TaskRole,
|
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;
|
export default router;
|
||||||
|
|||||||
589
backend/src/services/analytics/SalesAnalyticsService.ts
Normal file
589
backend/src/services/analytics/SalesAnalyticsService.ts
Normal file
@@ -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<DailySalesEstimate[]> {
|
||||||
|
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<BrandMarketShare[]> {
|
||||||
|
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<SkuVelocity[]> {
|
||||||
|
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<StorePerformance[]> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<CategoryWeeklyTrend[]> {
|
||||||
|
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<ProductIntelligence[]> {
|
||||||
|
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<Array<{
|
||||||
|
brand_name: string;
|
||||||
|
total_revenue: number;
|
||||||
|
total_units: number;
|
||||||
|
store_count: number;
|
||||||
|
sku_count: number;
|
||||||
|
avg_price: number | null;
|
||||||
|
}>> {
|
||||||
|
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<ViewRefreshResult[]> {
|
||||||
|
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<Record<string, number>> {
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user