feat(analytics): Brand promotional history + specials fix + API key editing
- Add brand promotional history endpoint (GET /api/analytics/v2/brand/:name/promotions) - Tracks when products go on special, duration, discounts, quantity sold estimates - Aggregates by category with frequency metrics (weekly/monthly) - Add quantity changes endpoint (GET /api/analytics/v2/store/:id/quantity-changes) - Filter by direction (increase/decrease/all) for sales vs restock estimation - Fix canonical-upsert to populate stock_quantity and total_quantity_available - Add API key edit functionality in admin UI - Edit allowed domains and IPs - Display domains in list view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -259,6 +259,122 @@ export class StoreAnalyticsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quantity changes for a store (increases/decreases)
|
||||
* Useful for estimating sales (decreases) or restocks (increases)
|
||||
*
|
||||
* @param direction - 'decrease' for likely sales, 'increase' for restocks, 'all' for both
|
||||
*/
|
||||
async getQuantityChanges(
|
||||
dispensaryId: number,
|
||||
options: {
|
||||
window?: TimeWindow;
|
||||
customRange?: DateRange;
|
||||
direction?: 'increase' | 'decrease' | 'all';
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<{
|
||||
dispensary_id: number;
|
||||
window: TimeWindow;
|
||||
direction: string;
|
||||
total_changes: number;
|
||||
total_units_decreased: number;
|
||||
total_units_increased: number;
|
||||
changes: Array<{
|
||||
store_product_id: number;
|
||||
product_name: string;
|
||||
brand_name: string | null;
|
||||
category: string | null;
|
||||
old_quantity: number;
|
||||
new_quantity: number;
|
||||
quantity_delta: number;
|
||||
direction: 'increase' | 'decrease';
|
||||
captured_at: string;
|
||||
}>;
|
||||
}> {
|
||||
const { window = '7d', customRange, direction = 'all', limit = 100 } = options;
|
||||
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||
|
||||
// Build direction filter
|
||||
let directionFilter = '';
|
||||
if (direction === 'decrease') {
|
||||
directionFilter = 'AND qty_delta < 0';
|
||||
} else if (direction === 'increase') {
|
||||
directionFilter = 'AND qty_delta > 0';
|
||||
}
|
||||
|
||||
const result = await this.pool.query(`
|
||||
WITH qty_changes AS (
|
||||
SELECT
|
||||
sps.store_product_id,
|
||||
sp.name_raw AS product_name,
|
||||
sp.brand_name_raw AS brand_name,
|
||||
sp.category_raw AS category,
|
||||
LAG(sps.stock_quantity) OVER w AS old_quantity,
|
||||
sps.stock_quantity AS new_quantity,
|
||||
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta,
|
||||
sps.captured_at
|
||||
FROM store_product_snapshots sps
|
||||
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||
WHERE sps.dispensary_id = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
AND sps.stock_quantity IS NOT NULL
|
||||
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||
)
|
||||
SELECT *
|
||||
FROM qty_changes
|
||||
WHERE old_quantity IS NOT NULL
|
||||
AND qty_delta != 0
|
||||
${directionFilter}
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT $4
|
||||
`, [dispensaryId, start, end, limit]);
|
||||
|
||||
// Calculate totals
|
||||
const totalsResult = await this.pool.query(`
|
||||
WITH qty_changes AS (
|
||||
SELECT
|
||||
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta
|
||||
FROM store_product_snapshots sps
|
||||
WHERE sps.dispensary_id = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
AND sps.stock_quantity IS NOT NULL
|
||||
AND sps.store_product_id IS NOT NULL
|
||||
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE qty_delta != 0) AS total_changes,
|
||||
COALESCE(SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0), 0) AS units_decreased,
|
||||
COALESCE(SUM(qty_delta) FILTER (WHERE qty_delta > 0), 0) AS units_increased
|
||||
FROM qty_changes
|
||||
WHERE qty_delta IS NOT NULL
|
||||
`, [dispensaryId, start, end]);
|
||||
|
||||
const totals = totalsResult.rows[0] || {};
|
||||
|
||||
return {
|
||||
dispensary_id: dispensaryId,
|
||||
window,
|
||||
direction,
|
||||
total_changes: parseInt(totals.total_changes) || 0,
|
||||
total_units_decreased: parseInt(totals.units_decreased) || 0,
|
||||
total_units_increased: parseInt(totals.units_increased) || 0,
|
||||
changes: result.rows.map((row: any) => ({
|
||||
store_product_id: row.store_product_id,
|
||||
product_name: row.product_name,
|
||||
brand_name: row.brand_name,
|
||||
category: row.category,
|
||||
old_quantity: row.old_quantity,
|
||||
new_quantity: row.new_quantity,
|
||||
quantity_delta: row.qty_delta,
|
||||
direction: row.qty_delta > 0 ? 'increase' : 'decrease',
|
||||
captured_at: row.captured_at?.toISOString() || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store inventory composition (categories and brands breakdown)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user