fix: Correct Analytics V2 SQL queries for schema alignment

- Fix JOIN path: store_products -> dispensaries -> states (was incorrectly joining sp.state_id which doesn't exist)
- Fix column names to use *_raw suffixes (category_raw, brand_name_raw, name_raw)
- Fix row mappings to read correct column names from query results
- Add ::timestamp casts for interval arithmetic in StoreAnalyticsService

All Analytics V2 endpoints now work correctly:
- /state/legal-breakdown
- /state/recreational
- /category/all
- /category/rec-vs-med
- /state/:code/summary
- /store/:id/summary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-10 18:52:57 -07:00
parent 1fa9ea496c
commit 9aefb554bc
4 changed files with 142 additions and 127 deletions

View File

@@ -43,14 +43,14 @@ export class CategoryAnalyticsService {
// Get current category metrics // Get current category metrics
const currentResult = await this.pool.query(` const currentResult = await this.pool.query(`
SELECT SELECT
sp.category, sp.category_raw,
COUNT(*) AS sku_count, COUNT(*) AS sku_count,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count, COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
AVG(sp.price_rec) AS avg_price AVG(sp.price_rec) AS avg_price
FROM store_products sp FROM store_products sp
WHERE sp.category = $1 WHERE sp.category_raw = $1
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
GROUP BY sp.category GROUP BY sp.category_raw
`, [category]); `, [category]);
if (currentResult.rows.length === 0) { if (currentResult.rows.length === 0) {
@@ -70,7 +70,7 @@ export class CategoryAnalyticsService {
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count, COUNT(DISTINCT sps.dispensary_id) AS dispensary_count,
AVG(sps.price_rec) AS avg_price AVG(sps.price_rec) AS avg_price
FROM store_product_snapshots sps FROM store_product_snapshots sps
WHERE sps.category = $1 WHERE sps.category_raw = $1
AND sps.captured_at >= $2 AND sps.captured_at >= $2
AND sps.captured_at <= $3 AND sps.captured_at <= $3
AND sps.is_in_stock = TRUE AND sps.is_in_stock = TRUE
@@ -111,8 +111,9 @@ export class CategoryAnalyticsService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count, COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
AVG(sp.price_rec) AS avg_price AVG(sp.price_rec) AS avg_price
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.category = $1 JOIN states s ON s.id = d.state_id
WHERE sp.category_raw = $1
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
GROUP BY s.code, s.name, s.recreational_legal GROUP BY s.code, s.name, s.recreational_legal
ORDER BY sku_count DESC ORDER BY sku_count DESC
@@ -154,24 +155,25 @@ export class CategoryAnalyticsService {
const result = await this.pool.query(` const result = await this.pool.query(`
SELECT SELECT
sp.category, sp.category_raw,
COUNT(*) AS sku_count, COUNT(*) AS sku_count,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count, COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
COUNT(DISTINCT sp.brand_name) AS brand_count, COUNT(DISTINCT sp.brand_name_raw) AS brand_count,
AVG(sp.price_rec) AS avg_price, AVG(sp.price_rec) AS avg_price,
COUNT(DISTINCT s.code) AS state_count COUNT(DISTINCT s.code) AS state_count
FROM store_products sp FROM store_products sp
LEFT JOIN states s ON s.id = sp.state_id LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.category IS NOT NULL JOIN states s ON s.id = d.state_id
WHERE sp.category_raw IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
${stateFilter} ${stateFilter}
GROUP BY sp.category GROUP BY sp.category_raw
ORDER BY sku_count DESC ORDER BY sku_count DESC
LIMIT $1 LIMIT $1
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
sku_count: parseInt(row.sku_count), sku_count: parseInt(row.sku_count),
dispensary_count: parseInt(row.dispensary_count), dispensary_count: parseInt(row.dispensary_count),
brand_count: parseInt(row.brand_count), brand_count: parseInt(row.brand_count),
@@ -188,14 +190,14 @@ export class CategoryAnalyticsService {
let categoryFilter = ''; let categoryFilter = '';
if (category) { if (category) {
categoryFilter = 'WHERE sp.category = $1'; categoryFilter = 'WHERE sp.category_raw = $1';
params.push(category); params.push(category);
} }
const result = await this.pool.query(` const result = await this.pool.query(`
WITH category_stats AS ( WITH category_stats AS (
SELECT SELECT
sp.category, sp.category_raw,
CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END AS legal_type, CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END AS legal_type,
COUNT(DISTINCT s.code) AS state_count, COUNT(DISTINCT s.code) AS state_count,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count, COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
@@ -203,13 +205,14 @@ export class CategoryAnalyticsService {
AVG(sp.price_rec) AS avg_price, AVG(sp.price_rec) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
${categoryFilter} ${categoryFilter}
${category ? 'AND' : 'WHERE'} sp.category IS NOT NULL ${category ? 'AND' : 'WHERE'} sp.category_raw IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE) AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
GROUP BY sp.category, CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END GROUP BY sp.category_raw, CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END
), ),
rec_stats AS ( rec_stats AS (
SELECT * FROM category_stats WHERE legal_type = 'recreational' SELECT * FROM category_stats WHERE legal_type = 'recreational'
@@ -218,7 +221,7 @@ export class CategoryAnalyticsService {
SELECT * FROM category_stats WHERE legal_type = 'medical_only' SELECT * FROM category_stats WHERE legal_type = 'medical_only'
) )
SELECT SELECT
COALESCE(r.category, m.category) AS category, COALESCE(r.category_raw, m.category_raw) AS category,
r.state_count AS rec_state_count, r.state_count AS rec_state_count,
r.dispensary_count AS rec_dispensary_count, r.dispensary_count AS rec_dispensary_count,
r.sku_count AS rec_sku_count, r.sku_count AS rec_sku_count,
@@ -235,7 +238,7 @@ export class CategoryAnalyticsService {
ELSE NULL ELSE NULL
END AS price_diff_percent END AS price_diff_percent
FROM rec_stats r FROM rec_stats r
FULL OUTER JOIN med_stats m ON r.category = m.category FULL OUTER JOIN med_stats m ON r.category_raw = m.category_raw
ORDER BY COALESCE(r.sku_count, 0) + COALESCE(m.sku_count, 0) DESC ORDER BY COALESCE(r.sku_count, 0) + COALESCE(m.sku_count, 0) DESC
`, params); `, params);
@@ -282,7 +285,7 @@ export class CategoryAnalyticsService {
COUNT(*) AS sku_count, COUNT(*) AS sku_count,
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
FROM store_product_snapshots sps FROM store_product_snapshots sps
WHERE sps.category = $1 WHERE sps.category_raw = $1
AND sps.captured_at >= $2 AND sps.captured_at >= $2
AND sps.captured_at <= $3 AND sps.captured_at <= $3
AND sps.is_in_stock = TRUE AND sps.is_in_stock = TRUE
@@ -335,31 +338,33 @@ export class CategoryAnalyticsService {
WITH category_total AS ( WITH category_total AS (
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
FROM store_products sp FROM store_products sp
LEFT JOIN states s ON s.id = sp.state_id LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.category = $1 JOIN states s ON s.id = d.state_id
WHERE sp.category_raw = $1
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.brand_name IS NOT NULL AND sp.brand_name_raw IS NOT NULL
${stateFilter} ${stateFilter}
) )
SELECT SELECT
sp.brand_name, sp.brand_name_raw,
COUNT(*) AS sku_count, COUNT(*) AS sku_count,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count, COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
AVG(sp.price_rec) AS avg_price, AVG(sp.price_rec) AS avg_price,
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF((SELECT total FROM category_total), 0), 2) AS category_share_percent ROUND(COUNT(*)::NUMERIC * 100 / NULLIF((SELECT total FROM category_total), 0), 2) AS category_share_percent
FROM store_products sp FROM store_products sp
LEFT JOIN states s ON s.id = sp.state_id LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.category = $1 JOIN states s ON s.id = d.state_id
WHERE sp.category_raw = $1
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.brand_name IS NOT NULL AND sp.brand_name_raw IS NOT NULL
${stateFilter} ${stateFilter}
GROUP BY sp.brand_name GROUP BY sp.brand_name_raw
ORDER BY sku_count DESC ORDER BY sku_count DESC
LIMIT $2 LIMIT $2
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
brand_name: row.brand_name, brand_name: row.brand_name_raw,
sku_count: parseInt(row.sku_count), sku_count: parseInt(row.sku_count),
dispensary_count: parseInt(row.dispensary_count), dispensary_count: parseInt(row.dispensary_count),
avg_price: row.avg_price ? parseFloat(row.avg_price) : null, avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
@@ -421,7 +426,7 @@ export class CategoryAnalyticsService {
`, [start, end, limit]); `, [start, end, limit]);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
start_sku_count: parseInt(row.start_sku_count), start_sku_count: parseInt(row.start_sku_count),
end_sku_count: parseInt(row.end_sku_count), end_sku_count: parseInt(row.end_sku_count),
growth: parseInt(row.growth), growth: parseInt(row.growth),

View File

@@ -43,9 +43,9 @@ export class PriceAnalyticsService {
const productResult = await this.pool.query(` const productResult = await this.pool.query(`
SELECT SELECT
sp.id, sp.id,
sp.name, sp.name_raw,
sp.brand_name, sp.brand_name_raw,
sp.category, sp.category_raw,
sp.dispensary_id, sp.dispensary_id,
sp.price_rec, sp.price_rec,
sp.price_med, sp.price_med,
@@ -53,7 +53,7 @@ export class PriceAnalyticsService {
s.code AS state_code s.code AS state_code
FROM store_products sp FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id JOIN dispensaries d ON d.id = sp.dispensary_id
LEFT JOIN states s ON s.id = sp.state_id JOIN states s ON s.id = d.state_id
WHERE sp.id = $1 WHERE sp.id = $1
`, [storeProductId]); `, [storeProductId]);
@@ -133,7 +133,7 @@ export class PriceAnalyticsService {
const result = await this.pool.query(` const result = await this.pool.query(`
SELECT SELECT
sp.category, sp.category_raw,
s.code AS state_code, s.code AS state_code,
s.name AS state_name, s.name AS state_name,
CASE CASE
@@ -148,18 +148,18 @@ export class PriceAnalyticsService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
FROM store_products sp FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = sp.state_id JOIN states s ON s.id = d.state_id
WHERE sp.category = $1 WHERE sp.category_raw = $1
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE) AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
${stateFilter} ${stateFilter}
GROUP BY sp.category, s.code, s.name, s.recreational_legal GROUP BY sp.category_raw, s.code, s.name, s.recreational_legal
ORDER BY state_code ORDER BY state_code
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
state_code: row.state_code, state_code: row.state_code,
state_name: row.state_name, state_name: row.state_name,
legal_type: row.legal_type, legal_type: row.legal_type,
@@ -189,7 +189,7 @@ export class PriceAnalyticsService {
const result = await this.pool.query(` const result = await this.pool.query(`
SELECT SELECT
sp.brand_name AS category, sp.brand_name_raw AS category,
s.code AS state_code, s.code AS state_code,
s.name AS state_name, s.name AS state_name,
CASE CASE
@@ -204,18 +204,18 @@ export class PriceAnalyticsService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
FROM store_products sp FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = sp.state_id JOIN states s ON s.id = d.state_id
WHERE sp.brand_name = $1 WHERE sp.brand_name_raw = $1
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE) AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
${stateFilter} ${stateFilter}
GROUP BY sp.brand_name, s.code, s.name, s.recreational_legal GROUP BY sp.brand_name_raw, s.code, s.name, s.recreational_legal
ORDER BY state_code ORDER BY state_code
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
state_code: row.state_code, state_code: row.state_code,
state_name: row.state_name, state_name: row.state_name,
legal_type: row.legal_type, legal_type: row.legal_type,
@@ -254,7 +254,7 @@ export class PriceAnalyticsService {
} }
if (category) { if (category) {
filters += ` AND sp.category = $${paramIdx}`; filters += ` AND sp.category_raw = $${paramIdx}`;
params.push(category); params.push(category);
paramIdx++; paramIdx++;
} }
@@ -288,15 +288,16 @@ export class PriceAnalyticsService {
) )
SELECT SELECT
v.store_product_id, v.store_product_id,
sp.name AS product_name, sp.name_raw AS product_name,
sp.brand_name, sp.brand_name_raw,
v.change_count, v.change_count,
v.avg_change_pct, v.avg_change_pct,
v.max_change_pct, v.max_change_pct,
v.last_change_at v.last_change_at
FROM volatility v FROM volatility v
JOIN store_products sp ON sp.id = v.store_product_id JOIN store_products sp ON sp.id = v.store_product_id
LEFT JOIN states s ON s.id = sp.state_id LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE 1=1 ${filters} WHERE 1=1 ${filters}
ORDER BY v.change_count DESC, v.avg_change_pct DESC ORDER BY v.change_count DESC, v.avg_change_pct DESC
LIMIT $3 LIMIT $3
@@ -305,7 +306,7 @@ export class PriceAnalyticsService {
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
store_product_id: row.store_product_id, store_product_id: row.store_product_id,
product_name: row.product_name, product_name: row.product_name,
brand_name: row.brand_name, brand_name: row.brand_name_raw,
change_count: parseInt(row.change_count), change_count: parseInt(row.change_count),
avg_change_percent: row.avg_change_pct ? parseFloat(row.avg_change_pct) : 0, avg_change_percent: row.avg_change_pct ? parseFloat(row.avg_change_pct) : 0,
max_change_percent: row.max_change_pct ? parseFloat(row.max_change_pct) : 0, max_change_percent: row.max_change_pct ? parseFloat(row.max_change_pct) : 0,
@@ -327,13 +328,13 @@ export class PriceAnalyticsService {
let categoryFilter = ''; let categoryFilter = '';
if (category) { if (category) {
categoryFilter = 'WHERE sp.category = $1'; categoryFilter = 'WHERE sp.category_raw = $1';
params.push(category); params.push(category);
} }
const result = await this.pool.query(` const result = await this.pool.query(`
SELECT SELECT
sp.category, sp.category_raw,
AVG(sp.price_rec) FILTER (WHERE s.recreational_legal = TRUE) AS rec_avg, AVG(sp.price_rec) FILTER (WHERE s.recreational_legal = TRUE) AS rec_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
FILTER (WHERE s.recreational_legal = TRUE) AS rec_median, FILTER (WHERE s.recreational_legal = TRUE) AS rec_median,
@@ -343,17 +344,18 @@ export class PriceAnalyticsService {
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
FILTER (WHERE s.medical_legal = TRUE AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)) AS med_median FILTER (WHERE s.medical_legal = TRUE AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)) AS med_median
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
${categoryFilter} ${categoryFilter}
${category ? 'AND' : 'WHERE'} sp.price_rec IS NOT NULL ${category ? 'AND' : 'WHERE'} sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.category IS NOT NULL AND sp.category_raw IS NOT NULL
GROUP BY sp.category GROUP BY sp.category_raw
ORDER BY sp.category ORDER BY sp.category_raw
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
rec_avg: row.rec_avg ? parseFloat(row.rec_avg) : null, rec_avg: row.rec_avg ? parseFloat(row.rec_avg) : null,
rec_median: row.rec_median ? parseFloat(row.rec_median) : null, rec_median: row.rec_median ? parseFloat(row.rec_median) : null,
med_avg: row.med_avg ? parseFloat(row.med_avg) : null, med_avg: row.med_avg ? parseFloat(row.med_avg) : null,

View File

@@ -108,14 +108,14 @@ export class StateAnalyticsService {
SELECT SELECT
COUNT(DISTINCT d.id) AS dispensary_count, COUNT(DISTINCT d.id) AS dispensary_count,
COUNT(DISTINCT sp.id) AS product_count, COUNT(DISTINCT sp.id) AS product_count,
COUNT(DISTINCT sp.brand_name) FILTER (WHERE sp.brand_name IS NOT NULL) AS brand_count, COUNT(DISTINCT sp.brand_name_raw) FILTER (WHERE sp.brand_name_raw IS NOT NULL) AS brand_count,
COUNT(DISTINCT sp.category) FILTER (WHERE sp.category IS NOT NULL) AS category_count, COUNT(DISTINCT sp.category_raw) FILTER (WHERE sp.category_raw IS NOT NULL) AS category_count,
COUNT(sps.id) AS snapshot_count, COUNT(sps.id) AS snapshot_count,
MAX(sps.captured_at) AS last_crawl_at MAX(sps.captured_at) AS last_crawl_at
FROM states s FROM states s
LEFT JOIN dispensaries d ON d.state_id = s.id LEFT JOIN dispensaries d ON d.state_id = s.id
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
WHERE s.code = $1 WHERE s.code = $1
`, [stateCode]); `, [stateCode]);
@@ -129,7 +129,8 @@ export class StateAnalyticsService {
MIN(price_rec) AS min_price, MIN(price_rec) AS min_price,
MAX(price_rec) AS max_price MAX(price_rec) AS max_price
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE s.code = $1 WHERE s.code = $1
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
@@ -140,14 +141,15 @@ export class StateAnalyticsService {
// Get top categories // Get top categories
const topCategoriesResult = await this.pool.query(` const topCategoriesResult = await this.pool.query(`
SELECT SELECT
sp.category, sp.category_raw,
COUNT(*) AS count COUNT(*) AS count
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE s.code = $1 WHERE s.code = $1
AND sp.category IS NOT NULL AND sp.category_raw IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
GROUP BY sp.category GROUP BY sp.category_raw
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 LIMIT 10
`, [stateCode]); `, [stateCode]);
@@ -155,14 +157,15 @@ export class StateAnalyticsService {
// Get top brands // Get top brands
const topBrandsResult = await this.pool.query(` const topBrandsResult = await this.pool.query(`
SELECT SELECT
sp.brand_name AS brand, sp.brand_name_raw AS brand,
COUNT(*) AS count COUNT(*) AS count
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE s.code = $1 WHERE s.code = $1
AND sp.brand_name IS NOT NULL AND sp.brand_name_raw IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
GROUP BY sp.brand_name GROUP BY sp.brand_name_raw
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 LIMIT 10
`, [stateCode]); `, [stateCode]);
@@ -191,7 +194,7 @@ export class StateAnalyticsService {
max_price: pricing.max_price ? parseFloat(pricing.max_price) : null, max_price: pricing.max_price ? parseFloat(pricing.max_price) : null,
}, },
top_categories: topCategoriesResult.rows.map((row: any) => ({ top_categories: topCategoriesResult.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
count: parseInt(row.count), count: parseInt(row.count),
})), })),
top_brands: topBrandsResult.rows.map((row: any) => ({ top_brands: topBrandsResult.rows.map((row: any) => ({
@@ -215,8 +218,8 @@ export class StateAnalyticsService {
COUNT(sps.id) AS snapshot_count COUNT(sps.id) AS snapshot_count
FROM states s FROM states s
LEFT JOIN dispensaries d ON d.state_id = s.id LEFT JOIN dispensaries d ON d.state_id = s.id
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
WHERE s.recreational_legal = TRUE WHERE s.recreational_legal = TRUE
GROUP BY s.code, s.name GROUP BY s.code, s.name
ORDER BY dispensary_count DESC ORDER BY dispensary_count DESC
@@ -232,8 +235,8 @@ export class StateAnalyticsService {
COUNT(sps.id) AS snapshot_count COUNT(sps.id) AS snapshot_count
FROM states s FROM states s
LEFT JOIN dispensaries d ON d.state_id = s.id LEFT JOIN dispensaries d ON d.state_id = s.id
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
WHERE s.medical_legal = TRUE WHERE s.medical_legal = TRUE
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL) AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
GROUP BY s.code, s.name GROUP BY s.code, s.name
@@ -295,46 +298,48 @@ export class StateAnalyticsService {
let groupBy = 'NULL'; let groupBy = 'NULL';
if (category) { if (category) {
categoryFilter = 'AND sp.category = $1'; categoryFilter = 'AND sp.category_raw = $1';
params.push(category); params.push(category);
groupBy = 'sp.category'; groupBy = 'sp.category_raw';
} else { } else {
groupBy = 'sp.category'; groupBy = 'sp.category_raw';
} }
const result = await this.pool.query(` const result = await this.pool.query(`
WITH rec_prices AS ( WITH rec_prices AS (
SELECT SELECT
${category ? 'sp.category' : 'sp.category'}, ${category ? 'sp.category_raw' : 'sp.category_raw'},
COUNT(DISTINCT s.code) AS state_count, COUNT(DISTINCT s.code) AS state_count,
COUNT(*) AS product_count, COUNT(*) AS product_count,
AVG(sp.price_rec) AS avg_price, AVG(sp.price_rec) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE s.recreational_legal = TRUE WHERE s.recreational_legal = TRUE
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.category IS NOT NULL AND sp.category_raw IS NOT NULL
${categoryFilter} ${categoryFilter}
GROUP BY sp.category GROUP BY sp.category_raw
), ),
med_prices AS ( med_prices AS (
SELECT SELECT
${category ? 'sp.category' : 'sp.category'}, ${category ? 'sp.category_raw' : 'sp.category_raw'},
COUNT(DISTINCT s.code) AS state_count, COUNT(DISTINCT s.code) AS state_count,
COUNT(*) AS product_count, COUNT(*) AS product_count,
AVG(sp.price_rec) AS avg_price, AVG(sp.price_rec) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
FROM store_products sp FROM store_products sp
JOIN states s ON s.id = sp.state_id JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE s.medical_legal = TRUE WHERE s.medical_legal = TRUE
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL) AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.category IS NOT NULL AND sp.category_raw IS NOT NULL
${categoryFilter} ${categoryFilter}
GROUP BY sp.category GROUP BY sp.category_raw
) )
SELECT SELECT
COALESCE(r.category, m.category) AS category, COALESCE(r.category, m.category) AS category,
@@ -357,7 +362,7 @@ export class StateAnalyticsService {
`, params); `, params);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
recreational: { recreational: {
state_count: parseInt(row.rec_state_count) || 0, state_count: parseInt(row.rec_state_count) || 0,
product_count: parseInt(row.rec_product_count) || 0, product_count: parseInt(row.rec_product_count) || 0,
@@ -395,12 +400,12 @@ export class StateAnalyticsService {
COALESCE(s.medical_legal, FALSE) AS medical_legal, COALESCE(s.medical_legal, FALSE) AS medical_legal,
COUNT(DISTINCT d.id) AS dispensary_count, COUNT(DISTINCT d.id) AS dispensary_count,
COUNT(DISTINCT sp.id) AS product_count, COUNT(DISTINCT sp.id) AS product_count,
COUNT(DISTINCT sp.brand_name) FILTER (WHERE sp.brand_name IS NOT NULL) AS brand_count, COUNT(DISTINCT sp.brand_name_raw) FILTER (WHERE sp.brand_name_raw IS NOT NULL) AS brand_count,
MAX(sps.captured_at) AS last_crawl_at MAX(sps.captured_at) AS last_crawl_at
FROM states s FROM states s
LEFT JOIN dispensaries d ON d.state_id = s.id LEFT JOIN dispensaries d ON d.state_id = s.id
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
ORDER BY dispensary_count DESC, s.name ORDER BY dispensary_count DESC, s.name
`); `);
@@ -451,8 +456,8 @@ export class StateAnalyticsService {
END AS gap_reason END AS gap_reason
FROM states s FROM states s
LEFT JOIN dispensaries d ON d.state_id = s.id LEFT JOIN dispensaries d ON d.state_id = s.id
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
WHERE s.recreational_legal = TRUE OR s.medical_legal = TRUE WHERE s.recreational_legal = TRUE OR s.medical_legal = TRUE
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
HAVING COUNT(DISTINCT d.id) = 0 HAVING COUNT(DISTINCT d.id) = 0
@@ -499,7 +504,8 @@ export class StateAnalyticsService {
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price,
COUNT(*) AS product_count COUNT(*) AS product_count
FROM states s FROM states s
JOIN store_products sp ON sp.state_id = s.id JOIN dispensaries d ON d.state_id = s.id
JOIN store_products sp ON sp.dispensary_id = d.id
WHERE sp.price_rec IS NOT NULL WHERE sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE) AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)

View File

@@ -89,22 +89,22 @@ export class StoreAnalyticsService {
// Get brands added/dropped // Get brands added/dropped
const brandsResult = await this.pool.query(` const brandsResult = await this.pool.query(`
WITH start_brands AS ( WITH start_brands AS (
SELECT DISTINCT brand_name SELECT DISTINCT brand_name_raw
FROM store_product_snapshots FROM store_product_snapshots
WHERE dispensary_id = $1 WHERE dispensary_id = $1
AND captured_at >= $2 AND captured_at < $2 + INTERVAL '1 day' AND captured_at >= $2::timestamp AND captured_at < $2::timestamp + INTERVAL '1 day'
AND brand_name IS NOT NULL AND brand_name_raw IS NOT NULL
), ),
end_brands AS ( end_brands AS (
SELECT DISTINCT brand_name SELECT DISTINCT brand_name_raw
FROM store_product_snapshots FROM store_product_snapshots
WHERE dispensary_id = $1 WHERE dispensary_id = $1
AND captured_at >= $3 - INTERVAL '1 day' AND captured_at <= $3 AND captured_at >= $3::timestamp - INTERVAL '1 day' AND captured_at <= $3::timestamp
AND brand_name IS NOT NULL AND brand_name_raw IS NOT NULL
) )
SELECT SELECT
ARRAY(SELECT brand_name FROM end_brands EXCEPT SELECT brand_name FROM start_brands) AS added, ARRAY(SELECT brand_name_raw FROM end_brands EXCEPT SELECT brand_name_raw FROM start_brands) AS added,
ARRAY(SELECT brand_name FROM start_brands EXCEPT SELECT brand_name FROM end_brands) AS dropped ARRAY(SELECT brand_name_raw FROM start_brands EXCEPT SELECT brand_name_raw FROM end_brands) AS dropped
`, [dispensaryId, start, end]); `, [dispensaryId, start, end]);
const brands = brandsResult.rows[0] || { added: [], dropped: [] }; const brands = brandsResult.rows[0] || { added: [], dropped: [] };
@@ -184,9 +184,9 @@ export class StoreAnalyticsService {
-- Products added -- Products added
SELECT SELECT
sp.id AS store_product_id, sp.id AS store_product_id,
sp.name AS product_name, sp.name_raw AS product_name,
sp.brand_name, sp.brand_name_raw,
sp.category, sp.category_raw,
'added' AS event_type, 'added' AS event_type,
sp.first_seen_at AS event_date, sp.first_seen_at AS event_date,
NULL::TEXT AS old_value, NULL::TEXT AS old_value,
@@ -201,9 +201,9 @@ export class StoreAnalyticsService {
-- Stock in/out from snapshots -- Stock in/out from snapshots
SELECT SELECT
sps.store_product_id, sps.store_product_id,
sp.name AS product_name, sp.name_raw AS product_name,
sp.brand_name, sp.brand_name_raw,
sp.category, sp.category_raw,
CASE CASE
WHEN sps.is_in_stock = TRUE AND LAG(sps.is_in_stock) OVER w = FALSE THEN 'stock_in' WHEN sps.is_in_stock = TRUE AND LAG(sps.is_in_stock) OVER w = FALSE THEN 'stock_in'
WHEN sps.is_in_stock = FALSE AND LAG(sps.is_in_stock) OVER w = TRUE THEN 'stock_out' WHEN sps.is_in_stock = FALSE AND LAG(sps.is_in_stock) OVER w = TRUE THEN 'stock_out'
@@ -224,9 +224,9 @@ export class StoreAnalyticsService {
-- Price changes from snapshots -- Price changes from snapshots
SELECT SELECT
sps.store_product_id, sps.store_product_id,
sp.name AS product_name, sp.name_raw AS product_name,
sp.brand_name, sp.brand_name_raw,
sp.category, sp.category_raw,
'price_change' AS event_type, 'price_change' AS event_type,
sps.captured_at AS event_date, sps.captured_at AS event_date,
LAG(sps.price_rec::TEXT) OVER w AS old_value, LAG(sps.price_rec::TEXT) OVER w AS old_value,
@@ -250,8 +250,8 @@ export class StoreAnalyticsService {
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
store_product_id: row.store_product_id, store_product_id: row.store_product_id,
product_name: row.product_name, product_name: row.product_name,
brand_name: row.brand_name, brand_name: row.brand_name_raw,
category: row.category, category: row.category_raw,
event_type: row.event_type, event_type: row.event_type,
event_date: row.event_date ? row.event_date.toISOString() : null, event_date: row.event_date ? row.event_date.toISOString() : null,
old_value: row.old_value, old_value: row.old_value,
@@ -364,8 +364,8 @@ export class StoreAnalyticsService {
changes: result.rows.map((row: any) => ({ changes: result.rows.map((row: any) => ({
store_product_id: row.store_product_id, store_product_id: row.store_product_id,
product_name: row.product_name, product_name: row.product_name,
brand_name: row.brand_name, brand_name: row.brand_name_raw,
category: row.category, category: row.category_raw,
old_quantity: row.old_quantity, old_quantity: row.old_quantity,
new_quantity: row.new_quantity, new_quantity: row.new_quantity,
quantity_delta: row.qty_delta, quantity_delta: row.qty_delta,
@@ -415,14 +415,14 @@ export class StoreAnalyticsService {
// Get top brands // Get top brands
const brandsResult = await this.pool.query(` const brandsResult = await this.pool.query(`
SELECT SELECT
brand_name AS brand, brand_name_raw AS brand,
COUNT(*) AS count, COUNT(*) AS count,
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF($2, 0), 2) AS percent ROUND(COUNT(*)::NUMERIC * 100 / NULLIF($2, 0), 2) AS percent
FROM store_products FROM store_products
WHERE dispensary_id = $1 WHERE dispensary_id = $1
AND brand_name IS NOT NULL AND brand_name_raw IS NOT NULL
AND is_in_stock = TRUE AND is_in_stock = TRUE
GROUP BY brand_name GROUP BY brand_name_raw
ORDER BY count DESC ORDER BY count DESC
LIMIT 20 LIMIT 20
`, [dispensaryId, totalProducts]); `, [dispensaryId, totalProducts]);
@@ -432,7 +432,7 @@ export class StoreAnalyticsService {
in_stock_count: parseInt(totals.in_stock) || 0, in_stock_count: parseInt(totals.in_stock) || 0,
out_of_stock_count: parseInt(totals.out_of_stock) || 0, out_of_stock_count: parseInt(totals.out_of_stock) || 0,
categories: categoriesResult.rows.map((row: any) => ({ categories: categoriesResult.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
count: parseInt(row.count), count: parseInt(row.count),
percent: parseFloat(row.percent) || 0, percent: parseFloat(row.percent) || 0,
})), })),
@@ -574,23 +574,24 @@ export class StoreAnalyticsService {
), ),
market_prices AS ( market_prices AS (
SELECT SELECT
sp.category, sp.category_raw,
AVG(sp.price_rec) AS market_avg AVG(sp.price_rec) AS market_avg
FROM store_products sp FROM store_products sp
WHERE sp.state_id = $2 JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE d.state_id = $2
AND sp.price_rec IS NOT NULL AND sp.price_rec IS NOT NULL
AND sp.is_in_stock = TRUE AND sp.is_in_stock = TRUE
AND sp.category IS NOT NULL AND sp.category_raw IS NOT NULL
GROUP BY sp.category GROUP BY sp.category_raw
) )
SELECT SELECT
sp.category, sp.category_raw,
sp.store_avg AS store_avg_price, sp.store_avg AS store_avg_price,
mp.market_avg AS market_avg_price, mp.market_avg AS market_avg_price,
ROUND(((sp.store_avg - mp.market_avg) / NULLIF(mp.market_avg, 0) * 100)::NUMERIC, 2) AS price_vs_market_percent, ROUND(((sp.store_avg - mp.market_avg) / NULLIF(mp.market_avg, 0) * 100)::NUMERIC, 2) AS price_vs_market_percent,
sp.product_count sp.product_count
FROM store_prices sp FROM store_prices sp
LEFT JOIN market_prices mp ON mp.category = sp.category LEFT JOIN market_prices mp ON mp.category = sp.category_raw
ORDER BY sp.product_count DESC ORDER BY sp.product_count DESC
`, [dispensaryId, dispensary.state_id]); `, [dispensaryId, dispensary.state_id]);
@@ -602,9 +603,10 @@ export class StoreAnalyticsService {
WHERE dispensary_id = $1 AND price_rec IS NOT NULL AND is_in_stock = TRUE WHERE dispensary_id = $1 AND price_rec IS NOT NULL AND is_in_stock = TRUE
), ),
market_avg AS ( market_avg AS (
SELECT AVG(price_rec) AS avg SELECT AVG(sp.price_rec) AS avg
FROM store_products FROM store_products sp
WHERE state_id = $2 AND price_rec IS NOT NULL AND is_in_stock = TRUE JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE d.state_id = $2 AND sp.price_rec IS NOT NULL AND sp.is_in_stock = TRUE
) )
SELECT SELECT
ROUND(((sa.avg - ma.avg) / NULLIF(ma.avg, 0) * 100)::NUMERIC, 2) AS price_vs_market ROUND(((sa.avg - ma.avg) / NULLIF(ma.avg, 0) * 100)::NUMERIC, 2) AS price_vs_market
@@ -615,7 +617,7 @@ export class StoreAnalyticsService {
dispensary_id: dispensaryId, dispensary_id: dispensaryId,
dispensary_name: dispensary.name, dispensary_name: dispensary.name,
categories: result.rows.map((row: any) => ({ categories: result.rows.map((row: any) => ({
category: row.category, category: row.category_raw,
store_avg_price: parseFloat(row.store_avg_price), store_avg_price: parseFloat(row.store_avg_price),
market_avg_price: row.market_avg_price ? parseFloat(row.market_avg_price) : 0, market_avg_price: row.market_avg_price ? parseFloat(row.market_avg_price) : 0,
price_vs_market_percent: row.price_vs_market_percent ? parseFloat(row.price_vs_market_percent) : 0, price_vs_market_percent: row.price_vs_market_percent ? parseFloat(row.price_vs_market_percent) : 0,