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>
590 lines
17 KiB
TypeScript
590 lines
17 KiB
TypeScript
/**
|
|
* 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();
|