fix: Worker task concurrency limit and inventory tracking
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Fix claim_task to enforce max 5 tasks per worker (was unlimited) - Add session_task_count check before ANY claiming path - Add triggers to auto-decrement count on task complete/release - Update MAX_CONCURRENT_TASKS default from 3 to 5 - Update frontend fallback to show 5 task slots - Add Wasabi S3 storage for payload archival - Add inventory snapshots service (delta-only tracking) - Add sales analytics views and routes - Add high-frequency manager UI components - Reset hardcoded AZ 5-minute intervals (use UI to configure) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
122
CLAUDE.md
122
CLAUDE.md
@@ -72,10 +72,10 @@ Batch everything, push once, wait for user feedback.
|
||||
|
||||
```bash
|
||||
# CORRECT - scale pods (up to 8)
|
||||
kubectl scale deployment/scraper-worker -n dispensary-scraper --replicas=8
|
||||
kubectl scale deployment/scraper-worker -n cannaiq --replicas=8
|
||||
|
||||
# WRONG - will cause OOM crashes
|
||||
kubectl set env deployment/scraper-worker -n dispensary-scraper MAX_CONCURRENT_TASKS=10
|
||||
kubectl set env deployment/scraper-worker -n cannaiq MAX_CONCURRENT_TASKS=10
|
||||
```
|
||||
|
||||
**If K8s API returns ServiceUnavailable:** STOP IMMEDIATELY. Do not retry. The cluster is overloaded.
|
||||
@@ -294,7 +294,7 @@ Workers use Evomi's residential proxy API for geo-targeted proxies on-demand.
|
||||
|
||||
**K8s Secret**: Credentials stored in `scraper-secrets`:
|
||||
```bash
|
||||
kubectl get secret scraper-secrets -n dispensary-scraper -o jsonpath='{.data.EVOMI_PASS}' | base64 -d
|
||||
kubectl get secret scraper-secrets -n cannaiq -o jsonpath='{.data.EVOMI_PASS}' | base64 -d
|
||||
```
|
||||
|
||||
**Proxy URL Format**: `http://{user}_{session}_{geo}:{pass}@{host}:{port}`
|
||||
@@ -373,6 +373,122 @@ curl -X POST http://localhost:3010/api/tasks/crawl-state/AZ \
|
||||
|
||||
---
|
||||
|
||||
## Wasabi S3 Storage (Payload Archive)
|
||||
|
||||
Raw crawl payloads are archived to Wasabi S3 for long-term storage and potential reprocessing.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `WASABI_ACCESS_KEY` | Wasabi access key ID | - |
|
||||
| `WASABI_SECRET_KEY` | Wasabi secret access key | - |
|
||||
| `WASABI_BUCKET` | Bucket name | `cannaiq` |
|
||||
| `WASABI_REGION` | Wasabi region | `us-west-2` |
|
||||
| `WASABI_ENDPOINT` | S3 endpoint URL | `https://s3.us-west-2.wasabisys.com` |
|
||||
|
||||
### Storage Path Format
|
||||
```
|
||||
payloads/{state}/{YYYY-MM-DD}/{dispensary_id}/{platform}_{timestamp}.json.gz
|
||||
```
|
||||
|
||||
Example: `payloads/AZ/2025-12-16/123/dutchie_2025-12-16T10-30-00-000Z.json.gz`
|
||||
|
||||
### Features
|
||||
- **Gzip compression**: ~70% size reduction on JSON payloads
|
||||
- **Automatic archival**: Every crawl is archived (not just daily baselines)
|
||||
- **Metadata**: taskId, productCount, platform stored with each object
|
||||
- **Graceful fallback**: If Wasabi not configured, archival is skipped (no task failure)
|
||||
|
||||
### Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/wasabi-storage.ts` | S3 client and storage functions |
|
||||
| `src/tasks/handlers/product-discovery-dutchie.ts` | Archives Dutchie payloads |
|
||||
| `src/tasks/handlers/product-discovery-jane.ts` | Archives Jane payloads |
|
||||
| `src/tasks/handlers/product-discovery-treez.ts` | Archives Treez payloads |
|
||||
|
||||
### K8s Secret Setup
|
||||
```bash
|
||||
kubectl patch secret scraper-secrets -n cannaiq -p '{"stringData":{
|
||||
"WASABI_ACCESS_KEY": "<access-key>",
|
||||
"WASABI_SECRET_KEY": "<secret-key>"
|
||||
}}'
|
||||
```
|
||||
|
||||
### Usage in Code
|
||||
```typescript
|
||||
import { storePayload, getPayload, listPayloads } from '../services/wasabi-storage';
|
||||
|
||||
// Store a payload
|
||||
const result = await storePayload(dispensaryId, 'AZ', 'dutchie', rawPayload);
|
||||
console.log(result.path); // payloads/AZ/2025-12-16/123/dutchie_...
|
||||
console.log(result.compressedBytes); // Size after gzip
|
||||
|
||||
// Retrieve a payload
|
||||
const payload = await getPayload(result.path);
|
||||
|
||||
// List payloads for a store on a date
|
||||
const paths = await listPayloads(123, 'AZ', '2025-12-16');
|
||||
```
|
||||
|
||||
### Estimated Storage
|
||||
- ~100KB per crawl (compressed)
|
||||
- ~200 stores × 12 crawls/day = 240MB/day
|
||||
- ~7.2GB/month
|
||||
- 5TB capacity = ~5+ years of storage
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Inventory Tracking
|
||||
|
||||
High-frequency crawling for sales velocity and inventory analytics.
|
||||
|
||||
### Crawl Intervals
|
||||
|
||||
| State | Interval | Jitter | Effective Range |
|
||||
|-------|----------|--------|-----------------|
|
||||
| AZ | 5 min | ±3 min | 2-8 min |
|
||||
| Others | 60 min | ±3 min | 57-63 min |
|
||||
|
||||
### Delta-Only Snapshots
|
||||
|
||||
Only store inventory changes, not full state. Reduces storage by ~95%.
|
||||
|
||||
**Change Types**:
|
||||
- `sale`: quantity decreased (qty_delta < 0)
|
||||
- `restock`: quantity increased (qty_delta > 0)
|
||||
- `price_change`: price changed, quantity same
|
||||
- `oos`: went out of stock (qty → 0)
|
||||
- `back_in_stock`: returned to stock (0 → qty)
|
||||
- `new_product`: first time seeing product
|
||||
|
||||
### Revenue Calculation
|
||||
```
|
||||
revenue = ABS(qty_delta) × effective_price
|
||||
effective_price = sale_price if on_special else regular_price
|
||||
```
|
||||
|
||||
### Key Views
|
||||
| View | Purpose |
|
||||
|------|---------|
|
||||
| `v_hourly_sales` | Sales aggregated by hour |
|
||||
| `v_daily_store_sales` | Daily revenue by store |
|
||||
| `v_daily_brand_sales` | Daily brand performance |
|
||||
| `v_product_velocity` | Hot/steady/slow/stale rankings |
|
||||
| `v_stock_out_prediction` | Days until OOS based on velocity |
|
||||
| `v_brand_variants` | SKU counts per brand |
|
||||
|
||||
### Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/inventory-snapshots.ts` | Delta calculation and storage |
|
||||
| `src/services/task-scheduler.ts` | High-frequency scheduling with jitter |
|
||||
| `migrations/125_delta_only_snapshots.sql` | Delta columns and views |
|
||||
| `migrations/126_az_high_frequency.sql` | AZ 5-min intervals |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Purpose |
|
||||
|
||||
383
backend/migrations/121_sales_analytics_views.sql
Normal file
383
backend/migrations/121_sales_analytics_views.sql
Normal file
@@ -0,0 +1,383 @@
|
||||
-- Migration 121: Sales Analytics Materialized Views
|
||||
-- Pre-computed views for sales velocity, brand market share, and store performance
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 1: Daily Sales Estimates (per product/store)
|
||||
-- Calculates delta between consecutive snapshots
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_daily_sales_estimates AS
|
||||
WITH qty_deltas AS (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
brand_name,
|
||||
category,
|
||||
DATE(captured_at) AS sale_date,
|
||||
price_rec,
|
||||
quantity_available,
|
||||
LAG(quantity_available) OVER (
|
||||
PARTITION BY dispensary_id, product_id
|
||||
ORDER BY captured_at
|
||||
) AS prev_quantity
|
||||
FROM inventory_snapshots
|
||||
WHERE quantity_available IS NOT NULL
|
||||
AND captured_at >= NOW() - INTERVAL '30 days'
|
||||
)
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
brand_name,
|
||||
category,
|
||||
sale_date,
|
||||
AVG(price_rec) AS avg_price,
|
||||
SUM(GREATEST(0, COALESCE(prev_quantity, 0) - quantity_available)) AS units_sold,
|
||||
SUM(GREATEST(0, quantity_available - COALESCE(prev_quantity, 0))) AS units_restocked,
|
||||
SUM(GREATEST(0, COALESCE(prev_quantity, 0) - quantity_available) * COALESCE(price_rec, 0)) AS revenue_estimate,
|
||||
COUNT(*) AS snapshot_count
|
||||
FROM qty_deltas
|
||||
WHERE prev_quantity IS NOT NULL
|
||||
GROUP BY dispensary_id, product_id, brand_name, category, sale_date;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_daily_sales_pk
|
||||
ON mv_daily_sales_estimates(dispensary_id, product_id, sale_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_brand
|
||||
ON mv_daily_sales_estimates(brand_name, sale_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_category
|
||||
ON mv_daily_sales_estimates(category, sale_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_date
|
||||
ON mv_daily_sales_estimates(sale_date DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 2: Brand Market Share by State
|
||||
-- Weighted distribution across stores
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_brand_market_share AS
|
||||
WITH brand_presence AS (
|
||||
SELECT
|
||||
sp.brand AS brand_name,
|
||||
d.state AS state_code,
|
||||
COUNT(DISTINCT sp.dispensary_id) AS stores_carrying,
|
||||
COUNT(*) AS sku_count,
|
||||
SUM(CASE WHEN sp.is_in_stock THEN 1 ELSE 0 END) AS in_stock_skus,
|
||||
AVG(sp.price_rec) AS avg_price
|
||||
FROM store_products sp
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
WHERE sp.brand IS NOT NULL
|
||||
AND d.state IS NOT NULL
|
||||
GROUP BY sp.brand, d.state
|
||||
),
|
||||
state_totals AS (
|
||||
SELECT
|
||||
d.state AS state_code,
|
||||
COUNT(DISTINCT d.id) FILTER (WHERE d.crawl_enabled) AS total_stores
|
||||
FROM dispensaries d
|
||||
WHERE d.state IS NOT NULL
|
||||
GROUP BY d.state
|
||||
)
|
||||
SELECT
|
||||
bp.brand_name,
|
||||
bp.state_code,
|
||||
bp.stores_carrying,
|
||||
st.total_stores,
|
||||
ROUND(bp.stores_carrying::NUMERIC * 100 / NULLIF(st.total_stores, 0), 2) AS penetration_pct,
|
||||
bp.sku_count,
|
||||
bp.in_stock_skus,
|
||||
bp.avg_price,
|
||||
NOW() AS calculated_at
|
||||
FROM brand_presence bp
|
||||
JOIN state_totals st ON st.state_code = bp.state_code;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_brand_market_pk
|
||||
ON mv_brand_market_share(brand_name, state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_brand_market_state
|
||||
ON mv_brand_market_share(state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_brand_market_penetration
|
||||
ON mv_brand_market_share(penetration_pct DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 3: SKU Velocity (30-day rolling)
|
||||
-- Average daily units sold per SKU
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_sku_velocity AS
|
||||
SELECT
|
||||
dse.product_id,
|
||||
dse.brand_name,
|
||||
dse.category,
|
||||
dse.dispensary_id,
|
||||
d.name AS dispensary_name,
|
||||
d.state AS state_code,
|
||||
SUM(dse.units_sold) AS total_units_30d,
|
||||
SUM(dse.revenue_estimate) AS total_revenue_30d,
|
||||
COUNT(DISTINCT dse.sale_date) AS days_with_sales,
|
||||
ROUND(SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0), 2) AS avg_daily_units,
|
||||
AVG(dse.avg_price) AS avg_price,
|
||||
CASE
|
||||
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 5 THEN 'hot'
|
||||
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 1 THEN 'steady'
|
||||
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 0.1 THEN 'slow'
|
||||
ELSE 'stale'
|
||||
END AS velocity_tier,
|
||||
NOW() AS calculated_at
|
||||
FROM mv_daily_sales_estimates dse
|
||||
JOIN dispensaries d ON d.id = dse.dispensary_id
|
||||
WHERE dse.sale_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY dse.product_id, dse.brand_name, dse.category, dse.dispensary_id, d.name, d.state;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_sku_velocity_pk
|
||||
ON mv_sku_velocity(dispensary_id, product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_brand
|
||||
ON mv_sku_velocity(brand_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_tier
|
||||
ON mv_sku_velocity(velocity_tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_state
|
||||
ON mv_sku_velocity(state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_units
|
||||
ON mv_sku_velocity(total_units_30d DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 4: Store Performance Rankings
|
||||
-- Revenue estimates and brand diversity per store
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_store_performance AS
|
||||
SELECT
|
||||
d.id AS dispensary_id,
|
||||
d.name AS dispensary_name,
|
||||
d.city,
|
||||
d.state AS state_code,
|
||||
-- Revenue metrics from sales estimates
|
||||
COALESCE(sales.total_revenue_30d, 0) AS total_revenue_30d,
|
||||
COALESCE(sales.total_units_30d, 0) AS total_units_30d,
|
||||
-- Inventory metrics
|
||||
COUNT(DISTINCT sp.id) AS total_skus,
|
||||
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock) AS in_stock_skus,
|
||||
-- Brand diversity
|
||||
COUNT(DISTINCT sp.brand) AS unique_brands,
|
||||
COUNT(DISTINCT sp.category) AS unique_categories,
|
||||
-- Pricing
|
||||
AVG(sp.price_rec) AS avg_price,
|
||||
-- Activity
|
||||
MAX(sp.updated_at) AS last_updated,
|
||||
NOW() AS calculated_at
|
||||
FROM dispensaries d
|
||||
LEFT JOIN store_products sp ON sp.dispensary_id = d.id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
SUM(revenue_estimate) AS total_revenue_30d,
|
||||
SUM(units_sold) AS total_units_30d
|
||||
FROM mv_daily_sales_estimates
|
||||
WHERE sale_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY dispensary_id
|
||||
) sales ON sales.dispensary_id = d.id
|
||||
WHERE d.crawl_enabled = TRUE
|
||||
GROUP BY d.id, d.name, d.city, d.state, sales.total_revenue_30d, sales.total_units_30d;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_store_perf_pk
|
||||
ON mv_store_performance(dispensary_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_store_perf_state
|
||||
ON mv_store_performance(state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_store_perf_revenue
|
||||
ON mv_store_performance(total_revenue_30d DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 5: Weekly Category Trends
|
||||
-- Category performance over time
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_category_weekly_trends AS
|
||||
SELECT
|
||||
dse.category,
|
||||
d.state AS state_code,
|
||||
DATE_TRUNC('week', dse.sale_date)::DATE AS week_start,
|
||||
COUNT(DISTINCT dse.product_id) AS sku_count,
|
||||
COUNT(DISTINCT dse.dispensary_id) AS store_count,
|
||||
SUM(dse.units_sold) AS total_units,
|
||||
SUM(dse.revenue_estimate) AS total_revenue,
|
||||
AVG(dse.avg_price) AS avg_price,
|
||||
NOW() AS calculated_at
|
||||
FROM mv_daily_sales_estimates dse
|
||||
JOIN dispensaries d ON d.id = dse.dispensary_id
|
||||
WHERE dse.category IS NOT NULL
|
||||
AND dse.sale_date >= CURRENT_DATE - INTERVAL '90 days'
|
||||
GROUP BY dse.category, d.state, DATE_TRUNC('week', dse.sale_date);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_cat_weekly_pk
|
||||
ON mv_category_weekly_trends(category, state_code, week_start);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_cat_weekly_state
|
||||
ON mv_category_weekly_trends(state_code, week_start);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_cat_weekly_date
|
||||
ON mv_category_weekly_trends(week_start DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW 6: Product Intelligence (Hoodie-style per-product metrics)
|
||||
-- Includes stock diff, days since OOS, days until stockout
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_product_intelligence AS
|
||||
WITH
|
||||
-- Calculate stock diff over 120 days
|
||||
stock_diff AS (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
-- Get oldest and newest quantity in last 120 days
|
||||
FIRST_VALUE(quantity_available) OVER (
|
||||
PARTITION BY dispensary_id, product_id
|
||||
ORDER BY captured_at ASC
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
||||
) AS qty_120d_ago,
|
||||
LAST_VALUE(quantity_available) OVER (
|
||||
PARTITION BY dispensary_id, product_id
|
||||
ORDER BY captured_at ASC
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
||||
) AS qty_current
|
||||
FROM inventory_snapshots
|
||||
WHERE captured_at >= NOW() - INTERVAL '120 days'
|
||||
),
|
||||
stock_diff_calc AS (
|
||||
SELECT DISTINCT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
qty_current - COALESCE(qty_120d_ago, qty_current) AS stock_diff_120
|
||||
FROM stock_diff
|
||||
),
|
||||
-- Get days since last OOS event
|
||||
last_oos AS (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
MAX(detected_at) AS last_oos_date
|
||||
FROM product_visibility_events
|
||||
WHERE event_type = 'oos'
|
||||
GROUP BY dispensary_id, product_id
|
||||
),
|
||||
-- Calculate avg daily units sold (from velocity view)
|
||||
velocity AS (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
avg_daily_units
|
||||
FROM mv_sku_velocity
|
||||
)
|
||||
SELECT
|
||||
sp.dispensary_id,
|
||||
d.name AS dispensary_name,
|
||||
d.state AS state_code,
|
||||
d.city,
|
||||
sp.provider_product_id AS sku,
|
||||
sp.name_raw AS product_name,
|
||||
sp.brand_name_raw AS brand,
|
||||
sp.category_raw AS category,
|
||||
sp.is_in_stock,
|
||||
sp.stock_status,
|
||||
sp.stock_quantity,
|
||||
sp.price_rec AS price,
|
||||
sp.first_seen_at AS first_seen,
|
||||
sp.last_seen_at AS last_seen,
|
||||
-- Calculated fields
|
||||
COALESCE(sd.stock_diff_120, 0) AS stock_diff_120,
|
||||
CASE
|
||||
WHEN lo.last_oos_date IS NOT NULL
|
||||
THEN EXTRACT(DAY FROM NOW() - lo.last_oos_date)::INT
|
||||
ELSE NULL
|
||||
END AS days_since_oos,
|
||||
-- Days until stockout = current stock / daily burn rate
|
||||
CASE
|
||||
WHEN v.avg_daily_units > 0 AND sp.stock_quantity > 0
|
||||
THEN ROUND(sp.stock_quantity::NUMERIC / v.avg_daily_units)::INT
|
||||
ELSE NULL
|
||||
END AS days_until_stock_out,
|
||||
v.avg_daily_units,
|
||||
NOW() AS calculated_at
|
||||
FROM store_products sp
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
LEFT JOIN stock_diff_calc sd ON sd.dispensary_id = sp.dispensary_id
|
||||
AND sd.product_id = sp.provider_product_id
|
||||
LEFT JOIN last_oos lo ON lo.dispensary_id = sp.dispensary_id
|
||||
AND lo.product_id = sp.provider_product_id
|
||||
LEFT JOIN velocity v ON v.dispensary_id = sp.dispensary_id
|
||||
AND v.product_id = sp.provider_product_id
|
||||
WHERE d.crawl_enabled = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_prod_intel_pk
|
||||
ON mv_product_intelligence(dispensary_id, sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_brand
|
||||
ON mv_product_intelligence(brand);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_state
|
||||
ON mv_product_intelligence(state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_stock_out
|
||||
ON mv_product_intelligence(days_until_stock_out ASC NULLS LAST);
|
||||
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_oos
|
||||
ON mv_product_intelligence(days_since_oos DESC NULLS LAST);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- REFRESH FUNCTION
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION refresh_sales_analytics_views()
|
||||
RETURNS TABLE(view_name TEXT, rows_affected BIGINT) AS $$
|
||||
DECLARE
|
||||
row_count BIGINT;
|
||||
BEGIN
|
||||
-- Must refresh in dependency order:
|
||||
-- 1. daily_sales (base view)
|
||||
-- 2. sku_velocity (depends on daily_sales)
|
||||
-- 3. product_intelligence (depends on sku_velocity)
|
||||
-- 4. others (independent)
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales_estimates;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_daily_sales_estimates;
|
||||
view_name := 'mv_daily_sales_estimates';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_brand_market_share;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_brand_market_share;
|
||||
view_name := 'mv_brand_market_share';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_velocity;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_sku_velocity;
|
||||
view_name := 'mv_sku_velocity';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_store_performance;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_store_performance;
|
||||
view_name := 'mv_store_performance';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_weekly_trends;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_category_weekly_trends;
|
||||
view_name := 'mv_category_weekly_trends';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
|
||||
-- Product intelligence depends on sku_velocity, so refresh last
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_product_intelligence;
|
||||
SELECT COUNT(*) INTO row_count FROM mv_product_intelligence;
|
||||
view_name := 'mv_product_intelligence';
|
||||
rows_affected := row_count;
|
||||
RETURN NEXT;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION refresh_sales_analytics_views IS
|
||||
'Refresh all sales analytics materialized views. Call hourly via scheduler.';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- INITIAL REFRESH (populate views)
|
||||
-- ============================================================
|
||||
-- Note: Initial refresh must be non-concurrent (no unique index yet populated)
|
||||
-- Run these manually after migration:
|
||||
-- REFRESH MATERIALIZED VIEW mv_daily_sales_estimates;
|
||||
-- REFRESH MATERIALIZED VIEW mv_brand_market_share;
|
||||
-- REFRESH MATERIALIZED VIEW mv_sku_velocity;
|
||||
-- REFRESH MATERIALIZED VIEW mv_store_performance;
|
||||
-- REFRESH MATERIALIZED VIEW mv_category_weekly_trends;
|
||||
359
backend/migrations/122_market_intelligence_schema.sql
Normal file
359
backend/migrations/122_market_intelligence_schema.sql
Normal file
@@ -0,0 +1,359 @@
|
||||
-- Migration 122: Market Intelligence Schema
|
||||
-- Separate schema for external market data ingestion
|
||||
-- Supports product, brand, and dispensary data from third-party sources
|
||||
|
||||
-- Create dedicated schema
|
||||
CREATE SCHEMA IF NOT EXISTS market_intel;
|
||||
|
||||
-- ============================================================
|
||||
-- BRANDS: Brand/Company Intelligence
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS market_intel.brands (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Identity
|
||||
brand_name VARCHAR(255) NOT NULL,
|
||||
parent_brand VARCHAR(255),
|
||||
parent_company VARCHAR(255),
|
||||
slug VARCHAR(255),
|
||||
external_id VARCHAR(255) UNIQUE, -- objectID from source
|
||||
|
||||
-- Details
|
||||
brand_description TEXT,
|
||||
brand_logo_url TEXT,
|
||||
brand_url TEXT,
|
||||
linkedin_url TEXT,
|
||||
|
||||
-- Presence
|
||||
states JSONB DEFAULT '[]', -- Array of state names
|
||||
active_variants INTEGER DEFAULT 0,
|
||||
all_variants INTEGER DEFAULT 0,
|
||||
|
||||
-- Metadata
|
||||
source VARCHAR(50) DEFAULT 'external',
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_brands_name ON market_intel.brands(brand_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_brands_parent ON market_intel.brands(parent_brand);
|
||||
CREATE INDEX IF NOT EXISTS idx_brands_external ON market_intel.brands(external_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_brands_states ON market_intel.brands USING GIN(states);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- DISPENSARIES: Dispensary/Store Intelligence
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS market_intel.dispensaries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Identity
|
||||
dispensary_name VARCHAR(255) NOT NULL,
|
||||
dispensary_company_name VARCHAR(255),
|
||||
dispensary_company_id VARCHAR(255),
|
||||
slug VARCHAR(255),
|
||||
external_id VARCHAR(255) UNIQUE, -- objectID from source
|
||||
|
||||
-- Location
|
||||
street_address VARCHAR(255),
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(100),
|
||||
postal_code VARCHAR(20),
|
||||
county_name VARCHAR(100),
|
||||
country_code VARCHAR(10) DEFAULT 'USA',
|
||||
full_address TEXT,
|
||||
latitude DECIMAL(10, 7),
|
||||
longitude DECIMAL(10, 7),
|
||||
timezone VARCHAR(50),
|
||||
urbanicity VARCHAR(50), -- Urban, Suburban, Rural
|
||||
|
||||
-- Contact
|
||||
phone VARCHAR(50),
|
||||
email VARCHAR(255),
|
||||
website TEXT,
|
||||
linkedin_url TEXT,
|
||||
|
||||
-- License
|
||||
license_number VARCHAR(100),
|
||||
license_type VARCHAR(100),
|
||||
|
||||
-- Store Type
|
||||
is_medical BOOLEAN DEFAULT FALSE,
|
||||
is_recreational BOOLEAN DEFAULT FALSE,
|
||||
delivery_enabled BOOLEAN DEFAULT FALSE,
|
||||
curbside_pickup BOOLEAN DEFAULT FALSE,
|
||||
instore_pickup BOOLEAN DEFAULT FALSE,
|
||||
location_type VARCHAR(50), -- RETAIL, DELIVERY, etc.
|
||||
|
||||
-- Sales Estimates
|
||||
estimated_daily_sales DECIMAL(12, 2),
|
||||
estimated_sales DECIMAL(12, 2),
|
||||
avg_daily_sales DECIMAL(12, 2),
|
||||
state_sales_bucket INTEGER,
|
||||
|
||||
-- Customer Demographics
|
||||
affluency JSONB DEFAULT '[]', -- Array of affluency segments
|
||||
age_skew JSONB DEFAULT '[]', -- Array of age brackets
|
||||
customer_segments JSONB DEFAULT '[]', -- Array of segment names
|
||||
|
||||
-- Inventory Stats
|
||||
menus_count INTEGER DEFAULT 0,
|
||||
menus_count_med INTEGER DEFAULT 0,
|
||||
menus_count_rec INTEGER DEFAULT 0,
|
||||
parent_brands JSONB DEFAULT '[]',
|
||||
brand_company_names JSONB DEFAULT '[]',
|
||||
|
||||
-- Business Info
|
||||
banner VARCHAR(255), -- Chain/banner name
|
||||
business_type VARCHAR(50), -- MSO, Independent, etc.
|
||||
pos_system VARCHAR(100),
|
||||
atm_presence BOOLEAN DEFAULT FALSE,
|
||||
tax_included BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Ratings
|
||||
rating DECIMAL(3, 2),
|
||||
reviews_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
is_closed BOOLEAN DEFAULT FALSE,
|
||||
open_date TIMESTAMPTZ,
|
||||
last_updated_at TIMESTAMPTZ,
|
||||
|
||||
-- Media
|
||||
logo_url TEXT,
|
||||
cover_url TEXT,
|
||||
|
||||
-- Metadata
|
||||
source VARCHAR(50) DEFAULT 'external',
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_name ON market_intel.dispensaries(dispensary_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_state ON market_intel.dispensaries(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_city ON market_intel.dispensaries(city);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_external ON market_intel.dispensaries(external_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_banner ON market_intel.dispensaries(banner);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_business_type ON market_intel.dispensaries(business_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_geo ON market_intel.dispensaries(latitude, longitude);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_segments ON market_intel.dispensaries USING GIN(customer_segments);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- PRODUCTS: Product/SKU Intelligence
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS market_intel.products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Identity
|
||||
name VARCHAR(500) NOT NULL,
|
||||
brand VARCHAR(255),
|
||||
brand_id VARCHAR(255),
|
||||
brand_company_name VARCHAR(255),
|
||||
parent_brand VARCHAR(255),
|
||||
external_id VARCHAR(255) UNIQUE, -- objectID from source
|
||||
cm_id VARCHAR(100), -- Canonical menu ID
|
||||
|
||||
-- Category Hierarchy
|
||||
category_0 VARCHAR(100), -- Top level: Flower, Edibles, Vapes
|
||||
category_1 VARCHAR(255), -- Mid level: Flower > Pre-Rolls
|
||||
category_2 VARCHAR(500), -- Detailed: Flower > Pre-Rolls > Singles
|
||||
|
||||
-- Cannabis Classification
|
||||
cannabis_type VARCHAR(50), -- SATIVA, INDICA, HYBRID
|
||||
strain VARCHAR(255),
|
||||
flavor VARCHAR(255),
|
||||
pack_size VARCHAR(100),
|
||||
description TEXT,
|
||||
|
||||
-- Cannabinoids
|
||||
thc_mg DECIMAL(10, 2),
|
||||
cbd_mg DECIMAL(10, 2),
|
||||
percent_thc DECIMAL(5, 2),
|
||||
percent_cbd DECIMAL(5, 2),
|
||||
|
||||
-- Dispensary Context (denormalized for query performance)
|
||||
master_dispensary_name VARCHAR(255),
|
||||
master_dispensary_id VARCHAR(255),
|
||||
dispensary_count INTEGER DEFAULT 0, -- How many stores carry this
|
||||
d_state VARCHAR(100),
|
||||
d_city VARCHAR(100),
|
||||
d_banner VARCHAR(255),
|
||||
d_business_type VARCHAR(50),
|
||||
d_medical BOOLEAN,
|
||||
d_recreational BOOLEAN,
|
||||
|
||||
-- Customer Demographics (from dispensary)
|
||||
d_customer_segments JSONB DEFAULT '[]',
|
||||
d_age_skew JSONB DEFAULT '[]',
|
||||
d_affluency JSONB DEFAULT '[]',
|
||||
d_urbanicity VARCHAR(50),
|
||||
|
||||
-- Stock Status
|
||||
in_stock BOOLEAN DEFAULT TRUE,
|
||||
last_seen_at DATE,
|
||||
last_seen_at_ts BIGINT,
|
||||
|
||||
-- Media
|
||||
img_url TEXT,
|
||||
product_url TEXT,
|
||||
menu_slug VARCHAR(500),
|
||||
|
||||
-- Geo
|
||||
latitude DECIMAL(10, 7),
|
||||
longitude DECIMAL(10, 7),
|
||||
|
||||
-- Metadata
|
||||
source VARCHAR(50) DEFAULT 'external',
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_name ON market_intel.products(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_brand ON market_intel.products(brand);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_external ON market_intel.products(external_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON market_intel.products(category_0, category_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_cannabis_type ON market_intel.products(cannabis_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_strain ON market_intel.products(strain);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_state ON market_intel.products(d_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_in_stock ON market_intel.products(in_stock);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_dispensary_count ON market_intel.products(dispensary_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_segments ON market_intel.products USING GIN(d_customer_segments);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- PRODUCT_VARIANTS: Variant-Level Data (Pricing, Stock)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS market_intel.product_variants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER REFERENCES market_intel.products(id) ON DELETE CASCADE,
|
||||
|
||||
-- Identity
|
||||
variant_id VARCHAR(255) NOT NULL,
|
||||
pos_sku VARCHAR(255),
|
||||
pos_product_id VARCHAR(255),
|
||||
pos_system VARCHAR(100),
|
||||
|
||||
-- Pricing
|
||||
actual_price DECIMAL(10, 2),
|
||||
original_price DECIMAL(10, 2),
|
||||
discounted_price DECIMAL(10, 2),
|
||||
|
||||
-- Presentation
|
||||
product_presentation VARCHAR(255), -- "100.00 mg", "3.5g", etc.
|
||||
quantity DECIMAL(10, 2),
|
||||
unit VARCHAR(50), -- mg, g, oz, each
|
||||
|
||||
-- Availability
|
||||
is_medical BOOLEAN DEFAULT FALSE,
|
||||
is_recreational BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Stock Intelligence
|
||||
stock_status VARCHAR(50), -- In Stock, Low Stock, Out of Stock
|
||||
stock_diff_120 DECIMAL(10, 2), -- 120-day stock change
|
||||
days_since_oos INTEGER,
|
||||
days_until_stock_out INTEGER,
|
||||
|
||||
-- Timestamps
|
||||
first_seen_at_ts BIGINT,
|
||||
first_seen_at TIMESTAMPTZ,
|
||||
last_seen_at DATE,
|
||||
|
||||
-- Metadata
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(product_id, variant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_product ON market_intel.product_variants(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_sku ON market_intel.product_variants(pos_sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_stock_status ON market_intel.product_variants(stock_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_price ON market_intel.product_variants(actual_price);
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_days_out ON market_intel.product_variants(days_until_stock_out);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- FETCH_LOG: Track data fetches
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS market_intel.fetch_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fetch_type VARCHAR(50) NOT NULL, -- brands, dispensaries, products
|
||||
state_code VARCHAR(10),
|
||||
query_params JSONB,
|
||||
records_fetched INTEGER DEFAULT 0,
|
||||
records_inserted INTEGER DEFAULT 0,
|
||||
records_updated INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fetch_log_type ON market_intel.fetch_log(fetch_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_fetch_log_state ON market_intel.fetch_log(state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_fetch_log_started ON market_intel.fetch_log(started_at DESC);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- HELPER VIEWS
|
||||
-- ============================================================
|
||||
|
||||
-- Brand market presence summary
|
||||
CREATE OR REPLACE VIEW market_intel.v_brand_presence AS
|
||||
SELECT
|
||||
b.brand_name,
|
||||
b.parent_company,
|
||||
b.active_variants,
|
||||
b.all_variants,
|
||||
jsonb_array_length(b.states) as state_count,
|
||||
b.states,
|
||||
b.fetched_at
|
||||
FROM market_intel.brands b
|
||||
ORDER BY b.active_variants DESC;
|
||||
|
||||
-- Dispensary sales rankings by state
|
||||
CREATE OR REPLACE VIEW market_intel.v_dispensary_rankings AS
|
||||
SELECT
|
||||
d.dispensary_name,
|
||||
d.city,
|
||||
d.state,
|
||||
d.banner,
|
||||
d.business_type,
|
||||
d.estimated_daily_sales,
|
||||
d.menus_count,
|
||||
d.is_medical,
|
||||
d.is_recreational,
|
||||
d.customer_segments,
|
||||
RANK() OVER (PARTITION BY d.state ORDER BY d.estimated_daily_sales DESC NULLS LAST) as state_rank
|
||||
FROM market_intel.dispensaries d
|
||||
WHERE d.is_closed = FALSE;
|
||||
|
||||
-- Product distribution by brand and state
|
||||
CREATE OR REPLACE VIEW market_intel.v_product_distribution AS
|
||||
SELECT
|
||||
p.brand,
|
||||
p.d_state as state,
|
||||
p.category_0 as category,
|
||||
COUNT(*) as product_count,
|
||||
COUNT(*) FILTER (WHERE p.in_stock) as in_stock_count,
|
||||
AVG(p.dispensary_count) as avg_store_count,
|
||||
COUNT(DISTINCT p.master_dispensary_id) as unique_stores
|
||||
FROM market_intel.products p
|
||||
GROUP BY p.brand, p.d_state, p.category_0;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
COMMENT ON SCHEMA market_intel IS 'Market intelligence data from external sources';
|
||||
COMMENT ON TABLE market_intel.brands IS 'Brand/company data with multi-state presence';
|
||||
COMMENT ON TABLE market_intel.dispensaries IS 'Dispensary data with sales estimates and demographics';
|
||||
COMMENT ON TABLE market_intel.products IS 'Product/SKU data with cannabinoid and category info';
|
||||
COMMENT ON TABLE market_intel.product_variants IS 'Variant-level pricing and stock data';
|
||||
COMMENT ON TABLE market_intel.fetch_log IS 'Log of data fetches for monitoring';
|
||||
159
backend/migrations/123_extract_provider_fields.sql
Normal file
159
backend/migrations/123_extract_provider_fields.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
-- Migration 123: Extract unmapped fields from provider_data
|
||||
-- These fields exist in our crawl payloads but weren't being stored in columns
|
||||
|
||||
-- ============================================================
|
||||
-- ADD NEW COLUMNS TO store_products
|
||||
-- ============================================================
|
||||
|
||||
-- Cannabis classification (SATIVA, INDICA, HYBRID, CBD)
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS cannabis_type VARCHAR(50);
|
||||
|
||||
-- Canonical IDs from POS systems
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_strain_id VARCHAR(100);
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_vendor_id VARCHAR(100);
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_brand_id VARCHAR(100);
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_category_id VARCHAR(100);
|
||||
|
||||
-- Lab results
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS lab_result_url TEXT;
|
||||
|
||||
-- Flavors (extracted from JSONB to text array for easier querying)
|
||||
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS flavors_list TEXT[];
|
||||
|
||||
-- ============================================================
|
||||
-- BACKFILL FROM provider_data
|
||||
-- ============================================================
|
||||
|
||||
-- Backfill cannabis_type from classification
|
||||
UPDATE store_products
|
||||
SET cannabis_type = CASE
|
||||
WHEN provider_data->>'classification' IN ('HYBRID', 'H') THEN 'HYBRID'
|
||||
WHEN provider_data->>'classification' IN ('INDICA', 'I') THEN 'INDICA'
|
||||
WHEN provider_data->>'classification' IN ('SATIVA', 'S') THEN 'SATIVA'
|
||||
WHEN provider_data->>'classification' = 'I/S' THEN 'INDICA_DOMINANT'
|
||||
WHEN provider_data->>'classification' = 'S/I' THEN 'SATIVA_DOMINANT'
|
||||
WHEN provider_data->>'classification' = 'CBD' THEN 'CBD'
|
||||
ELSE provider_data->>'classification'
|
||||
END
|
||||
WHERE provider_data->>'classification' IS NOT NULL
|
||||
AND cannabis_type IS NULL;
|
||||
|
||||
-- Also backfill from strain_type if cannabis_type still null
|
||||
UPDATE store_products
|
||||
SET cannabis_type = CASE
|
||||
WHEN strain_type ILIKE '%indica%hybrid%' OR strain_type ILIKE '%hybrid%indica%' THEN 'INDICA_DOMINANT'
|
||||
WHEN strain_type ILIKE '%sativa%hybrid%' OR strain_type ILIKE '%hybrid%sativa%' THEN 'SATIVA_DOMINANT'
|
||||
WHEN strain_type ILIKE '%indica%' THEN 'INDICA'
|
||||
WHEN strain_type ILIKE '%sativa%' THEN 'SATIVA'
|
||||
WHEN strain_type ILIKE '%hybrid%' THEN 'HYBRID'
|
||||
WHEN strain_type ILIKE '%cbd%' THEN 'CBD'
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE strain_type IS NOT NULL
|
||||
AND cannabis_type IS NULL;
|
||||
|
||||
-- Backfill canonical IDs from POSMetaData
|
||||
UPDATE store_products
|
||||
SET
|
||||
canonical_strain_id = provider_data->'POSMetaData'->>'canonicalStrainId',
|
||||
canonical_vendor_id = provider_data->'POSMetaData'->>'canonicalVendorId',
|
||||
canonical_brand_id = provider_data->'POSMetaData'->>'canonicalBrandId',
|
||||
canonical_category_id = provider_data->'POSMetaData'->>'canonicalCategoryId'
|
||||
WHERE provider_data->'POSMetaData' IS NOT NULL
|
||||
AND canonical_strain_id IS NULL;
|
||||
|
||||
-- Backfill lab result URLs
|
||||
UPDATE store_products
|
||||
SET lab_result_url = provider_data->'POSMetaData'->>'canonicalLabResultUrl'
|
||||
WHERE provider_data->'POSMetaData'->>'canonicalLabResultUrl' IS NOT NULL
|
||||
AND lab_result_url IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- INDEXES
|
||||
-- ============================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_store_products_cannabis_type ON store_products(cannabis_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_store_products_vendor_id ON store_products(canonical_vendor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_store_products_strain_id ON store_products(canonical_strain_id);
|
||||
|
||||
-- ============================================================
|
||||
-- ADD MSO FLAG TO DISPENSARIES
|
||||
-- ============================================================
|
||||
|
||||
-- Multi-State Operator flag (calculated from chain presence in multiple states)
|
||||
ALTER TABLE dispensaries ADD COLUMN IF NOT EXISTS is_mso BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Update MSO flag based on chain presence in multiple states
|
||||
WITH mso_chains AS (
|
||||
SELECT chain_id
|
||||
FROM dispensaries
|
||||
WHERE chain_id IS NOT NULL
|
||||
GROUP BY chain_id
|
||||
HAVING COUNT(DISTINCT state) > 1
|
||||
)
|
||||
UPDATE dispensaries d
|
||||
SET is_mso = TRUE
|
||||
WHERE d.chain_id IN (SELECT chain_id FROM mso_chains);
|
||||
|
||||
-- Index for MSO queries
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_is_mso ON dispensaries(is_mso) WHERE is_mso = TRUE;
|
||||
|
||||
-- ============================================================
|
||||
-- PRODUCT DISTRIBUTION VIEW
|
||||
-- ============================================================
|
||||
|
||||
-- View: How many stores carry each product (by brand + canonical name)
|
||||
CREATE OR REPLACE VIEW v_product_distribution AS
|
||||
SELECT
|
||||
sp.brand_name_raw as brand,
|
||||
sp.c_name as product_canonical_name,
|
||||
COUNT(DISTINCT sp.dispensary_id) as store_count,
|
||||
COUNT(DISTINCT d.state) as state_count,
|
||||
ARRAY_AGG(DISTINCT d.state) as states,
|
||||
AVG(sp.price_rec) as avg_price,
|
||||
MIN(sp.price_rec) as min_price,
|
||||
MAX(sp.price_rec) as max_price
|
||||
FROM store_products sp
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
WHERE sp.c_name IS NOT NULL
|
||||
AND sp.brand_name_raw IS NOT NULL
|
||||
AND sp.is_in_stock = TRUE
|
||||
GROUP BY sp.brand_name_raw, sp.c_name
|
||||
HAVING COUNT(DISTINCT sp.dispensary_id) > 1
|
||||
ORDER BY store_count DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- MSO SUMMARY VIEW
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE VIEW v_mso_summary AS
|
||||
SELECT
|
||||
c.name as chain_name,
|
||||
COUNT(DISTINCT d.id) as store_count,
|
||||
COUNT(DISTINCT d.state) as state_count,
|
||||
ARRAY_AGG(DISTINCT d.state ORDER BY d.state) as states,
|
||||
SUM(d.product_count) as total_products,
|
||||
TRUE as is_mso
|
||||
FROM dispensaries d
|
||||
JOIN chains c ON c.id = d.chain_id
|
||||
WHERE d.chain_id IN (
|
||||
SELECT chain_id
|
||||
FROM dispensaries
|
||||
WHERE chain_id IS NOT NULL
|
||||
GROUP BY chain_id
|
||||
HAVING COUNT(DISTINCT state) > 1
|
||||
)
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY state_count DESC, store_count DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
|
||||
COMMENT ON COLUMN store_products.cannabis_type IS 'Normalized cannabis classification: SATIVA, INDICA, HYBRID, INDICA_DOMINANT, SATIVA_DOMINANT, CBD';
|
||||
COMMENT ON COLUMN store_products.canonical_strain_id IS 'POS system strain identifier for cross-store matching';
|
||||
COMMENT ON COLUMN store_products.canonical_vendor_id IS 'POS system vendor/supplier identifier';
|
||||
COMMENT ON COLUMN store_products.lab_result_url IS 'Link to Certificate of Analysis / lab test results';
|
||||
COMMENT ON COLUMN dispensaries.is_mso IS 'Multi-State Operator: chain operates in 2+ states';
|
||||
COMMENT ON VIEW v_product_distribution IS 'Shows how many stores carry each product for distribution analysis';
|
||||
COMMENT ON VIEW v_mso_summary IS 'Summary of multi-state operator chains';
|
||||
73
backend/migrations/124_timescaledb_snapshots.sql
Normal file
73
backend/migrations/124_timescaledb_snapshots.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- Migration 124: Convert inventory_snapshots to TimescaleDB hypertable
|
||||
-- Requires: CREATE EXTENSION timescaledb; (run after installing TimescaleDB)
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 1: Enable TimescaleDB extension
|
||||
-- ============================================================
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 2: Convert to hypertable
|
||||
-- ============================================================
|
||||
-- Note: Table must have a time column and no foreign key constraints
|
||||
|
||||
-- First, drop any foreign keys if they exist
|
||||
ALTER TABLE inventory_snapshots DROP CONSTRAINT IF EXISTS inventory_snapshots_dispensary_id_fkey;
|
||||
|
||||
-- Convert to hypertable, partitioned by captured_at (1 day chunks)
|
||||
SELECT create_hypertable(
|
||||
'inventory_snapshots',
|
||||
'captured_at',
|
||||
chunk_time_interval => INTERVAL '1 day',
|
||||
if_not_exists => TRUE,
|
||||
migrate_data => TRUE
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 3: Enable compression
|
||||
-- ============================================================
|
||||
-- Compress by dispensary_id and product_id (common query patterns)
|
||||
ALTER TABLE inventory_snapshots SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'dispensary_id, product_id',
|
||||
timescaledb.compress_orderby = 'captured_at DESC'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 4: Compression policy (compress chunks older than 1 day)
|
||||
-- ============================================================
|
||||
SELECT add_compression_policy('inventory_snapshots', INTERVAL '1 day');
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 5: Retention policy (optional - drop chunks older than 90 days)
|
||||
-- ============================================================
|
||||
-- Uncomment if you want automatic cleanup:
|
||||
-- SELECT add_retention_policy('inventory_snapshots', INTERVAL '90 days');
|
||||
|
||||
-- ============================================================
|
||||
-- STEP 6: Optimize indexes for time-series queries
|
||||
-- ============================================================
|
||||
-- TimescaleDB automatically creates time-based indexes
|
||||
-- Add composite index for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_disp_prod_time
|
||||
ON inventory_snapshots (dispensary_id, product_id, captured_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- VERIFICATION QUERIES (run after migration)
|
||||
-- ============================================================
|
||||
-- Check hypertable status:
|
||||
-- SELECT * FROM timescaledb_information.hypertables WHERE hypertable_name = 'inventory_snapshots';
|
||||
|
||||
-- Check compression status:
|
||||
-- SELECT * FROM timescaledb_information.compression_settings WHERE hypertable_name = 'inventory_snapshots';
|
||||
|
||||
-- Check chunk sizes:
|
||||
-- SELECT chunk_name, pg_size_pretty(before_compression_total_bytes) as before,
|
||||
-- pg_size_pretty(after_compression_total_bytes) as after,
|
||||
-- round(100 - (after_compression_total_bytes::numeric / before_compression_total_bytes * 100), 1) as compression_pct
|
||||
-- FROM chunk_compression_stats('inventory_snapshots');
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
COMMENT ON TABLE inventory_snapshots IS 'TimescaleDB hypertable for inventory time-series data. Compressed after 1 day.';
|
||||
402
backend/migrations/125_delta_only_snapshots.sql
Normal file
402
backend/migrations/125_delta_only_snapshots.sql
Normal file
@@ -0,0 +1,402 @@
|
||||
-- Migration 125: Delta-only inventory snapshots
|
||||
-- Only store a row when something meaningful changes
|
||||
-- Revenue calculated as: effective_price × qty_sold
|
||||
|
||||
-- ============================================================
|
||||
-- ADD DELTA TRACKING COLUMNS
|
||||
-- ============================================================
|
||||
|
||||
-- Previous values (to show what changed)
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_quantity INTEGER;
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_price_rec DECIMAL(10,2);
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_price_med DECIMAL(10,2);
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_status VARCHAR(50);
|
||||
|
||||
-- Calculated deltas
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS qty_delta INTEGER; -- negative = sold, positive = restocked
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS price_delta DECIMAL(10,2);
|
||||
|
||||
-- Change type flags
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS change_type VARCHAR(50); -- 'sale', 'restock', 'price_change', 'oos', 'back_in_stock'
|
||||
|
||||
-- ============================================================
|
||||
-- INDEX FOR CHANGE TYPE QUERIES
|
||||
-- ============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_change_type ON inventory_snapshots(change_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_qty_delta ON inventory_snapshots(qty_delta) WHERE qty_delta != 0;
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Latest product state (for delta comparison)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_product_latest_state AS
|
||||
SELECT DISTINCT ON (dispensary_id, product_id)
|
||||
dispensary_id,
|
||||
product_id,
|
||||
quantity_available,
|
||||
price_rec,
|
||||
price_med,
|
||||
status,
|
||||
captured_at
|
||||
FROM inventory_snapshots
|
||||
ORDER BY dispensary_id, product_id, captured_at DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- FUNCTION: Check if product state changed
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION should_capture_snapshot(
|
||||
p_dispensary_id INTEGER,
|
||||
p_product_id TEXT,
|
||||
p_quantity INTEGER,
|
||||
p_price_rec DECIMAL,
|
||||
p_price_med DECIMAL,
|
||||
p_status VARCHAR
|
||||
) RETURNS TABLE (
|
||||
should_capture BOOLEAN,
|
||||
prev_quantity INTEGER,
|
||||
prev_price_rec DECIMAL,
|
||||
prev_price_med DECIMAL,
|
||||
prev_status VARCHAR,
|
||||
qty_delta INTEGER,
|
||||
price_delta DECIMAL,
|
||||
change_type VARCHAR
|
||||
) AS $$
|
||||
DECLARE
|
||||
v_prev RECORD;
|
||||
BEGIN
|
||||
-- Get previous state
|
||||
SELECT
|
||||
ls.quantity_available,
|
||||
ls.price_rec,
|
||||
ls.price_med,
|
||||
ls.status
|
||||
INTO v_prev
|
||||
FROM v_product_latest_state ls
|
||||
WHERE ls.dispensary_id = p_dispensary_id
|
||||
AND ls.product_id = p_product_id;
|
||||
|
||||
-- First time seeing this product
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT
|
||||
TRUE,
|
||||
NULL::INTEGER,
|
||||
NULL::DECIMAL,
|
||||
NULL::DECIMAL,
|
||||
NULL::VARCHAR,
|
||||
NULL::INTEGER,
|
||||
NULL::DECIMAL,
|
||||
'new_product'::VARCHAR;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Check for changes
|
||||
IF v_prev.quantity_available IS DISTINCT FROM p_quantity
|
||||
OR v_prev.price_rec IS DISTINCT FROM p_price_rec
|
||||
OR v_prev.price_med IS DISTINCT FROM p_price_med
|
||||
OR v_prev.status IS DISTINCT FROM p_status THEN
|
||||
|
||||
RETURN QUERY SELECT
|
||||
TRUE,
|
||||
v_prev.quantity_available,
|
||||
v_prev.price_rec,
|
||||
v_prev.price_med,
|
||||
v_prev.status,
|
||||
COALESCE(p_quantity, 0) - COALESCE(v_prev.quantity_available, 0),
|
||||
COALESCE(p_price_rec, 0) - COALESCE(v_prev.price_rec, 0),
|
||||
CASE
|
||||
WHEN COALESCE(p_quantity, 0) < COALESCE(v_prev.quantity_available, 0) THEN 'sale'
|
||||
WHEN COALESCE(p_quantity, 0) > COALESCE(v_prev.quantity_available, 0) THEN 'restock'
|
||||
WHEN p_quantity = 0 AND v_prev.quantity_available > 0 THEN 'oos'
|
||||
WHEN p_quantity > 0 AND v_prev.quantity_available = 0 THEN 'back_in_stock'
|
||||
WHEN p_price_rec IS DISTINCT FROM v_prev.price_rec THEN 'price_change'
|
||||
ELSE 'status_change'
|
||||
END;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- No change
|
||||
RETURN QUERY SELECT
|
||||
FALSE,
|
||||
NULL::INTEGER,
|
||||
NULL::DECIMAL,
|
||||
NULL::DECIMAL,
|
||||
NULL::VARCHAR,
|
||||
NULL::INTEGER,
|
||||
NULL::DECIMAL,
|
||||
NULL::VARCHAR;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- REVENUE CALCULATION COLUMNS
|
||||
-- ============================================================
|
||||
-- Effective prices (sale price if on special, otherwise regular)
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS effective_price_rec DECIMAL(10,2);
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS effective_price_med DECIMAL(10,2);
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS is_on_special BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Revenue by market type
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS revenue_rec DECIMAL(10,2);
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS revenue_med DECIMAL(10,2);
|
||||
|
||||
-- Time between snapshots (for velocity calc)
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS time_since_last_snapshot INTERVAL;
|
||||
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS hours_since_last DECIMAL(10,2);
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Hourly Sales Velocity
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_hourly_sales AS
|
||||
SELECT
|
||||
dispensary_id,
|
||||
DATE(captured_at) as sale_date,
|
||||
EXTRACT(HOUR FROM captured_at) as sale_hour,
|
||||
COUNT(*) FILTER (WHERE qty_delta < 0) as transactions,
|
||||
SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0) as units_sold,
|
||||
SUM(revenue_estimate) FILTER (WHERE qty_delta < 0) as revenue,
|
||||
COUNT(DISTINCT product_id) FILTER (WHERE qty_delta < 0) as unique_products_sold
|
||||
FROM inventory_snapshots
|
||||
WHERE change_type = 'sale'
|
||||
GROUP BY dispensary_id, DATE(captured_at), EXTRACT(HOUR FROM captured_at);
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Daily Sales by Store
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_daily_store_sales AS
|
||||
SELECT
|
||||
s.dispensary_id,
|
||||
d.name as store_name,
|
||||
d.state,
|
||||
DATE(s.captured_at) as sale_date,
|
||||
SUM(ABS(s.qty_delta)) as units_sold,
|
||||
SUM(s.revenue_estimate) as revenue,
|
||||
COUNT(*) as sale_events,
|
||||
COUNT(DISTINCT s.product_id) as unique_products
|
||||
FROM inventory_snapshots s
|
||||
JOIN dispensaries d ON d.id = s.dispensary_id
|
||||
WHERE s.change_type = 'sale'
|
||||
GROUP BY s.dispensary_id, d.name, d.state, DATE(s.captured_at);
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Daily Sales by Brand
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_daily_brand_sales AS
|
||||
SELECT
|
||||
s.brand_name,
|
||||
d.state,
|
||||
DATE(s.captured_at) as sale_date,
|
||||
SUM(ABS(s.qty_delta)) as units_sold,
|
||||
SUM(s.revenue_estimate) as revenue,
|
||||
COUNT(DISTINCT s.dispensary_id) as stores_with_sales,
|
||||
COUNT(DISTINCT s.product_id) as unique_skus_sold
|
||||
FROM inventory_snapshots s
|
||||
JOIN dispensaries d ON d.id = s.dispensary_id
|
||||
WHERE s.change_type = 'sale'
|
||||
AND s.brand_name IS NOT NULL
|
||||
GROUP BY s.brand_name, d.state, DATE(s.captured_at);
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Product Velocity Rankings
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_product_velocity AS
|
||||
SELECT
|
||||
s.product_id,
|
||||
s.brand_name,
|
||||
s.category,
|
||||
s.dispensary_id,
|
||||
d.name as store_name,
|
||||
d.state,
|
||||
SUM(ABS(s.qty_delta)) as units_sold_30d,
|
||||
SUM(s.revenue_estimate) as revenue_30d,
|
||||
COUNT(*) as sale_events,
|
||||
ROUND(SUM(ABS(s.qty_delta))::NUMERIC / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0), 2) as avg_daily_units,
|
||||
ROUND(SUM(s.revenue_estimate) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0), 2) as avg_daily_revenue,
|
||||
CASE
|
||||
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 10 THEN 'hot'
|
||||
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 3 THEN 'steady'
|
||||
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 1 THEN 'slow'
|
||||
ELSE 'stale'
|
||||
END as velocity_tier
|
||||
FROM inventory_snapshots s
|
||||
JOIN dispensaries d ON d.id = s.dispensary_id
|
||||
WHERE s.change_type = 'sale'
|
||||
AND s.captured_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY s.product_id, s.brand_name, s.category, s.dispensary_id, d.name, d.state;
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Busiest Hours by Store
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_busiest_hours AS
|
||||
SELECT
|
||||
dispensary_id,
|
||||
sale_hour,
|
||||
AVG(units_sold) as avg_units_per_hour,
|
||||
AVG(revenue) as avg_revenue_per_hour,
|
||||
SUM(units_sold) as total_units,
|
||||
SUM(revenue) as total_revenue,
|
||||
COUNT(*) as days_with_data,
|
||||
RANK() OVER (PARTITION BY dispensary_id ORDER BY AVG(revenue) DESC) as hour_rank
|
||||
FROM v_hourly_sales
|
||||
GROUP BY dispensary_id, sale_hour;
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Promotion Effectiveness (compare sale vs non-sale prices)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_promotion_effectiveness AS
|
||||
SELECT
|
||||
s.dispensary_id,
|
||||
d.name as store_name,
|
||||
s.product_id,
|
||||
s.brand_name,
|
||||
DATE(s.captured_at) as sale_date,
|
||||
SUM(ABS(s.qty_delta)) FILTER (WHERE s.price_rec < s.prev_price_rec) as units_on_discount,
|
||||
SUM(ABS(s.qty_delta)) FILTER (WHERE s.price_rec >= COALESCE(s.prev_price_rec, s.price_rec)) as units_full_price,
|
||||
SUM(s.revenue_estimate) FILTER (WHERE s.price_rec < s.prev_price_rec) as revenue_discounted,
|
||||
SUM(s.revenue_estimate) FILTER (WHERE s.price_rec >= COALESCE(s.prev_price_rec, s.price_rec)) as revenue_full_price
|
||||
FROM inventory_snapshots s
|
||||
JOIN dispensaries d ON d.id = s.dispensary_id
|
||||
WHERE s.change_type = 'sale'
|
||||
GROUP BY s.dispensary_id, d.name, s.product_id, s.brand_name, DATE(s.captured_at);
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
COMMENT ON COLUMN inventory_snapshots.qty_delta IS 'Quantity change: negative=sold, positive=restocked';
|
||||
COMMENT ON COLUMN inventory_snapshots.revenue_estimate IS 'Estimated revenue: ABS(qty_delta) * price_rec when qty_delta < 0';
|
||||
COMMENT ON COLUMN inventory_snapshots.change_type IS 'Type of change: sale, restock, price_change, oos, back_in_stock, new_product';
|
||||
COMMENT ON FUNCTION should_capture_snapshot IS 'Returns whether a snapshot should be captured and delta values';
|
||||
COMMENT ON VIEW v_hourly_sales IS 'Sales aggregated by hour - find busiest times';
|
||||
COMMENT ON VIEW v_daily_store_sales IS 'Daily revenue by store';
|
||||
COMMENT ON VIEW v_daily_brand_sales IS 'Daily brand performance by state';
|
||||
COMMENT ON VIEW v_product_velocity IS 'Product sales velocity rankings (hot/steady/slow/stale)';
|
||||
COMMENT ON VIEW v_busiest_hours IS 'Rank hours by sales volume per store';
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Days Until Stock Out (Predictive)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_stock_out_prediction AS
|
||||
WITH velocity AS (
|
||||
SELECT
|
||||
dispensary_id,
|
||||
product_id,
|
||||
brand_name,
|
||||
-- Average units sold per day (last 7 days)
|
||||
ROUND(SUM(ABS(qty_delta))::NUMERIC / NULLIF(COUNT(DISTINCT DATE(captured_at)), 0), 2) as daily_velocity,
|
||||
-- Hours between sales
|
||||
AVG(hours_since_last) FILTER (WHERE qty_delta < 0) as avg_hours_between_sales
|
||||
FROM inventory_snapshots
|
||||
WHERE change_type = 'sale'
|
||||
AND captured_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY dispensary_id, product_id, brand_name
|
||||
),
|
||||
current_stock AS (
|
||||
SELECT DISTINCT ON (dispensary_id, product_id)
|
||||
dispensary_id,
|
||||
product_id,
|
||||
quantity_available as current_qty,
|
||||
captured_at as last_seen
|
||||
FROM inventory_snapshots
|
||||
ORDER BY dispensary_id, product_id, captured_at DESC
|
||||
)
|
||||
SELECT
|
||||
cs.dispensary_id,
|
||||
d.name as store_name,
|
||||
cs.product_id,
|
||||
v.brand_name,
|
||||
cs.current_qty,
|
||||
v.daily_velocity,
|
||||
CASE
|
||||
WHEN v.daily_velocity > 0 THEN ROUND(cs.current_qty / v.daily_velocity, 1)
|
||||
ELSE NULL
|
||||
END as days_until_stock_out,
|
||||
CASE
|
||||
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 3 THEN 'critical'
|
||||
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 7 THEN 'low'
|
||||
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 14 THEN 'moderate'
|
||||
ELSE 'healthy'
|
||||
END as stock_health,
|
||||
cs.last_seen
|
||||
FROM current_stock cs
|
||||
JOIN velocity v ON v.dispensary_id = cs.dispensary_id AND v.product_id = cs.product_id
|
||||
JOIN dispensaries d ON d.id = cs.dispensary_id
|
||||
WHERE cs.current_qty > 0
|
||||
AND v.daily_velocity > 0;
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Days Since OOS (for products currently out of stock)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_days_since_oos AS
|
||||
SELECT
|
||||
s.dispensary_id,
|
||||
d.name as store_name,
|
||||
s.product_id,
|
||||
s.brand_name,
|
||||
s.captured_at as went_oos_at,
|
||||
EXTRACT(EPOCH FROM (NOW() - s.captured_at)) / 86400 as days_since_oos,
|
||||
s.prev_quantity as last_known_qty
|
||||
FROM inventory_snapshots s
|
||||
JOIN dispensaries d ON d.id = s.dispensary_id
|
||||
WHERE s.change_type = 'oos'
|
||||
AND NOT EXISTS (
|
||||
-- No back_in_stock event after this OOS
|
||||
SELECT 1 FROM inventory_snapshots s2
|
||||
WHERE s2.dispensary_id = s.dispensary_id
|
||||
AND s2.product_id = s.product_id
|
||||
AND s2.change_type = 'back_in_stock'
|
||||
AND s2.captured_at > s.captured_at
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Brand Variant Counts (track brand growth)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_brand_variants AS
|
||||
SELECT
|
||||
sp.brand_name_raw as brand_name,
|
||||
d.state,
|
||||
COUNT(DISTINCT sp.id) as total_variants,
|
||||
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock = TRUE) as active_variants,
|
||||
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock = FALSE) as inactive_variants,
|
||||
COUNT(DISTINCT sp.dispensary_id) as stores_carrying,
|
||||
COUNT(DISTINCT sp.category_raw) as categories,
|
||||
MIN(sp.first_seen_at) as brand_first_seen,
|
||||
MAX(sp.last_seen_at) as brand_last_seen
|
||||
FROM store_products sp
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
WHERE sp.brand_name_raw IS NOT NULL
|
||||
GROUP BY sp.brand_name_raw, d.state;
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: Brand Growth (compare variant counts over time)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_brand_growth AS
|
||||
WITH weekly_counts AS (
|
||||
SELECT
|
||||
brand_name_raw as brand_name,
|
||||
DATE_TRUNC('week', last_seen_at) as week,
|
||||
COUNT(DISTINCT id) as variant_count
|
||||
FROM store_products
|
||||
WHERE brand_name_raw IS NOT NULL
|
||||
AND last_seen_at >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY brand_name_raw, DATE_TRUNC('week', last_seen_at)
|
||||
)
|
||||
SELECT
|
||||
w1.brand_name,
|
||||
w1.week as current_week,
|
||||
w1.variant_count as current_variants,
|
||||
w2.variant_count as prev_week_variants,
|
||||
w1.variant_count - COALESCE(w2.variant_count, 0) as variant_change,
|
||||
CASE
|
||||
WHEN w2.variant_count IS NULL THEN 'new'
|
||||
WHEN w1.variant_count > w2.variant_count THEN 'growing'
|
||||
WHEN w1.variant_count < w2.variant_count THEN 'declining'
|
||||
ELSE 'stable'
|
||||
END as growth_status
|
||||
FROM weekly_counts w1
|
||||
LEFT JOIN weekly_counts w2
|
||||
ON w2.brand_name = w1.brand_name
|
||||
AND w2.week = w1.week - INTERVAL '1 week'
|
||||
ORDER BY w1.brand_name, w1.week DESC;
|
||||
|
||||
COMMENT ON VIEW v_stock_out_prediction IS 'Predict days until stock out based on velocity';
|
||||
COMMENT ON VIEW v_days_since_oos IS 'Products currently OOS and how long they have been out';
|
||||
COMMENT ON VIEW v_brand_variants IS 'Active vs inactive SKU counts per brand per state';
|
||||
COMMENT ON VIEW v_brand_growth IS 'Week-over-week brand variant growth tracking';
|
||||
53
backend/migrations/126_az_high_frequency.sql
Normal file
53
backend/migrations/126_az_high_frequency.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration 126: Set AZ stores to 5-minute high-frequency crawls
|
||||
-- Other states default to 60-minute (1 hour) intervals
|
||||
|
||||
-- ============================================================
|
||||
-- SET AZ STORES TO 5-MINUTE INTERVALS (with 3-min jitter)
|
||||
-- ============================================================
|
||||
-- Base interval: 5 minutes
|
||||
-- Jitter: +/- 3 minutes (so 2-8 minute effective range)
|
||||
UPDATE dispensaries
|
||||
SET
|
||||
crawl_interval_minutes = 5,
|
||||
next_crawl_at = NOW() + (RANDOM() * INTERVAL '5 minutes') -- Stagger initial crawls
|
||||
WHERE state = 'AZ'
|
||||
AND crawl_enabled = TRUE;
|
||||
|
||||
-- ============================================================
|
||||
-- SET OTHER STATES TO 60-MINUTE INTERVALS (with 3-min jitter)
|
||||
-- ============================================================
|
||||
UPDATE dispensaries
|
||||
SET
|
||||
crawl_interval_minutes = 60,
|
||||
next_crawl_at = NOW() + (RANDOM() * INTERVAL '60 minutes') -- Stagger initial crawls
|
||||
WHERE state != 'AZ'
|
||||
AND crawl_enabled = TRUE
|
||||
AND crawl_interval_minutes IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- VERIFY RESULTS
|
||||
-- ============================================================
|
||||
-- SELECT state, crawl_interval_minutes, COUNT(*)
|
||||
-- FROM dispensaries
|
||||
-- WHERE crawl_enabled = TRUE
|
||||
-- GROUP BY state, crawl_interval_minutes
|
||||
-- ORDER BY state;
|
||||
|
||||
-- ============================================================
|
||||
-- CREATE VIEW FOR MONITORING CRAWL LOAD
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW v_crawl_load AS
|
||||
SELECT
|
||||
state,
|
||||
crawl_interval_minutes,
|
||||
COUNT(*) as store_count,
|
||||
-- Crawls per hour = stores * (60 / interval)
|
||||
ROUND(COUNT(*) * (60.0 / COALESCE(crawl_interval_minutes, 60))) as crawls_per_hour,
|
||||
-- Assuming 30 sec per crawl, workers needed = crawls_per_hour / 120
|
||||
ROUND(COUNT(*) * (60.0 / COALESCE(crawl_interval_minutes, 60)) / 120, 1) as workers_needed
|
||||
FROM dispensaries
|
||||
WHERE crawl_enabled = TRUE
|
||||
GROUP BY state, crawl_interval_minutes
|
||||
ORDER BY crawls_per_hour DESC;
|
||||
|
||||
COMMENT ON VIEW v_crawl_load IS 'Monitor crawl load by state and interval';
|
||||
164
backend/migrations/127_fix_worker_task_limit.sql
Normal file
164
backend/migrations/127_fix_worker_task_limit.sql
Normal file
@@ -0,0 +1,164 @@
|
||||
-- Migration 127: Fix worker task concurrency limit
|
||||
-- Problem: claim_task function checks session_task_count but never increments it
|
||||
-- Solution: Increment on claim, decrement on complete/fail/release
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 1: Set max tasks to 5 for all workers
|
||||
-- =============================================================================
|
||||
UPDATE worker_registry SET session_max_tasks = 5;
|
||||
|
||||
-- Set default to 5 for new workers
|
||||
ALTER TABLE worker_registry ALTER COLUMN session_max_tasks SET DEFAULT 5;
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2: Reset all session_task_count to match actual active tasks
|
||||
-- =============================================================================
|
||||
UPDATE worker_registry wr SET session_task_count = (
|
||||
SELECT COUNT(*) FROM worker_tasks wt
|
||||
WHERE wt.worker_id = wr.worker_id
|
||||
AND wt.status IN ('claimed', 'running')
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 3: Update claim_task function to increment session_task_count
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION claim_task(
|
||||
p_role VARCHAR(50),
|
||||
p_worker_id VARCHAR(100),
|
||||
p_curl_passed BOOLEAN DEFAULT TRUE,
|
||||
p_http_passed BOOLEAN DEFAULT FALSE
|
||||
) RETURNS worker_tasks AS $$
|
||||
DECLARE
|
||||
claimed_task worker_tasks;
|
||||
worker_state VARCHAR(2);
|
||||
session_valid BOOLEAN;
|
||||
session_tasks INT;
|
||||
max_tasks INT;
|
||||
BEGIN
|
||||
-- Get worker's current geo session info
|
||||
SELECT
|
||||
current_state,
|
||||
session_task_count,
|
||||
session_max_tasks,
|
||||
(geo_session_started_at IS NOT NULL AND geo_session_started_at > NOW() - INTERVAL '60 minutes')
|
||||
INTO worker_state, session_tasks, max_tasks, session_valid
|
||||
FROM worker_registry
|
||||
WHERE worker_id = p_worker_id;
|
||||
|
||||
-- Check if worker has reached max concurrent tasks (default 5)
|
||||
IF session_tasks >= COALESCE(max_tasks, 5) THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- If no valid geo session, or session expired, worker can't claim tasks
|
||||
-- Worker must re-qualify first
|
||||
IF worker_state IS NULL OR NOT session_valid THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Claim task matching worker's state
|
||||
UPDATE worker_tasks
|
||||
SET
|
||||
status = 'claimed',
|
||||
worker_id = p_worker_id,
|
||||
claimed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT wt.id FROM worker_tasks wt
|
||||
JOIN dispensaries d ON wt.dispensary_id = d.id
|
||||
WHERE wt.role = p_role
|
||||
AND wt.status = 'pending'
|
||||
AND (wt.scheduled_for IS NULL OR wt.scheduled_for <= NOW())
|
||||
-- GEO FILTER: Task's dispensary must match worker's state
|
||||
AND d.state = worker_state
|
||||
-- Method compatibility: worker must have passed the required preflight
|
||||
AND (
|
||||
wt.method IS NULL -- No preference, any worker can claim
|
||||
OR (wt.method = 'curl' AND p_curl_passed = TRUE)
|
||||
OR (wt.method = 'http' AND p_http_passed = TRUE)
|
||||
)
|
||||
-- Exclude stores that already have an active task
|
||||
AND (wt.dispensary_id IS NULL OR wt.dispensary_id NOT IN (
|
||||
SELECT dispensary_id FROM worker_tasks
|
||||
WHERE status IN ('claimed', 'running')
|
||||
AND dispensary_id IS NOT NULL
|
||||
AND dispensary_id != wt.dispensary_id
|
||||
))
|
||||
ORDER BY wt.priority DESC, wt.created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING * INTO claimed_task;
|
||||
|
||||
-- INCREMENT session_task_count if we claimed a task
|
||||
IF claimed_task.id IS NOT NULL THEN
|
||||
UPDATE worker_registry
|
||||
SET session_task_count = session_task_count + 1
|
||||
WHERE worker_id = p_worker_id;
|
||||
END IF;
|
||||
|
||||
RETURN claimed_task;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 4: Create trigger to decrement on task completion/failure/release
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION decrement_worker_task_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only decrement when task was assigned to a worker and is now complete/released
|
||||
IF OLD.worker_id IS NOT NULL AND OLD.status IN ('claimed', 'running') THEN
|
||||
-- Task completed/failed/released - decrement count
|
||||
IF NEW.status IN ('pending', 'completed', 'failed') OR NEW.worker_id IS NULL THEN
|
||||
UPDATE worker_registry
|
||||
SET session_task_count = GREATEST(0, session_task_count - 1)
|
||||
WHERE worker_id = OLD.worker_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop existing trigger if any
|
||||
DROP TRIGGER IF EXISTS trg_decrement_worker_task_count ON worker_tasks;
|
||||
|
||||
-- Create trigger on UPDATE (status change or worker_id cleared)
|
||||
CREATE TRIGGER trg_decrement_worker_task_count
|
||||
AFTER UPDATE ON worker_tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION decrement_worker_task_count();
|
||||
|
||||
-- Also handle DELETE (completed tasks are deleted from pool)
|
||||
CREATE OR REPLACE FUNCTION decrement_worker_task_count_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.worker_id IS NOT NULL AND OLD.status IN ('claimed', 'running') THEN
|
||||
UPDATE worker_registry
|
||||
SET session_task_count = GREATEST(0, session_task_count - 1)
|
||||
WHERE worker_id = OLD.worker_id;
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_decrement_worker_task_count_delete ON worker_tasks;
|
||||
|
||||
CREATE TRIGGER trg_decrement_worker_task_count_delete
|
||||
AFTER DELETE ON worker_tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION decrement_worker_task_count_delete();
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 5: Verify current state
|
||||
-- =============================================================================
|
||||
SELECT
|
||||
wr.worker_id,
|
||||
wr.friendly_name,
|
||||
wr.session_task_count,
|
||||
wr.session_max_tasks,
|
||||
(SELECT COUNT(*) FROM worker_tasks wt WHERE wt.worker_id = wr.worker_id AND wt.status IN ('claimed', 'running')) as actual_count
|
||||
FROM worker_registry wr
|
||||
WHERE wr.status = 'active'
|
||||
ORDER BY wr.friendly_name;
|
||||
1784
backend/node_modules/.package-lock.json
generated
vendored
1784
backend/node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
1550
backend/package-lock.json
generated
1550
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"seed:dt:cities:bulk": "tsx src/scripts/seed-dt-cities-bulk.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.953.0",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"axios": "^1.6.2",
|
||||
|
||||
@@ -266,6 +266,11 @@ console.log('[ClickAnalytics] Routes registered at /api/analytics/clicks');
|
||||
app.use('/api/analytics/price', priceAnalyticsRoutes);
|
||||
console.log('[PriceAnalytics] Routes registered at /api/analytics/price');
|
||||
|
||||
// Sales Analytics API - sales velocity, brand market share, product intelligence
|
||||
import salesAnalyticsRoutes from './routes/sales-analytics';
|
||||
app.use('/api/sales-analytics', salesAnalyticsRoutes);
|
||||
console.log('[SalesAnalytics] Routes registered at /api/sales-analytics');
|
||||
|
||||
// States API routes - cannabis legalization status and targeting
|
||||
try {
|
||||
const statesRouter = createStatesRouter(getPool());
|
||||
|
||||
295
backend/src/routes/sales-analytics.ts
Normal file
295
backend/src/routes/sales-analytics.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Sales Analytics API Routes
|
||||
*
|
||||
* Market intelligence endpoints for sales velocity, brand market share,
|
||||
* store performance, and product intelligence.
|
||||
*
|
||||
* Routes are prefixed with /api/sales-analytics
|
||||
*
|
||||
* Data Sources (materialized views):
|
||||
* - 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 { Router, Request, Response } from 'express';
|
||||
import { authMiddleware } from '../auth/middleware';
|
||||
import salesAnalyticsService from '../services/analytics/SalesAnalyticsService';
|
||||
import { TimeWindow, getDateRangeFromWindow } from '../services/analytics/types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ============================================================
|
||||
// DAILY SALES ESTIMATES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /daily-sales
|
||||
* Get daily sales estimates by product/dispensary
|
||||
*/
|
||||
router.get('/daily-sales', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const brandName = req.query.brand as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
const dispensaryId = req.query.dispensary_id
|
||||
? parseInt(req.query.dispensary_id as string)
|
||||
: undefined;
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 100;
|
||||
|
||||
const result = await salesAnalyticsService.getDailySalesEstimates({
|
||||
stateCode,
|
||||
brandName,
|
||||
category,
|
||||
dispensaryId,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Daily sales error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BRAND MARKET SHARE
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /brand-market-share
|
||||
* Get brand market share (penetration) by state
|
||||
*/
|
||||
router.get('/brand-market-share', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const brandName = req.query.brand as string | undefined;
|
||||
const minPenetration = req.query.min_penetration
|
||||
? parseFloat(req.query.min_penetration as string)
|
||||
: 0;
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 100;
|
||||
|
||||
const result = await salesAnalyticsService.getBrandMarketShare({
|
||||
stateCode,
|
||||
brandName,
|
||||
minPenetration,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Brand market share error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SKU VELOCITY
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /sku-velocity
|
||||
* Get SKU velocity rankings
|
||||
*/
|
||||
router.get('/sku-velocity', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const brandName = req.query.brand as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
const dispensaryId = req.query.dispensary_id
|
||||
? parseInt(req.query.dispensary_id as string)
|
||||
: undefined;
|
||||
const velocityTier = req.query.tier as 'hot' | 'steady' | 'slow' | 'stale' | undefined;
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 100;
|
||||
|
||||
const result = await salesAnalyticsService.getSkuVelocity({
|
||||
stateCode,
|
||||
brandName,
|
||||
category,
|
||||
dispensaryId,
|
||||
velocityTier,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] SKU velocity error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STORE PERFORMANCE
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /store-performance
|
||||
* Get dispensary performance rankings
|
||||
*/
|
||||
router.get('/store-performance', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const sortBy = (req.query.sort_by as 'revenue' | 'units' | 'brands' | 'skus') || 'revenue';
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 100;
|
||||
|
||||
const result = await salesAnalyticsService.getStorePerformance({
|
||||
stateCode,
|
||||
sortBy,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Store performance error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CATEGORY TRENDS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /category-trends
|
||||
* Get weekly category performance trends
|
||||
*/
|
||||
router.get('/category-trends', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
const weeks = req.query.weeks
|
||||
? parseInt(req.query.weeks as string)
|
||||
: 12;
|
||||
|
||||
const result = await salesAnalyticsService.getCategoryTrends({
|
||||
stateCode,
|
||||
category,
|
||||
weeks,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Category trends error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// PRODUCT INTELLIGENCE (Hoodie-style metrics)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /product-intelligence
|
||||
* Get per-product metrics including stock_diff_120, days_since_oos, days_until_stock_out
|
||||
*/
|
||||
router.get('/product-intelligence', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const brandName = req.query.brand as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
const dispensaryId = req.query.dispensary_id
|
||||
? parseInt(req.query.dispensary_id as string)
|
||||
: undefined;
|
||||
const inStockOnly = req.query.in_stock === 'true';
|
||||
const lowStock = req.query.low_stock === 'true';
|
||||
const recentOOS = req.query.recent_oos === 'true';
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 100;
|
||||
|
||||
const result = await salesAnalyticsService.getProductIntelligence({
|
||||
stateCode,
|
||||
brandName,
|
||||
category,
|
||||
dispensaryId,
|
||||
inStockOnly,
|
||||
lowStock,
|
||||
recentOOS,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Product intelligence error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// TOP BRANDS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /top-brands
|
||||
* Get top selling brands by revenue
|
||||
*/
|
||||
router.get('/top-brands', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const window = (req.query.window as TimeWindow) || '30d';
|
||||
const limit = req.query.limit
|
||||
? parseInt(req.query.limit as string)
|
||||
: 50;
|
||||
|
||||
const result = await salesAnalyticsService.getTopBrands({
|
||||
stateCode,
|
||||
window,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result, count: result.length });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Top brands error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// VIEW MANAGEMENT
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* POST /refresh
|
||||
* Manually refresh materialized views (admin only)
|
||||
*/
|
||||
router.post('/refresh', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('[SalesAnalytics] Manual view refresh requested');
|
||||
const result = await salesAnalyticsService.refreshViews();
|
||||
console.log('[SalesAnalytics] View refresh complete:', result);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Refresh error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /stats
|
||||
* Get view statistics (row counts for each materialized view)
|
||||
*/
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await salesAnalyticsService.getViewStats();
|
||||
res.json({ success: true, data: stats });
|
||||
} catch (error: any) {
|
||||
console.error('[SalesAnalytics] Stats error:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -598,7 +598,7 @@ router.delete('/schedules/:id', async (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the schedule
|
||||
// Delete the schedule (pending tasks remain in pool for manual management)
|
||||
await pool.query(`DELETE FROM task_schedules WHERE id = $1`, [scheduleId]);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -12,3 +12,4 @@ export { CategoryAnalyticsService } from './CategoryAnalyticsService';
|
||||
export { StoreAnalyticsService } from './StoreAnalyticsService';
|
||||
export { StateAnalyticsService } from './StateAnalyticsService';
|
||||
export { BrandIntelligenceService } from './BrandIntelligenceService';
|
||||
export { SalesAnalyticsService } from './SalesAnalyticsService';
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
/**
|
||||
* Inventory Snapshots Service
|
||||
* Inventory Snapshots Service (Delta-Only)
|
||||
*
|
||||
* Shared utility for saving lightweight inventory snapshots after each crawl.
|
||||
* Normalizes fields across all platforms (Dutchie, Jane, Treez) into a
|
||||
* common format for sales velocity tracking and analytics.
|
||||
* Only stores snapshots when something CHANGES (quantity, price, status).
|
||||
* This reduces storage by ~95% while capturing all meaningful events.
|
||||
*
|
||||
* Part of Real-Time Inventory Tracking feature.
|
||||
*
|
||||
* Field mappings:
|
||||
* | Field | Dutchie | Jane | Treez |
|
||||
* |-----------|------------------------|--------------------|------------------|
|
||||
* | ID | id | product_id | id |
|
||||
* | Quantity | children.quantityAvailable | max_cart_quantity | availableUnits |
|
||||
* | Low stock | isBelowThreshold | false | !isAboveThreshold|
|
||||
* | Price rec | recPrices[0] | bucket_price | customMinPrice |
|
||||
* | Brand | brand.name | brand | brand |
|
||||
* | Category | category | kind | category |
|
||||
* | Name | Name | name | name |
|
||||
* | Status | Status | (presence=active) | status |
|
||||
* Change types:
|
||||
* - sale: quantity decreased (qty_delta < 0)
|
||||
* - restock: quantity increased (qty_delta > 0)
|
||||
* - price_change: price changed but quantity same
|
||||
* - oos: went out of stock (quantity -> 0)
|
||||
* - back_in_stock: came back in stock (0 -> quantity)
|
||||
* - new_product: first time seeing this product
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
@@ -31,11 +26,37 @@ interface SnapshotRow {
|
||||
status: string | null;
|
||||
price_rec: number | null;
|
||||
price_med: number | null;
|
||||
price_rec_special: number | null;
|
||||
price_med_special: number | null;
|
||||
is_on_special: boolean;
|
||||
brand_name: string | null;
|
||||
category: string | null;
|
||||
product_name: string | null;
|
||||
}
|
||||
|
||||
interface PreviousState {
|
||||
quantity_available: number | null;
|
||||
price_rec: number | null;
|
||||
price_med: number | null;
|
||||
status: string | null;
|
||||
captured_at: Date;
|
||||
}
|
||||
|
||||
interface DeltaSnapshot extends SnapshotRow {
|
||||
prev_quantity: number | null;
|
||||
prev_price_rec: number | null;
|
||||
prev_price_med: number | null;
|
||||
prev_status: string | null;
|
||||
qty_delta: number | null;
|
||||
price_delta: number | null;
|
||||
change_type: string;
|
||||
effective_price_rec: number | null;
|
||||
effective_price_med: number | null;
|
||||
revenue_rec: number | null;
|
||||
revenue_med: number | null;
|
||||
hours_since_last: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a normalized snapshot row from a raw product based on platform.
|
||||
*/
|
||||
@@ -46,6 +67,9 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
let status: string | null = null;
|
||||
let priceRec: number | null = null;
|
||||
let priceMed: number | null = null;
|
||||
let priceRecSpecial: number | null = null;
|
||||
let priceMedSpecial: number | null = null;
|
||||
let isOnSpecial = false;
|
||||
let brandName: string | null = null;
|
||||
let category: string | null = null;
|
||||
let productName: string | null = null;
|
||||
@@ -75,6 +99,15 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
|
||||
const medPrices = product.medicalPrices || product.medPrices || [];
|
||||
priceMed = medPrices.length > 0 ? parseFloat(medPrices[0]) : null;
|
||||
|
||||
// Special/sale prices
|
||||
if (product.specialPrices && product.specialPrices.length > 0) {
|
||||
priceRecSpecial = parseFloat(product.specialPrices[0]);
|
||||
isOnSpecial = true;
|
||||
} else if (product.discountedPrices && product.discountedPrices.length > 0) {
|
||||
priceRecSpecial = parseFloat(product.discountedPrices[0]);
|
||||
isOnSpecial = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -83,20 +116,24 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
productName = product.name;
|
||||
brandName = product.brand || null;
|
||||
category = product.kind || null;
|
||||
status = 'Active'; // Jane products present = active
|
||||
isBelowThreshold = false; // Jane doesn't expose this
|
||||
status = 'Active';
|
||||
isBelowThreshold = false;
|
||||
|
||||
// Quantity: max_cart_quantity
|
||||
quantityAvailable = product.max_cart_quantity ?? null;
|
||||
|
||||
// Price: bucket_price or first available weight-based price
|
||||
priceRec =
|
||||
product.bucket_price ||
|
||||
product.price_gram ||
|
||||
product.price_eighth_ounce ||
|
||||
product.price_each ||
|
||||
null;
|
||||
priceMed = null; // Jane doesn't separate med prices clearly
|
||||
priceMed = null;
|
||||
|
||||
// Jane sale prices
|
||||
if (product.discounted_price && priceRec && product.discounted_price < priceRec) {
|
||||
priceRecSpecial = product.discounted_price;
|
||||
isOnSpecial = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -107,15 +144,17 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
category = product.category || null;
|
||||
status = product.status || (product.isActive ? 'ACTIVE' : 'INACTIVE');
|
||||
|
||||
// Quantity: availableUnits
|
||||
quantityAvailable = product.availableUnits ?? null;
|
||||
|
||||
// Low stock: inverse of isAboveThreshold
|
||||
isBelowThreshold = product.isAboveThreshold === false;
|
||||
|
||||
// Price: customMinPrice
|
||||
priceRec = product.customMinPrice ?? null;
|
||||
priceMed = null; // Treez doesn't distinguish med pricing
|
||||
priceMed = null;
|
||||
|
||||
// Treez sale prices
|
||||
if (product.customOnSaleValue && priceRec && product.customOnSaleValue < priceRec) {
|
||||
priceRecSpecial = product.customOnSaleValue;
|
||||
isOnSpecial = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +170,9 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
status,
|
||||
price_rec: priceRec,
|
||||
price_med: priceMed,
|
||||
price_rec_special: priceRecSpecial,
|
||||
price_med_special: priceMedSpecial,
|
||||
is_on_special: isOnSpecial,
|
||||
brand_name: brandName,
|
||||
category,
|
||||
product_name: productName,
|
||||
@@ -138,61 +180,223 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save inventory snapshots for all products in a crawl result.
|
||||
*
|
||||
* Call this after fetching products in any platform handler.
|
||||
* Uses bulk insert for efficiency.
|
||||
* Determine if product state changed and calculate deltas
|
||||
*/
|
||||
function calculateDelta(
|
||||
current: SnapshotRow,
|
||||
previous: PreviousState | null,
|
||||
now: Date
|
||||
): DeltaSnapshot | null {
|
||||
const qtyChanged =
|
||||
previous?.quantity_available !== current.quantity_available;
|
||||
const priceRecChanged =
|
||||
previous?.price_rec !== current.price_rec;
|
||||
const priceMedChanged =
|
||||
previous?.price_med !== current.price_med;
|
||||
const statusChanged =
|
||||
previous?.status !== current.status;
|
||||
|
||||
// No change - skip
|
||||
if (previous && !qtyChanged && !priceRecChanged && !priceMedChanged && !statusChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate qty delta
|
||||
const prevQty = previous?.quantity_available ?? null;
|
||||
const currQty = current.quantity_available ?? 0;
|
||||
const qtyDelta = previous ? currQty - (prevQty ?? 0) : null;
|
||||
|
||||
// Calculate price delta
|
||||
const priceDelta = previous && current.price_rec && previous.price_rec
|
||||
? current.price_rec - previous.price_rec
|
||||
: null;
|
||||
|
||||
// Determine change type
|
||||
let changeType = 'new_product';
|
||||
if (previous) {
|
||||
if (currQty === 0 && (prevQty ?? 0) > 0) {
|
||||
changeType = 'oos';
|
||||
} else if (currQty > 0 && (prevQty ?? 0) === 0) {
|
||||
changeType = 'back_in_stock';
|
||||
} else if (qtyDelta !== null && qtyDelta < 0) {
|
||||
changeType = 'sale';
|
||||
} else if (qtyDelta !== null && qtyDelta > 0) {
|
||||
changeType = 'restock';
|
||||
} else if (priceRecChanged || priceMedChanged) {
|
||||
changeType = 'price_change';
|
||||
} else {
|
||||
changeType = 'status_change';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate effective prices (sale price if on special, otherwise regular)
|
||||
const effectivePriceRec = current.is_on_special && current.price_rec_special
|
||||
? current.price_rec_special
|
||||
: current.price_rec;
|
||||
const effectivePriceMed = current.is_on_special && current.price_med_special
|
||||
? current.price_med_special
|
||||
: current.price_med;
|
||||
|
||||
// Calculate revenue (only for sales)
|
||||
let revenueRec: number | null = null;
|
||||
let revenueMed: number | null = null;
|
||||
if (changeType === 'sale' && qtyDelta !== null && qtyDelta < 0) {
|
||||
const unitsSold = Math.abs(qtyDelta);
|
||||
if (effectivePriceRec) {
|
||||
revenueRec = unitsSold * effectivePriceRec;
|
||||
}
|
||||
if (effectivePriceMed) {
|
||||
revenueMed = unitsSold * effectivePriceMed;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate hours since last snapshot
|
||||
let hoursSinceLast: number | null = null;
|
||||
if (previous?.captured_at) {
|
||||
const msDiff = now.getTime() - previous.captured_at.getTime();
|
||||
hoursSinceLast = Math.round((msDiff / 3600000) * 100) / 100; // 2 decimal places
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
prev_quantity: prevQty,
|
||||
prev_price_rec: previous?.price_rec ?? null,
|
||||
prev_price_med: previous?.price_med ?? null,
|
||||
prev_status: previous?.status ?? null,
|
||||
qty_delta: qtyDelta,
|
||||
price_delta: priceDelta,
|
||||
change_type: changeType,
|
||||
effective_price_rec: effectivePriceRec,
|
||||
effective_price_med: effectivePriceMed,
|
||||
revenue_rec: revenueRec,
|
||||
revenue_med: revenueMed,
|
||||
hours_since_last: hoursSinceLast,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous snapshot state for a dispensary.
|
||||
* Returns a map of product_id -> previous state.
|
||||
*/
|
||||
export async function getPreviousSnapshots(
|
||||
pool: Pool,
|
||||
dispensaryId: number
|
||||
): Promise<Map<string, PreviousState>> {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT DISTINCT ON (product_id)
|
||||
product_id,
|
||||
quantity_available,
|
||||
price_rec,
|
||||
price_med,
|
||||
status,
|
||||
captured_at
|
||||
FROM inventory_snapshots
|
||||
WHERE dispensary_id = $1
|
||||
ORDER BY product_id, captured_at DESC
|
||||
`,
|
||||
[dispensaryId]
|
||||
);
|
||||
|
||||
const map = new Map<string, PreviousState>();
|
||||
for (const row of result.rows) {
|
||||
map.set(row.product_id, {
|
||||
quantity_available: row.quantity_available,
|
||||
price_rec: row.price_rec ? parseFloat(row.price_rec) : null,
|
||||
price_med: row.price_med ? parseFloat(row.price_med) : null,
|
||||
status: row.status,
|
||||
captured_at: row.captured_at,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save delta-only inventory snapshots.
|
||||
* Only stores rows where something changed (qty, price, or status).
|
||||
*
|
||||
* @param pool - Database connection pool
|
||||
* @param dispensaryId - The dispensary ID
|
||||
* @param products - Array of raw products from the platform
|
||||
* @param platform - The platform type
|
||||
* @returns Number of snapshots saved
|
||||
* @returns Object with counts: { total, changed, sales, restocks }
|
||||
*/
|
||||
export async function saveInventorySnapshots(
|
||||
pool: Pool,
|
||||
dispensaryId: number,
|
||||
products: any[],
|
||||
platform: Platform
|
||||
): Promise<number> {
|
||||
): Promise<{ total: number; changed: number; sales: number; restocks: number; revenue: number }> {
|
||||
if (!products || products.length === 0) {
|
||||
return 0;
|
||||
return { total: 0, changed: 0, sales: 0, restocks: 0, revenue: 0 };
|
||||
}
|
||||
|
||||
const snapshots: SnapshotRow[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Get previous state for comparison
|
||||
const previousStates = await getPreviousSnapshots(pool, dispensaryId);
|
||||
|
||||
// Normalize products and calculate deltas
|
||||
const deltas: DeltaSnapshot[] = [];
|
||||
let salesCount = 0;
|
||||
let restockCount = 0;
|
||||
let totalRevenue = 0;
|
||||
|
||||
for (const product of products) {
|
||||
const row = normalizeProduct(product, platform);
|
||||
if (row) {
|
||||
snapshots.push(row);
|
||||
const normalized = normalizeProduct(product, platform);
|
||||
if (!normalized) continue;
|
||||
|
||||
const previous = previousStates.get(normalized.product_id) || null;
|
||||
const delta = calculateDelta(normalized, previous, now);
|
||||
|
||||
if (delta) {
|
||||
deltas.push(delta);
|
||||
if (delta.change_type === 'sale') {
|
||||
salesCount++;
|
||||
totalRevenue += (delta.revenue_rec || 0) + (delta.revenue_med || 0);
|
||||
} else if (delta.change_type === 'restock') {
|
||||
restockCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return 0;
|
||||
if (deltas.length === 0) {
|
||||
return { total: products.length, changed: 0, sales: 0, restocks: 0, revenue: 0 };
|
||||
}
|
||||
|
||||
// Bulk insert using VALUES list
|
||||
// Build parameterized query
|
||||
// Bulk insert deltas
|
||||
const values: any[] = [];
|
||||
const placeholders: string[] = [];
|
||||
|
||||
let paramIndex = 1;
|
||||
for (const s of snapshots) {
|
||||
for (const d of deltas) {
|
||||
placeholders.push(
|
||||
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`
|
||||
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`
|
||||
);
|
||||
values.push(
|
||||
dispensaryId,
|
||||
s.product_id,
|
||||
d.product_id,
|
||||
platform,
|
||||
s.quantity_available,
|
||||
s.is_below_threshold,
|
||||
s.status,
|
||||
s.price_rec,
|
||||
s.price_med,
|
||||
s.brand_name,
|
||||
s.category,
|
||||
s.product_name
|
||||
d.quantity_available,
|
||||
d.is_below_threshold,
|
||||
d.status,
|
||||
d.price_rec,
|
||||
d.price_med,
|
||||
d.brand_name,
|
||||
d.category,
|
||||
d.product_name,
|
||||
d.prev_quantity,
|
||||
d.prev_price_rec,
|
||||
d.prev_price_med,
|
||||
d.prev_status,
|
||||
d.qty_delta,
|
||||
d.price_delta,
|
||||
d.change_type,
|
||||
d.effective_price_rec,
|
||||
d.effective_price_med,
|
||||
d.revenue_rec,
|
||||
d.revenue_med,
|
||||
d.hours_since_last
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,45 +412,71 @@ export async function saveInventorySnapshots(
|
||||
price_med,
|
||||
brand_name,
|
||||
category,
|
||||
product_name
|
||||
product_name,
|
||||
prev_quantity,
|
||||
prev_price_rec,
|
||||
prev_price_med,
|
||||
prev_status,
|
||||
qty_delta,
|
||||
price_delta,
|
||||
change_type,
|
||||
effective_price_rec,
|
||||
effective_price_med,
|
||||
revenue_rec,
|
||||
revenue_med,
|
||||
hours_since_last
|
||||
) VALUES ${placeholders.join(', ')}
|
||||
`;
|
||||
|
||||
await pool.query(query, values);
|
||||
|
||||
return snapshots.length;
|
||||
return {
|
||||
total: products.length,
|
||||
changed: deltas.length,
|
||||
sales: salesCount,
|
||||
restocks: restockCount,
|
||||
revenue: Math.round(totalRevenue * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous snapshot for a dispensary (for delta calculation).
|
||||
* Returns a map of product_id -> snapshot data.
|
||||
* Get snapshot statistics for a dispensary
|
||||
*/
|
||||
export async function getPreviousSnapshots(
|
||||
export async function getSnapshotStats(
|
||||
pool: Pool,
|
||||
dispensaryId: number
|
||||
): Promise<Map<string, SnapshotRow>> {
|
||||
dispensaryId: number,
|
||||
hours: number = 24
|
||||
): Promise<{
|
||||
totalSnapshots: number;
|
||||
sales: number;
|
||||
restocks: number;
|
||||
priceChanges: number;
|
||||
oosEvents: number;
|
||||
revenue: number;
|
||||
}> {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT DISTINCT ON (product_id)
|
||||
product_id,
|
||||
quantity_available,
|
||||
is_below_threshold,
|
||||
status,
|
||||
price_rec,
|
||||
price_med,
|
||||
brand_name,
|
||||
category,
|
||||
product_name
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE change_type = 'sale') as sales,
|
||||
COUNT(*) FILTER (WHERE change_type = 'restock') as restocks,
|
||||
COUNT(*) FILTER (WHERE change_type = 'price_change') as price_changes,
|
||||
COUNT(*) FILTER (WHERE change_type = 'oos') as oos_events,
|
||||
COALESCE(SUM(revenue_rec), 0) + COALESCE(SUM(revenue_med), 0) as revenue
|
||||
FROM inventory_snapshots
|
||||
WHERE dispensary_id = $1
|
||||
ORDER BY product_id, captured_at DESC
|
||||
AND captured_at >= NOW() - INTERVAL '1 hour' * $2
|
||||
`,
|
||||
[dispensaryId]
|
||||
[dispensaryId, hours]
|
||||
);
|
||||
|
||||
const map = new Map<string, SnapshotRow>();
|
||||
for (const row of result.rows) {
|
||||
map.set(row.product_id, row);
|
||||
}
|
||||
return map;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
totalSnapshots: parseInt(row.total),
|
||||
sales: parseInt(row.sales),
|
||||
restocks: parseInt(row.restocks),
|
||||
priceChanges: parseInt(row.price_changes),
|
||||
oosEvents: parseInt(row.oos_events),
|
||||
revenue: parseFloat(row.revenue) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,9 +262,10 @@ class TaskScheduler {
|
||||
source: 'high_frequency_schedule',
|
||||
});
|
||||
|
||||
// Add jitter: interval + random(0, 20% of interval)
|
||||
const jitterMinutes = Math.floor(Math.random() * (store.crawl_interval_minutes * 0.2));
|
||||
const nextIntervalMinutes = store.crawl_interval_minutes + jitterMinutes;
|
||||
// Add jitter: interval + random(-3, +3) minutes
|
||||
const JITTER_MINUTES = 3;
|
||||
const jitterMinutes = Math.floor((Math.random() * JITTER_MINUTES * 2) - JITTER_MINUTES);
|
||||
const nextIntervalMinutes = Math.max(1, store.crawl_interval_minutes + jitterMinutes);
|
||||
|
||||
// Update next_crawl_at and last_crawl_started_at
|
||||
await pool.query(`
|
||||
|
||||
245
backend/src/services/wasabi-storage.ts
Normal file
245
backend/src/services/wasabi-storage.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Wasabi S3 Storage Service
|
||||
*
|
||||
* Stores raw crawl payloads to Wasabi S3-compatible storage for long-term archive.
|
||||
* Payloads can be reprocessed later if analytics logic changes.
|
||||
*
|
||||
* Environment variables:
|
||||
* - WASABI_ACCESS_KEY: Wasabi access key
|
||||
* - WASABI_SECRET_KEY: Wasabi secret key
|
||||
* - WASABI_BUCKET: Bucket name (default: cannaiq-payloads)
|
||||
* - WASABI_REGION: Region (default: us-east-1)
|
||||
* - WASABI_ENDPOINT: Endpoint URL (default: s3.wasabisys.com)
|
||||
*/
|
||||
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
interface WasabiConfig {
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
function getConfig(): WasabiConfig {
|
||||
return {
|
||||
accessKey: process.env.WASABI_ACCESS_KEY || '',
|
||||
secretKey: process.env.WASABI_SECRET_KEY || '',
|
||||
bucket: process.env.WASABI_BUCKET || 'cannaiq',
|
||||
region: process.env.WASABI_REGION || 'us-west-2',
|
||||
endpoint: process.env.WASABI_ENDPOINT || 'https://s3.us-west-2.wasabisys.com',
|
||||
};
|
||||
}
|
||||
|
||||
let s3Client: S3Client | null = null;
|
||||
|
||||
function getClient(): S3Client {
|
||||
if (s3Client) return s3Client;
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.accessKey || !config.secretKey) {
|
||||
throw new Error('Wasabi credentials not configured (WASABI_ACCESS_KEY, WASABI_SECRET_KEY)');
|
||||
}
|
||||
|
||||
s3Client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for Wasabi
|
||||
});
|
||||
|
||||
return s3Client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate storage path for a payload
|
||||
* Format: payloads/{state}/{YYYY-MM-DD}/{dispensary_id}/{timestamp}.json.gz
|
||||
*/
|
||||
export function getPayloadPath(
|
||||
dispensaryId: number,
|
||||
stateCode: string,
|
||||
platform: string,
|
||||
timestamp: Date = new Date()
|
||||
): string {
|
||||
const date = timestamp.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const ts = timestamp.toISOString().replace(/[:.]/g, '-'); // Safe filename
|
||||
return `payloads/${stateCode.toUpperCase()}/${date}/${dispensaryId}/${platform}_${ts}.json.gz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a raw payload to Wasabi
|
||||
* Compresses with gzip before upload to save space (~70% compression on JSON)
|
||||
*/
|
||||
export async function storePayload(
|
||||
dispensaryId: number,
|
||||
stateCode: string,
|
||||
platform: string,
|
||||
payload: any,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<{ path: string; sizeBytes: number; compressedBytes: number }> {
|
||||
const config = getConfig();
|
||||
const client = getClient();
|
||||
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const originalSize = Buffer.byteLength(jsonString, 'utf8');
|
||||
|
||||
// Compress with gzip
|
||||
const compressed = await new Promise<Buffer>((resolve, reject) => {
|
||||
zlib.gzip(jsonString, { level: 9 }, (err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
const path = getPayloadPath(dispensaryId, stateCode, platform);
|
||||
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: path,
|
||||
Body: compressed,
|
||||
ContentType: 'application/json',
|
||||
ContentEncoding: 'gzip',
|
||||
Metadata: {
|
||||
dispensaryId: String(dispensaryId),
|
||||
stateCode: stateCode,
|
||||
platform: platform,
|
||||
originalSize: String(originalSize),
|
||||
productCount: String(Array.isArray(payload) ? payload.length : 0),
|
||||
...metadata,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
path,
|
||||
sizeBytes: originalSize,
|
||||
compressedBytes: compressed.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a payload from Wasabi
|
||||
*/
|
||||
export async function getPayload(path: string): Promise<any> {
|
||||
const config = getConfig();
|
||||
const client = getClient();
|
||||
|
||||
const response = await client.send(new GetObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: path,
|
||||
}));
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error(`Empty response for ${path}`);
|
||||
}
|
||||
|
||||
// Read stream to buffer
|
||||
const stream = response.Body as Readable;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const compressed = Buffer.concat(chunks);
|
||||
|
||||
// Decompress
|
||||
const decompressed = await new Promise<Buffer>((resolve, reject) => {
|
||||
zlib.gunzip(compressed, (err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
return JSON.parse(decompressed.toString('utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* List payloads for a dispensary on a specific date
|
||||
*/
|
||||
export async function listPayloads(
|
||||
dispensaryId: number,
|
||||
stateCode: string,
|
||||
date: string // YYYY-MM-DD
|
||||
): Promise<string[]> {
|
||||
const config = getConfig();
|
||||
const client = getClient();
|
||||
|
||||
const prefix = `payloads/${stateCode.toUpperCase()}/${date}/${dispensaryId}/`;
|
||||
|
||||
const response = await client.send(new ListObjectsV2Command({
|
||||
Bucket: config.bucket,
|
||||
Prefix: prefix,
|
||||
}));
|
||||
|
||||
return (response.Contents || []).map(obj => obj.Key!).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Wasabi is configured and accessible
|
||||
*/
|
||||
export async function checkConnection(): Promise<{ connected: boolean; error?: string }> {
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.accessKey || !config.secretKey) {
|
||||
return { connected: false, error: 'Credentials not configured' };
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
|
||||
// Try to list bucket (will fail if credentials or bucket invalid)
|
||||
await client.send(new ListObjectsV2Command({
|
||||
Bucket: config.bucket,
|
||||
MaxKeys: 1,
|
||||
}));
|
||||
|
||||
return { connected: true };
|
||||
} catch (error: any) {
|
||||
return { connected: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
export async function getStorageStats(
|
||||
stateCode?: string,
|
||||
date?: string
|
||||
): Promise<{ objectCount: number; totalSizeBytes: number }> {
|
||||
const config = getConfig();
|
||||
const client = getClient();
|
||||
|
||||
let prefix = 'payloads/';
|
||||
if (stateCode) {
|
||||
prefix += `${stateCode.toUpperCase()}/`;
|
||||
if (date) {
|
||||
prefix += `${date}/`;
|
||||
}
|
||||
}
|
||||
|
||||
let objectCount = 0;
|
||||
let totalSizeBytes = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.send(new ListObjectsV2Command({
|
||||
Bucket: config.bucket,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}));
|
||||
|
||||
for (const obj of response.Contents || []) {
|
||||
objectCount++;
|
||||
totalSizeBytes += obj.Size || 0;
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
return { objectCount, totalSizeBytes };
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
|
||||
import { taskService } from '../task-service';
|
||||
import { saveInventorySnapshots } from '../../services/inventory-snapshots';
|
||||
import { detectVisibilityEvents } from '../../services/visibility-events';
|
||||
import { storePayload as storeWasabiPayload, checkConnection as checkWasabiConnection } from '../../services/wasabi-storage';
|
||||
|
||||
// GraphQL hash for FilteredProducts query - MUST match CLAUDE.md
|
||||
const FILTERED_PRODUCTS_HASH = 'ee29c060826dc41c527e470e9ae502c9b2c169720faa0a9f5d25e1b9a530a4a0';
|
||||
@@ -367,9 +368,8 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
|
||||
await ctx.heartbeat();
|
||||
|
||||
// ============================================================
|
||||
// STEP 5: Save daily baseline (full payload) if in window
|
||||
// Daily baselines are saved once per day per store (12:01 AM - 3:00 AM)
|
||||
// Outside this window, only inventory snapshots are saved (Step 5.5)
|
||||
// STEP 5: Archive raw payload to Wasabi S3 (long-term storage)
|
||||
// Every crawl is archived for potential reprocessing
|
||||
// ============================================================
|
||||
updateStep('saving', `Saving ${result.products.length} products`);
|
||||
const rawPayload = {
|
||||
@@ -381,6 +381,37 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
|
||||
products: result.products,
|
||||
};
|
||||
|
||||
// Archive to Wasabi S3 (if configured)
|
||||
let wasabiPath: string | null = null;
|
||||
try {
|
||||
const wasabiResult = await storeWasabiPayload(
|
||||
dispensaryId,
|
||||
dispensary.state || 'XX',
|
||||
'dutchie',
|
||||
rawPayload,
|
||||
{
|
||||
taskId: String(task.id),
|
||||
cName,
|
||||
productCount: String(result.products.length),
|
||||
}
|
||||
);
|
||||
wasabiPath = wasabiResult.path;
|
||||
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
|
||||
console.log(`[ProductDiscoveryHTTP] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
|
||||
} catch (wasabiErr: any) {
|
||||
// Wasabi archival is optional - don't fail the task if it fails
|
||||
if (wasabiErr.message?.includes('not configured')) {
|
||||
console.log(`[ProductDiscoveryHTTP] Wasabi not configured, skipping archive`);
|
||||
} else {
|
||||
console.warn(`[ProductDiscoveryHTTP] Wasabi archive failed: ${wasabiErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STEP 5b: Save daily baseline to PostgreSQL (if in window)
|
||||
// Daily baselines are saved once per day per store (12:01 AM - 3:00 AM)
|
||||
// Outside this window, only inventory snapshots are saved (Step 5.5)
|
||||
// ============================================================
|
||||
// saveDailyBaseline returns null if outside window or baseline already exists today
|
||||
const payloadResult = await saveDailyBaseline(
|
||||
pool,
|
||||
@@ -395,7 +426,7 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
|
||||
if (payloadResult) {
|
||||
console.log(`[ProductDiscoveryHTTP] Saved daily baseline #${payloadResult.id} (${(payloadResult.sizeBytes / 1024).toFixed(1)}KB)`);
|
||||
} else {
|
||||
console.log(`[ProductDiscoveryHTTP] Skipped full payload save (outside baseline window or already exists)`);
|
||||
console.log(`[ProductDiscoveryHTTP] Skipped PostgreSQL baseline (outside window or already exists)`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -459,6 +490,7 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
|
||||
productCount: result.products.length,
|
||||
sizeBytes: payloadResult?.sizeBytes || 0,
|
||||
baselineSaved: !!payloadResult,
|
||||
wasabiPath,
|
||||
snapshotCount,
|
||||
eventCount,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
|
||||
import { taskService } from '../task-service';
|
||||
import { saveInventorySnapshots } from '../../services/inventory-snapshots';
|
||||
import { detectVisibilityEvents } from '../../services/visibility-events';
|
||||
import { storePayload as storeWasabiPayload } from '../../services/wasabi-storage';
|
||||
|
||||
export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<TaskResult> {
|
||||
const { pool, task, crawlRotator } = ctx;
|
||||
@@ -36,7 +37,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
|
||||
try {
|
||||
// Load dispensary
|
||||
const dispResult = await pool.query(
|
||||
`SELECT id, name, menu_url, platform_dispensary_id, menu_type
|
||||
`SELECT id, name, menu_url, platform_dispensary_id, menu_type, state
|
||||
FROM dispensaries WHERE id = $1`,
|
||||
[dispensaryId]
|
||||
);
|
||||
@@ -99,7 +100,32 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
|
||||
storeId: dispensary.platform_dispensary_id,
|
||||
};
|
||||
|
||||
// Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day)
|
||||
// Archive to Wasabi S3 (if configured)
|
||||
let wasabiPath: string | null = null;
|
||||
try {
|
||||
const wasabiResult = await storeWasabiPayload(
|
||||
dispensaryId,
|
||||
dispensary.state || 'XX',
|
||||
'jane',
|
||||
rawPayload,
|
||||
{
|
||||
taskId: String(task.id),
|
||||
storeId: dispensary.platform_dispensary_id,
|
||||
productCount: String(result.products.length),
|
||||
}
|
||||
);
|
||||
wasabiPath = wasabiResult.path;
|
||||
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
|
||||
console.log(`[JaneProductDiscovery] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
|
||||
} catch (wasabiErr: any) {
|
||||
if (wasabiErr.message?.includes('not configured')) {
|
||||
console.log(`[JaneProductDiscovery] Wasabi not configured, skipping archive`);
|
||||
} else {
|
||||
console.warn(`[JaneProductDiscovery] Wasabi archive failed: ${wasabiErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save daily baseline to PostgreSQL (only in 12:01-3:00 AM window, once per day)
|
||||
const payloadResult = await saveDailyBaseline(
|
||||
pool,
|
||||
dispensaryId,
|
||||
@@ -113,7 +139,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
|
||||
if (payloadResult) {
|
||||
console.log(`[JaneProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`);
|
||||
} else {
|
||||
console.log(`[JaneProductDiscovery] Skipped full payload save (outside baseline window or already exists)`);
|
||||
console.log(`[JaneProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`);
|
||||
}
|
||||
|
||||
// Save inventory snapshots and detect visibility events
|
||||
@@ -155,6 +181,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
|
||||
payloadId: payloadResult?.id || null,
|
||||
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
|
||||
baselineSaved: !!payloadResult,
|
||||
wasabiPath,
|
||||
snapshotCount,
|
||||
eventCount,
|
||||
storeInfo: result.store ? {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
|
||||
import { taskService } from '../task-service';
|
||||
import { saveInventorySnapshots } from '../../services/inventory-snapshots';
|
||||
import { detectVisibilityEvents } from '../../services/visibility-events';
|
||||
import { storePayload as storeWasabiPayload } from '../../services/wasabi-storage';
|
||||
|
||||
export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<TaskResult> {
|
||||
const { pool, task, crawlRotator } = ctx;
|
||||
@@ -50,7 +51,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
|
||||
try {
|
||||
// Load dispensary
|
||||
const dispResult = await pool.query(
|
||||
`SELECT id, name, menu_url, platform_dispensary_id, menu_type, platform
|
||||
`SELECT id, name, menu_url, platform_dispensary_id, menu_type, platform, state
|
||||
FROM dispensaries WHERE id = $1`,
|
||||
[dispensaryId]
|
||||
);
|
||||
@@ -116,7 +117,32 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
|
||||
dispensaryId,
|
||||
};
|
||||
|
||||
// Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day)
|
||||
// Archive to Wasabi S3 (if configured)
|
||||
let wasabiPath: string | null = null;
|
||||
try {
|
||||
const wasabiResult = await storeWasabiPayload(
|
||||
dispensaryId,
|
||||
dispensary.state || 'XX',
|
||||
'treez',
|
||||
rawPayload,
|
||||
{
|
||||
taskId: String(task.id),
|
||||
storeId: result.storeId || 'unknown',
|
||||
productCount: String(result.products.length),
|
||||
}
|
||||
);
|
||||
wasabiPath = wasabiResult.path;
|
||||
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
|
||||
console.log(`[TreezProductDiscovery] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
|
||||
} catch (wasabiErr: any) {
|
||||
if (wasabiErr.message?.includes('not configured')) {
|
||||
console.log(`[TreezProductDiscovery] Wasabi not configured, skipping archive`);
|
||||
} else {
|
||||
console.warn(`[TreezProductDiscovery] Wasabi archive failed: ${wasabiErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save daily baseline to PostgreSQL (only in 12:01-3:00 AM window, once per day)
|
||||
const payloadResult = await saveDailyBaseline(
|
||||
pool,
|
||||
dispensaryId,
|
||||
@@ -130,7 +156,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
|
||||
if (payloadResult) {
|
||||
console.log(`[TreezProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`);
|
||||
} else {
|
||||
console.log(`[TreezProductDiscovery] Skipped full payload save (outside baseline window or already exists)`);
|
||||
console.log(`[TreezProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`);
|
||||
}
|
||||
|
||||
// Save inventory snapshots and detect visibility events
|
||||
@@ -171,6 +197,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
|
||||
payloadId: payloadResult?.id || null,
|
||||
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
|
||||
baselineSaved: !!payloadResult,
|
||||
wasabiPath,
|
||||
snapshotCount,
|
||||
eventCount,
|
||||
storeId: result.storeId,
|
||||
|
||||
@@ -194,6 +194,21 @@ class TaskService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ENFORCE MAX TASK LIMIT - check before ANY claiming path
|
||||
const workerCheck = await pool.query(`
|
||||
SELECT session_task_count, COALESCE(session_max_tasks, 5) as max_tasks
|
||||
FROM worker_registry
|
||||
WHERE worker_id = $1
|
||||
`, [workerId]);
|
||||
|
||||
if (workerCheck.rows.length > 0) {
|
||||
const { session_task_count, max_tasks } = workerCheck.rows[0];
|
||||
if (session_task_count >= max_tasks) {
|
||||
console.log(`[TaskService] Worker ${workerId} at max capacity (${session_task_count}/${max_tasks})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (role) {
|
||||
// Role-specific claiming - use the SQL function with preflight capabilities
|
||||
const result = await pool.query(
|
||||
@@ -233,6 +248,15 @@ class TaskService {
|
||||
RETURNING *
|
||||
`, [workerId, curlPassed, httpPassed]);
|
||||
|
||||
// Increment session_task_count if task was claimed
|
||||
if (result.rows[0]) {
|
||||
await pool.query(`
|
||||
UPDATE worker_registry
|
||||
SET session_task_count = session_task_count + 1
|
||||
WHERE worker_id = $1
|
||||
`, [workerId]);
|
||||
}
|
||||
|
||||
return (result.rows[0] as WorkerTask) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,9 +131,9 @@ const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3010';
|
||||
// Browser tasks (Puppeteer) use ~400MB RAM each. With 2GB pod limit:
|
||||
// - 3 browsers = ~1.3GB = SAFE
|
||||
// - 4 browsers = ~1.7GB = RISKY
|
||||
// - 5+ browsers = OOM CRASH
|
||||
// - 5 browsers = ~2.0GB = AT LIMIT (monitor memory closely)
|
||||
// See: docs/WORKER_TASK_ARCHITECTURE.md#browser-task-memory-limits
|
||||
const MAX_CONCURRENT_TASKS = parseInt(process.env.MAX_CONCURRENT_TASKS || '3');
|
||||
const MAX_CONCURRENT_TASKS = parseInt(process.env.MAX_CONCURRENT_TASKS || '5');
|
||||
|
||||
// When heap memory usage exceeds this threshold (as decimal 0.0-1.0), stop claiming new tasks
|
||||
// Default 85% - gives headroom before OOM
|
||||
|
||||
@@ -51,6 +51,9 @@ import { ProxyManagement } from './pages/ProxyManagement';
|
||||
import TasksDashboard from './pages/TasksDashboard';
|
||||
import { PayloadsDashboard } from './pages/PayloadsDashboard';
|
||||
import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard';
|
||||
import { HighFrequencyManager } from './pages/HighFrequencyManager';
|
||||
import { VisibilityEventsDashboard } from './pages/VisibilityEventsDashboard';
|
||||
import { InventorySnapshotsDashboard } from './pages/InventorySnapshotsDashboard';
|
||||
import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator';
|
||||
import { StatePage } from './pages/public/StatePage';
|
||||
import { SeoPage } from './pages/public/SeoPage';
|
||||
@@ -135,6 +138,10 @@ export default function App() {
|
||||
<Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} />
|
||||
{/* Scraper Overview Dashboard (new primary) */}
|
||||
<Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} />
|
||||
{/* Inventory Tracking routes */}
|
||||
<Route path="/inventory/high-frequency" element={<PrivateRoute><HighFrequencyManager /></PrivateRoute>} />
|
||||
<Route path="/inventory/events" element={<PrivateRoute><VisibilityEventsDashboard /></PrivateRoute>} />
|
||||
<Route path="/inventory/snapshots" element={<PrivateRoute><InventorySnapshotsDashboard /></PrivateRoute>} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
141
cannaiq/src/components/EventTypeBadge.tsx
Normal file
141
cannaiq/src/components/EventTypeBadge.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Event Type Badge Component
|
||||
*
|
||||
* Color-coded badge for visibility event types.
|
||||
* Used in VisibilityEventsDashboard and brand event views.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type EventType = 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
|
||||
|
||||
interface EventTypeConfig {
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_CONFIG: Record<EventType, EventTypeConfig> = {
|
||||
oos: {
|
||||
label: 'Out of Stock',
|
||||
shortLabel: 'OOS',
|
||||
bgColor: 'bg-red-600',
|
||||
textColor: 'text-white',
|
||||
icon: '!',
|
||||
description: 'Product went out of stock',
|
||||
},
|
||||
back_in_stock: {
|
||||
label: 'Back in Stock',
|
||||
shortLabel: 'In Stock',
|
||||
bgColor: 'bg-green-600',
|
||||
textColor: 'text-white',
|
||||
icon: '+',
|
||||
description: 'Product returned to stock',
|
||||
},
|
||||
brand_dropped: {
|
||||
label: 'Brand Dropped',
|
||||
shortLabel: 'Dropped',
|
||||
bgColor: 'bg-orange-600',
|
||||
textColor: 'text-white',
|
||||
icon: '-',
|
||||
description: 'Brand no longer at this store',
|
||||
},
|
||||
brand_added: {
|
||||
label: 'Brand Added',
|
||||
shortLabel: 'Added',
|
||||
bgColor: 'bg-blue-600',
|
||||
textColor: 'text-white',
|
||||
icon: '+',
|
||||
description: 'New brand at this store',
|
||||
},
|
||||
price_change: {
|
||||
label: 'Price Change',
|
||||
shortLabel: 'Price',
|
||||
bgColor: 'bg-yellow-600',
|
||||
textColor: 'text-black',
|
||||
icon: '$',
|
||||
description: 'Significant price change (>5%)',
|
||||
},
|
||||
};
|
||||
|
||||
interface EventTypeBadgeProps {
|
||||
type: EventType;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EventTypeBadge({
|
||||
type,
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
showIcon = true,
|
||||
className = '',
|
||||
}: EventTypeBadgeProps) {
|
||||
const config = EVENT_TYPE_CONFIG[type];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 rounded-full font-medium
|
||||
${config.bgColor} ${config.textColor}
|
||||
${sizeClasses[size]}
|
||||
${className}
|
||||
`}
|
||||
title={config.description}
|
||||
>
|
||||
{showIcon && (
|
||||
<span className="font-bold">{config.icon}</span>
|
||||
)}
|
||||
{showLabel && (
|
||||
<span>{size === 'sm' ? config.shortLabel : config.label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event type configuration
|
||||
*/
|
||||
export function getEventTypeConfig(type: EventType): EventTypeConfig {
|
||||
return EVENT_TYPE_CONFIG[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event types for filtering
|
||||
*/
|
||||
export function getAllEventTypes(): { value: EventType; label: string }[] {
|
||||
return Object.entries(EVENT_TYPE_CONFIG).map(([value, config]) => ({
|
||||
value: value as EventType,
|
||||
label: config.label,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price change for display
|
||||
*/
|
||||
export function formatPriceChange(
|
||||
previousPrice: number | null,
|
||||
newPrice: number | null,
|
||||
pctChange: number | null
|
||||
): string {
|
||||
if (previousPrice === null || newPrice === null) return 'N/A';
|
||||
|
||||
const diff = newPrice - previousPrice;
|
||||
const sign = diff > 0 ? '+' : '';
|
||||
const pct = pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
|
||||
|
||||
return `$${previousPrice.toFixed(2)} -> $${newPrice.toFixed(2)}${pct}`;
|
||||
}
|
||||
|
||||
export default EventTypeBadge;
|
||||
93
cannaiq/src/components/IntervalDropdown.tsx
Normal file
93
cannaiq/src/components/IntervalDropdown.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Interval Dropdown Component
|
||||
*
|
||||
* Dropdown selector for high-frequency crawl intervals.
|
||||
* Used in HighFrequencyManager and DispensaryDetail pages.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface IntervalOption {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const INTERVAL_OPTIONS: IntervalOption[] = [
|
||||
{ value: 15, label: '15 minutes' },
|
||||
{ value: 30, label: '30 minutes' },
|
||||
{ value: 60, label: '1 hour' },
|
||||
{ value: 120, label: '2 hours' },
|
||||
{ value: 240, label: '4 hours' },
|
||||
];
|
||||
|
||||
interface IntervalDropdownProps {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
includeNone?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function IntervalDropdown({
|
||||
value,
|
||||
onChange,
|
||||
includeNone = true,
|
||||
disabled = false,
|
||||
className = '',
|
||||
size = 'md',
|
||||
}: IntervalDropdownProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-3 py-2 text-base',
|
||||
lg: 'px-4 py-3 text-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onChange(val === '' ? null : parseInt(val, 10));
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
${sizeClasses[size]}
|
||||
bg-gray-800 border border-gray-700 rounded-md text-white
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{includeNone && <option value="">No high-frequency</option>}
|
||||
{INTERVAL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format interval minutes to human-readable string
|
||||
*/
|
||||
export function formatInterval(minutes: number | null): string {
|
||||
if (minutes === null) return 'Standard';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = minutes / 60;
|
||||
return hours === 1 ? '1 hour' : `${hours} hours`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interval badge color
|
||||
*/
|
||||
export function getIntervalColor(minutes: number | null): string {
|
||||
if (minutes === null) return 'bg-gray-600';
|
||||
if (minutes <= 15) return 'bg-red-600';
|
||||
if (minutes <= 30) return 'bg-orange-600';
|
||||
if (minutes <= 60) return 'bg-yellow-600';
|
||||
return 'bg-green-600';
|
||||
}
|
||||
|
||||
export default IntervalDropdown;
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
Key,
|
||||
Bot,
|
||||
ListChecks,
|
||||
Database
|
||||
Database,
|
||||
Clock,
|
||||
Bell,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -176,6 +179,12 @@ export function Layout({ children }: LayoutProps) {
|
||||
<NavLink to="/analytics/clicks" icon={<MousePointerClick className="w-4 h-4" />} label="Click Analytics" isActive={isActive('/analytics/clicks')} />
|
||||
</NavSection>
|
||||
|
||||
<NavSection title="Inventory">
|
||||
<NavLink to="/inventory/high-frequency" icon={<Clock className="w-4 h-4" />} label="High-Frequency" isActive={isActive('/inventory/high-frequency')} />
|
||||
<NavLink to="/inventory/events" icon={<Bell className="w-4 h-4" />} label="Visibility Events" isActive={isActive('/inventory/events')} />
|
||||
<NavLink to="/inventory/snapshots" icon={<Package className="w-4 h-4" />} label="Snapshots" isActive={isActive('/inventory/snapshots')} />
|
||||
</NavSection>
|
||||
|
||||
<NavSection title="Admin">
|
||||
<NavLink to="/admin/orchestrator" icon={<Activity className="w-4 h-4" />} label="Orchestrator" isActive={isActive('/admin/orchestrator')} />
|
||||
<NavLink to="/users" icon={<UserCog className="w-4 h-4" />} label="Users" isActive={isActive('/users')} />
|
||||
|
||||
128
cannaiq/src/components/StockStatusBadge.tsx
Normal file
128
cannaiq/src/components/StockStatusBadge.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Stock Status Badge Component
|
||||
*
|
||||
* Color-coded badge for product stock status.
|
||||
* Used in inventory views and product intelligence displays.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface StockStatusBadgeProps {
|
||||
inStock: boolean;
|
||||
quantity?: number | null;
|
||||
daysUntilOOS?: number | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showQuantity?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StockStatusBadge({
|
||||
inStock,
|
||||
quantity,
|
||||
daysUntilOOS,
|
||||
size = 'md',
|
||||
showQuantity = false,
|
||||
className = '',
|
||||
}: StockStatusBadgeProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
// Determine badge color based on stock status and days until OOS
|
||||
let bgColor = 'bg-gray-600';
|
||||
let textColor = 'text-white';
|
||||
let label = 'Unknown';
|
||||
|
||||
if (!inStock) {
|
||||
bgColor = 'bg-red-600';
|
||||
label = 'Out of Stock';
|
||||
} else if (daysUntilOOS !== null && daysUntilOOS <= 3) {
|
||||
bgColor = 'bg-orange-600';
|
||||
label = `Low (${daysUntilOOS}d)`;
|
||||
} else if (daysUntilOOS !== null && daysUntilOOS <= 7) {
|
||||
bgColor = 'bg-yellow-600';
|
||||
textColor = 'text-black';
|
||||
label = `Moderate (${daysUntilOOS}d)`;
|
||||
} else if (inStock) {
|
||||
bgColor = 'bg-green-600';
|
||||
label = 'In Stock';
|
||||
}
|
||||
|
||||
// Add quantity if requested
|
||||
if (showQuantity && quantity !== null && quantity !== undefined) {
|
||||
label = `${label} (${quantity})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full font-medium
|
||||
${bgColor} ${textColor}
|
||||
${sizeClasses[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days Until Stock Out Indicator
|
||||
*/
|
||||
interface DaysUntilOOSProps {
|
||||
days: number | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DaysUntilOOS({ days, className = '' }: DaysUntilOOSProps) {
|
||||
if (days === null) {
|
||||
return (
|
||||
<span className={`text-gray-500 ${className}`}>-</span>
|
||||
);
|
||||
}
|
||||
|
||||
let color = 'text-green-500';
|
||||
if (days <= 3) {
|
||||
color = 'text-red-500';
|
||||
} else if (days <= 7) {
|
||||
color = 'text-yellow-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-medium ${color} ${className}`}>
|
||||
{days}d
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock Diff Indicator
|
||||
* Shows the change in stock over a period (e.g., 120 days)
|
||||
*/
|
||||
interface StockDiffProps {
|
||||
diff: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StockDiff({ diff, className = '' }: StockDiffProps) {
|
||||
let color = 'text-gray-500';
|
||||
let sign = '';
|
||||
|
||||
if (diff > 0) {
|
||||
color = 'text-green-500';
|
||||
sign = '+';
|
||||
} else if (diff < 0) {
|
||||
color = 'text-red-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-medium ${color} ${className}`}>
|
||||
{sign}{diff}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StockStatusBadge;
|
||||
122
cannaiq/src/components/VelocityTierBadge.tsx
Normal file
122
cannaiq/src/components/VelocityTierBadge.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Velocity Tier Badge Component
|
||||
*
|
||||
* Color-coded badge for SKU velocity tiers.
|
||||
* Used in product intelligence and SKU velocity views.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type VelocityTier = 'hot' | 'steady' | 'slow' | 'stale';
|
||||
|
||||
interface TierConfig {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
unitsPerDay: string;
|
||||
}
|
||||
|
||||
const TIER_CONFIG: Record<VelocityTier, TierConfig> = {
|
||||
hot: {
|
||||
label: 'Hot',
|
||||
bgColor: 'bg-red-600',
|
||||
textColor: 'text-white',
|
||||
icon: '\u{1F525}', // Fire emoji
|
||||
description: 'High velocity - 5+ units/day',
|
||||
unitsPerDay: '5+',
|
||||
},
|
||||
steady: {
|
||||
label: 'Steady',
|
||||
bgColor: 'bg-green-600',
|
||||
textColor: 'text-white',
|
||||
icon: '\u{2705}', // Checkmark
|
||||
description: 'Moderate velocity - 1-5 units/day',
|
||||
unitsPerDay: '1-5',
|
||||
},
|
||||
slow: {
|
||||
label: 'Slow',
|
||||
bgColor: 'bg-yellow-600',
|
||||
textColor: 'text-black',
|
||||
icon: '\u{1F422}', // Turtle
|
||||
description: 'Low velocity - 0.1-1 units/day',
|
||||
unitsPerDay: '0.1-1',
|
||||
},
|
||||
stale: {
|
||||
label: 'Stale',
|
||||
bgColor: 'bg-gray-600',
|
||||
textColor: 'text-white',
|
||||
icon: '\u{1F4A4}', // Zzz
|
||||
description: 'No movement - <0.1 units/day',
|
||||
unitsPerDay: '<0.1',
|
||||
},
|
||||
};
|
||||
|
||||
interface VelocityTierBadgeProps {
|
||||
tier: VelocityTier;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VelocityTierBadge({
|
||||
tier,
|
||||
size = 'md',
|
||||
showIcon = false,
|
||||
className = '',
|
||||
}: VelocityTierBadgeProps) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 rounded-full font-medium
|
||||
${config.bgColor} ${config.textColor}
|
||||
${sizeClasses[size]}
|
||||
${className}
|
||||
`}
|
||||
title={config.description}
|
||||
>
|
||||
{showIcon && <span>{config.icon}</span>}
|
||||
<span>{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tier configuration
|
||||
*/
|
||||
export function getTierConfig(tier: VelocityTier): TierConfig {
|
||||
return TIER_CONFIG[tier];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all velocity tiers for filtering
|
||||
*/
|
||||
export function getAllVelocityTiers(): { value: VelocityTier; label: string; description: string }[] {
|
||||
return Object.entries(TIER_CONFIG).map(([value, config]) => ({
|
||||
value: value as VelocityTier,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine velocity tier from avg daily units
|
||||
*/
|
||||
export function getVelocityTier(avgDailyUnits: number | null): VelocityTier {
|
||||
if (avgDailyUnits === null) return 'stale';
|
||||
if (avgDailyUnits >= 5) return 'hot';
|
||||
if (avgDailyUnits >= 1) return 'steady';
|
||||
if (avgDailyUnits >= 0.1) return 'slow';
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
export default VelocityTierBadge;
|
||||
@@ -3231,6 +3231,399 @@ class ApiClient {
|
||||
};
|
||||
}>(`/api/payloads/store/${dispensaryId}/diff${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SALES ANALYTICS API (Materialized Views)
|
||||
// Part of Real-Time Inventory Tracking feature
|
||||
// ============================================================
|
||||
|
||||
async getDailySalesEstimates(params?: {
|
||||
state?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
dispensary_id?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.brand) searchParams.append('brand', params.brand);
|
||||
if (params?.category) searchParams.append('category', params.category);
|
||||
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: DailySalesEstimate[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/daily-sales${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getBrandMarketShare(params?: {
|
||||
state?: string;
|
||||
brand?: string;
|
||||
min_penetration?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.brand) searchParams.append('brand', params.brand);
|
||||
if (params?.min_penetration) searchParams.append('min_penetration', String(params.min_penetration));
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: BrandMarketShare[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/brand-market-share${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getSkuVelocity(params?: {
|
||||
state?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
dispensary_id?: number;
|
||||
tier?: 'hot' | 'steady' | 'slow' | 'stale';
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.brand) searchParams.append('brand', params.brand);
|
||||
if (params?.category) searchParams.append('category', params.category);
|
||||
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
|
||||
if (params?.tier) searchParams.append('tier', params.tier);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: SkuVelocity[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/sku-velocity${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getStorePerformance(params?: {
|
||||
state?: string;
|
||||
sort_by?: 'revenue' | 'units' | 'brands' | 'skus';
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.sort_by) searchParams.append('sort_by', params.sort_by);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: StorePerformance[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/store-performance${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getCategoryTrends(params?: {
|
||||
state?: string;
|
||||
category?: string;
|
||||
weeks?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.category) searchParams.append('category', params.category);
|
||||
if (params?.weeks) searchParams.append('weeks', String(params.weeks));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: CategoryTrend[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/category-trends${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getProductIntelligence(params?: {
|
||||
state?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
dispensary_id?: number;
|
||||
in_stock?: boolean;
|
||||
low_stock?: boolean;
|
||||
recent_oos?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.brand) searchParams.append('brand', params.brand);
|
||||
if (params?.category) searchParams.append('category', params.category);
|
||||
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
|
||||
if (params?.in_stock !== undefined) searchParams.append('in_stock', String(params.in_stock));
|
||||
if (params?.low_stock !== undefined) searchParams.append('low_stock', String(params.low_stock));
|
||||
if (params?.recent_oos !== undefined) searchParams.append('recent_oos', String(params.recent_oos));
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: ProductIntelligence[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/product-intelligence${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getTopBrands(params?: {
|
||||
state?: string;
|
||||
window?: '7d' | '30d' | '90d' | '1y' | 'all';
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.window) searchParams.append('window', params.window);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: TopBrand[];
|
||||
count: number;
|
||||
}>(`/api/sales-analytics/top-brands${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async refreshSalesAnalytics() {
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: Array<{ view_name: string; rows_affected: number }>;
|
||||
}>('/api/sales-analytics/refresh', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getSalesAnalyticsStats() {
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
data: Record<string, number>;
|
||||
}>('/api/sales-analytics/stats');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INVENTORY SNAPSHOTS & VISIBILITY EVENTS API
|
||||
// Part of Real-Time Inventory Tracking feature
|
||||
// ============================================================
|
||||
|
||||
async getInventorySnapshots(params?: {
|
||||
dispensary_id?: number;
|
||||
product_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
|
||||
if (params?.product_id) searchParams.append('product_id', params.product_id);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
snapshots: InventorySnapshot[];
|
||||
count: number;
|
||||
}>(`/api/tasks/inventory-snapshots${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getVisibilityEvents(params?: {
|
||||
dispensary_id?: number;
|
||||
brand?: string;
|
||||
event_type?: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
|
||||
if (params?.brand) searchParams.append('brand', params.brand);
|
||||
if (params?.event_type) searchParams.append('event_type', params.event_type);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
events: VisibilityEvent[];
|
||||
count: number;
|
||||
}>(`/api/tasks/visibility-events${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async acknowledgeVisibilityEvent(eventId: number) {
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`/api/tasks/visibility-events/${eventId}/acknowledge`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getBrandVisibilityEvents(brand: string, params?: {
|
||||
state?: string;
|
||||
event_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.event_type) searchParams.append('event_type', params.event_type);
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||
const query = searchParams.toString();
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
events: BrandVisibilityEvent[];
|
||||
count: number;
|
||||
}>(`/api/brands/${encodeURIComponent(brand)}/events${query ? '?' + query : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SALES ANALYTICS 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;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
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';
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
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;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
export interface CategoryTrend {
|
||||
category: string;
|
||||
state_code: string;
|
||||
week_start: string;
|
||||
sku_count: number;
|
||||
store_count: number;
|
||||
total_units: number;
|
||||
total_revenue: number;
|
||||
avg_price: number | null;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductIntelligence {
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
state_code: string;
|
||||
city: string | null;
|
||||
sku: string;
|
||||
product_name: string;
|
||||
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;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
export interface TopBrand {
|
||||
brand_name: string;
|
||||
total_revenue: number;
|
||||
total_units: number;
|
||||
store_count: number;
|
||||
sku_count: number;
|
||||
avg_price: number | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INVENTORY & VISIBILITY TYPES
|
||||
// ============================================================
|
||||
|
||||
export interface InventorySnapshot {
|
||||
id: number;
|
||||
dispensary_id: number;
|
||||
product_id: string;
|
||||
platform: 'dutchie' | 'jane' | 'treez';
|
||||
quantity_available: number | null;
|
||||
is_below_threshold: boolean;
|
||||
status: string | null;
|
||||
price_rec: number | null;
|
||||
price_med: number | null;
|
||||
brand_name: string | null;
|
||||
category: string | null;
|
||||
product_name: string | null;
|
||||
captured_at: string;
|
||||
}
|
||||
|
||||
export interface VisibilityEvent {
|
||||
id: number;
|
||||
dispensary_id: number;
|
||||
dispensary_name?: string;
|
||||
product_id: string | null;
|
||||
product_name: string | null;
|
||||
brand_name: string | null;
|
||||
event_type: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
|
||||
detected_at: string;
|
||||
previous_quantity: number | null;
|
||||
previous_price: number | null;
|
||||
new_price: number | null;
|
||||
price_change_pct: number | null;
|
||||
platform: 'dutchie' | 'jane' | 'treez';
|
||||
notified: boolean;
|
||||
acknowledged_at: string | null;
|
||||
}
|
||||
|
||||
export interface BrandVisibilityEvent {
|
||||
id: number;
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
state_code: string | null;
|
||||
product_id: string | null;
|
||||
product_name: string | null;
|
||||
brand_name: string;
|
||||
event_type: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
|
||||
detected_at: string;
|
||||
previous_price: number | null;
|
||||
new_price: number | null;
|
||||
price_change_pct: number | null;
|
||||
platform: 'dutchie' | 'jane' | 'treez';
|
||||
}
|
||||
|
||||
// Type for task schedules
|
||||
|
||||
342
cannaiq/src/pages/HighFrequencyManager.tsx
Normal file
342
cannaiq/src/pages/HighFrequencyManager.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* High-Frequency Manager Page
|
||||
*
|
||||
* View and manage stores with custom high-frequency crawl intervals.
|
||||
* Part of Real-Time Inventory Tracking feature.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api, HighFrequencyStore } from '../lib/api';
|
||||
import { IntervalDropdown, formatInterval, getIntervalColor } from '../components/IntervalDropdown';
|
||||
import {
|
||||
Clock,
|
||||
Store,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Stats {
|
||||
totalStores: number;
|
||||
byInterval: Record<number, number>;
|
||||
byPlatform: Record<string, number>;
|
||||
nextDueCount: number;
|
||||
}
|
||||
|
||||
export function HighFrequencyManager() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<HighFrequencyStore[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [updating, setUpdating] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.getHighFrequencySchedules();
|
||||
setStores(data.stores || []);
|
||||
setStats(data.stats || null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load high-frequency schedules:', err);
|
||||
setError(err.message || 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntervalChange = async (dispensaryId: number, intervalMinutes: number | null) => {
|
||||
try {
|
||||
setUpdating(dispensaryId);
|
||||
setError(null);
|
||||
|
||||
if (intervalMinutes === null) {
|
||||
await api.removeHighFrequencyInterval(dispensaryId);
|
||||
setSuccess('High-frequency scheduling removed');
|
||||
} else {
|
||||
await api.setHighFrequencyInterval(dispensaryId, intervalMinutes);
|
||||
setSuccess(`Interval updated to ${formatInterval(intervalMinutes)}`);
|
||||
}
|
||||
|
||||
// Reload data
|
||||
await loadData();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update interval:', err);
|
||||
setError(err.message || 'Failed to update interval');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (dispensaryId: number) => {
|
||||
if (!confirm('Remove high-frequency scheduling for this store?')) return;
|
||||
await handleIntervalChange(dispensaryId, null);
|
||||
};
|
||||
|
||||
// Filter stores by search term
|
||||
const filteredStores = stores.filter((store) =>
|
||||
store.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (ts: string | null) => {
|
||||
if (!ts) return '-';
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
// Format relative time
|
||||
const formatRelativeTime = (ts: string | null) => {
|
||||
if (!ts) return '-';
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-400">Loading high-frequency schedules...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Clock className="h-6 w-6 text-blue-500" />
|
||||
High-Frequency Manager
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Manage stores with custom crawl intervals for real-time inventory tracking
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 p-4 bg-green-900/50 border border-green-700 rounded-lg flex items-center gap-2 text-green-200">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Store className="h-4 w-4" />
|
||||
Total Stores
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.totalStores}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Clock className="h-4 w-4" />
|
||||
Next Due
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.nextDueCount}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
15m Interval
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.byInterval[15] || 0}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
30m Interval
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.byInterval[30] || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search stores..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stores Table */}
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Platform</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Interval</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Next Crawl</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Last Crawl</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Changes (24h)</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{filteredStores.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
{stores.length === 0
|
||||
? 'No stores configured for high-frequency crawling'
|
||||
: 'No stores match your search'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredStores.map((store) => (
|
||||
<tr key={store.id} className="hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/dispensaries/${store.id}`)}
|
||||
className="text-blue-400 hover:text-blue-300 font-medium"
|
||||
>
|
||||
{store.name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 bg-gray-700 rounded text-sm text-gray-300">
|
||||
{store.menu_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<IntervalDropdown
|
||||
value={store.crawl_interval_minutes}
|
||||
onChange={(val) => handleIntervalChange(store.id, val)}
|
||||
disabled={updating === store.id}
|
||||
size="sm"
|
||||
includeNone={true}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
{store.next_crawl_at ? (
|
||||
<span title={formatTime(store.next_crawl_at)}>
|
||||
{formatRelativeTime(store.next_crawl_at)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
{store.last_crawl_started_at ? (
|
||||
<span title={formatTime(store.last_crawl_started_at)}>
|
||||
{formatRelativeTime(store.last_crawl_started_at)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
store.inventory_changes_24h > 0
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{store.inventory_changes_24h} inv
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
store.price_changes_24h > 0
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{store.price_changes_24h} price
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleRemove(store.id)}
|
||||
disabled={updating === store.id}
|
||||
className="p-1 text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
title="Remove from high-frequency"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">About High-Frequency Crawling</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
High-frequency crawling allows you to track inventory changes in near real-time for select stores.
|
||||
Stores on 15-minute intervals will be crawled 96 times per day, enabling detection of:
|
||||
</p>
|
||||
<ul className="mt-2 text-sm text-gray-400 list-disc list-inside space-y-1">
|
||||
<li>Out-of-stock events</li>
|
||||
<li>Price changes ({'>'}5% threshold)</li>
|
||||
<li>Brand drops and additions</li>
|
||||
<li>Stock level changes for velocity calculations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default HighFrequencyManager;
|
||||
392
cannaiq/src/pages/InventorySnapshotsDashboard.tsx
Normal file
392
cannaiq/src/pages/InventorySnapshotsDashboard.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Inventory Snapshots Dashboard
|
||||
*
|
||||
* View inventory snapshots captured from high-frequency crawls.
|
||||
* Part of Real-Time Inventory Tracking feature.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api, InventorySnapshot } from '../lib/api';
|
||||
import {
|
||||
Database,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Search,
|
||||
Store,
|
||||
Package,
|
||||
Clock,
|
||||
TrendingDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SnapshotStats {
|
||||
total_snapshots: string;
|
||||
stores_tracked: string;
|
||||
products_tracked: string;
|
||||
oldest_snapshot: string;
|
||||
newest_snapshot: string;
|
||||
snapshots_24h: string;
|
||||
snapshots_1h: string;
|
||||
}
|
||||
|
||||
export function InventorySnapshotsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [snapshots, setSnapshots] = useState<InventorySnapshot[]>([]);
|
||||
const [stats, setStats] = useState<SnapshotStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dispensaryId, setDispensaryId] = useState<string>('');
|
||||
const [productId, setProductId] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const LIMIT = 50;
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadStats();
|
||||
}, [page]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params: any = {
|
||||
limit: LIMIT,
|
||||
offset: page * LIMIT,
|
||||
};
|
||||
|
||||
if (dispensaryId) {
|
||||
params.dispensary_id = parseInt(dispensaryId);
|
||||
}
|
||||
|
||||
if (productId) {
|
||||
params.product_id = productId;
|
||||
}
|
||||
|
||||
const data = await api.getInventorySnapshots(params);
|
||||
setSnapshots(data.snapshots || []);
|
||||
setHasMore((data.snapshots || []).length === LIMIT);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load snapshots:', err);
|
||||
setError(err.message || 'Failed to load snapshots');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; stats: SnapshotStats }>(
|
||||
'/api/tasks/inventory-snapshots/stats'
|
||||
);
|
||||
setStats(response.data.stats);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(0);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
// Get stock status color
|
||||
const getStockColor = (qty: number | null, isBelowThreshold: boolean) => {
|
||||
if (qty === null) return 'text-gray-400';
|
||||
if (qty === 0 || isBelowThreshold) return 'text-red-400';
|
||||
if (qty < 10) return 'text-yellow-400';
|
||||
return 'text-green-400';
|
||||
};
|
||||
|
||||
// Get platform badge color
|
||||
const getPlatformColor = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'dutchie':
|
||||
return 'bg-green-600';
|
||||
case 'jane':
|
||||
return 'bg-blue-600';
|
||||
case 'treez':
|
||||
return 'bg-purple-600';
|
||||
default:
|
||||
return 'bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Database className="h-6 w-6 text-purple-500" />
|
||||
Inventory Snapshots
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
View inventory snapshots captured from high-frequency crawls
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
loadData();
|
||||
loadStats();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Database className="h-4 w-4" />
|
||||
Total Snapshots
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">
|
||||
{parseInt(stats.total_snapshots).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Store className="h-4 w-4" />
|
||||
Stores Tracked
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.stores_tracked}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
Products Tracked
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">
|
||||
{parseInt(stats.products_tracked).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Clock className="h-4 w-4" />
|
||||
Last Hour
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">
|
||||
{parseInt(stats.snapshots_1h).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm text-gray-400 mb-1">Dispensary ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dispensaryId}
|
||||
onChange={(e) => setDispensaryId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Filter by dispensary ID..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm text-gray-400 mb-1">Product ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Filter by product ID..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshots Table */}
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Platform</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Product</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Brand</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Category</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Qty</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Price</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent mb-2"></div>
|
||||
<p>Loading snapshots...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : snapshots.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
|
||||
No inventory snapshots found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
<span title={formatTime(snapshot.captured_at)}>
|
||||
{formatRelativeTime(snapshot.captured_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs text-white ${getPlatformColor(
|
||||
snapshot.platform
|
||||
)}`}
|
||||
>
|
||||
{snapshot.platform}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/dispensaries/${snapshot.dispensary_id}`)}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
#{snapshot.dispensary_id}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white max-w-[200px] truncate">
|
||||
{snapshot.product_name || snapshot.product_id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
{snapshot.brand_name || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
{snapshot.category || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-sm text-right font-medium ${getStockColor(
|
||||
snapshot.quantity_available,
|
||||
snapshot.is_below_threshold
|
||||
)}`}
|
||||
>
|
||||
{snapshot.quantity_available ?? '-'}
|
||||
{snapshot.is_below_threshold && (
|
||||
<TrendingDown className="h-3 w-3 inline ml-1" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-300">
|
||||
{snapshot.price_rec ? `$${snapshot.price_rec.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
snapshot.status === 'Active' || snapshot.status === 'ACTIVE'
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{snapshot.status || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
Showing {snapshots.length} snapshots (page {page + 1})
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">About Inventory Snapshots</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Inventory snapshots capture the state of products during each crawl. They include:
|
||||
</p>
|
||||
<ul className="mt-2 text-sm text-gray-400 list-disc list-inside space-y-1">
|
||||
<li>Quantity available (for delta/velocity calculations)</li>
|
||||
<li>Price (recreational and medical)</li>
|
||||
<li>Stock status and low-stock indicators</li>
|
||||
<li>Brand and category information</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Data is normalized across all platforms (Dutchie, Jane, Treez) into a common format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventorySnapshotsDashboard;
|
||||
435
cannaiq/src/pages/VisibilityEventsDashboard.tsx
Normal file
435
cannaiq/src/pages/VisibilityEventsDashboard.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Visibility Events Dashboard
|
||||
*
|
||||
* View and manage product visibility events (OOS, price changes, brand drops, etc.)
|
||||
* Part of Real-Time Inventory Tracking feature.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api, VisibilityEvent } from '../lib/api';
|
||||
import { EventTypeBadge, getAllEventTypes, formatPriceChange, EventType } from '../components/EventTypeBadge';
|
||||
import {
|
||||
Bell,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Search,
|
||||
Filter,
|
||||
Check,
|
||||
Clock,
|
||||
Store,
|
||||
Package,
|
||||
DollarSign,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EventStats {
|
||||
total_events: string;
|
||||
oos_events: string;
|
||||
back_in_stock_events: string;
|
||||
brand_dropped_events: string;
|
||||
brand_added_events: string;
|
||||
price_change_events: string;
|
||||
events_24h: string;
|
||||
acknowledged_events: string;
|
||||
notified_events: string;
|
||||
}
|
||||
|
||||
export function VisibilityEventsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [events, setEvents] = useState<VisibilityEvent[]>([]);
|
||||
const [stats, setStats] = useState<EventStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<EventType | ''>('');
|
||||
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(new Set());
|
||||
const [acknowledging, setAcknowledging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const LIMIT = 50;
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadStats();
|
||||
}, [selectedType, page]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params: any = {
|
||||
limit: LIMIT,
|
||||
offset: page * LIMIT,
|
||||
};
|
||||
|
||||
if (selectedType) {
|
||||
params.event_type = selectedType;
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
params.brand = searchTerm;
|
||||
}
|
||||
|
||||
const data = await api.getVisibilityEvents(params);
|
||||
setEvents(data.events || []);
|
||||
setHasMore((data.events || []).length === LIMIT);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load visibility events:', err);
|
||||
setError(err.message || 'Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; stats: EventStats }>(
|
||||
'/api/tasks/visibility-events/stats'
|
||||
);
|
||||
setStats(response.data.stats);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (eventId: number) => {
|
||||
try {
|
||||
setAcknowledging(true);
|
||||
await api.acknowledgeVisibilityEvent(eventId);
|
||||
setSuccess('Event acknowledged');
|
||||
await loadData();
|
||||
await loadStats();
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to acknowledge event');
|
||||
} finally {
|
||||
setAcknowledging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAcknowledge = async () => {
|
||||
if (selectedEvents.size === 0) return;
|
||||
if (!confirm(`Acknowledge ${selectedEvents.size} events?`)) return;
|
||||
|
||||
try {
|
||||
setAcknowledging(true);
|
||||
await api.post('/api/tasks/visibility-events/acknowledge-bulk', {
|
||||
event_ids: Array.from(selectedEvents),
|
||||
});
|
||||
setSuccess(`${selectedEvents.size} events acknowledged`);
|
||||
setSelectedEvents(new Set());
|
||||
await loadData();
|
||||
await loadStats();
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to acknowledge events');
|
||||
} finally {
|
||||
setAcknowledging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedEvents.size === events.length) {
|
||||
setSelectedEvents(new Set());
|
||||
} else {
|
||||
setSelectedEvents(new Set(events.map((e) => e.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (eventId: number) => {
|
||||
const newSelected = new Set(selectedEvents);
|
||||
if (newSelected.has(eventId)) {
|
||||
newSelected.delete(eventId);
|
||||
} else {
|
||||
newSelected.add(eventId);
|
||||
}
|
||||
setSelectedEvents(newSelected);
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
const eventTypes = getAllEventTypes();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Bell className="h-6 w-6 text-yellow-500" />
|
||||
Visibility Events
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Track product out-of-stock, price changes, and brand visibility events
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedEvents.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkAcknowledge}
|
||||
disabled={acknowledging}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Acknowledge ({selectedEvents.size})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
loadData();
|
||||
loadStats();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 p-4 bg-green-900/50 border border-green-700 rounded-lg flex items-center gap-2 text-green-200">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Clock className="h-4 w-4" />
|
||||
Last 24h
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{stats.events_24h}</div>
|
||||
</div>
|
||||
<div className="bg-red-900/30 rounded-lg p-4 border border-red-700">
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
OOS Events
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-400 mt-1">{stats.oos_events}</div>
|
||||
</div>
|
||||
<div className="bg-green-900/30 rounded-lg p-4 border border-green-700">
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
Back in Stock
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-400 mt-1">{stats.back_in_stock_events}</div>
|
||||
</div>
|
||||
<div className="bg-yellow-900/30 rounded-lg p-4 border border-yellow-700">
|
||||
<div className="flex items-center gap-2 text-yellow-400 text-sm">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Price Changes
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-yellow-400 mt-1">{stats.price_change_events}</div>
|
||||
</div>
|
||||
<div className="bg-orange-900/30 rounded-lg p-4 border border-orange-700">
|
||||
<div className="flex items-center gap-2 text-orange-400 text-sm">
|
||||
<Tag className="h-4 w-4" />
|
||||
Brand Drops
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-400 mt-1">{stats.brand_dropped_events}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadData()}
|
||||
placeholder="Search by brand..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => {
|
||||
setSelectedType(e.target.value as EventType | '');
|
||||
setPage(0);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Event Types</option>
|
||||
{eventTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Events Table */}
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.size === events.length && events.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded bg-gray-700 border-gray-600 text-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Type</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Product/Brand</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Details</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Status</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent mb-2"></div>
|
||||
<p>Loading events...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
No visibility events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.has(event.id)}
|
||||
onChange={() => toggleSelect(event.id)}
|
||||
className="rounded bg-gray-700 border-gray-600 text-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<EventTypeBadge type={event.event_type} size="sm" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
<span title={formatTime(event.detected_at)}>
|
||||
{formatRelativeTime(event.detected_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/dispensaries/${event.dispensary_id}`)}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
{event.dispensary_name || `Store #${event.dispensary_id}`}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-white">{event.product_name || event.brand_name || '-'}</div>
|
||||
{event.product_name && event.brand_name && (
|
||||
<div className="text-xs text-gray-400">{event.brand_name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">
|
||||
{event.event_type === 'price_change'
|
||||
? formatPriceChange(event.previous_price, event.new_price, event.price_change_pct)
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{event.acknowledged_at ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/50 text-green-400 rounded text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
Acknowledged
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-700 text-gray-400 rounded text-xs">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!event.acknowledged_at && (
|
||||
<button
|
||||
onClick={() => handleAcknowledge(event.id)}
|
||||
disabled={acknowledging}
|
||||
className="px-2 py-1 bg-green-600 hover:bg-green-500 text-white rounded text-xs disabled:opacity-50"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
Showing {events.length} events (page {page + 1})
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisibilityEventsDashboard;
|
||||
@@ -805,7 +805,7 @@ function PodVisualization({
|
||||
// Get the single worker for this pod (1 worker_registry entry per K8s pod)
|
||||
const worker = workers[0];
|
||||
const activeTasks = worker?.active_tasks ?? [];
|
||||
const maxSlots = worker?.max_concurrent_tasks ?? 3;
|
||||
const maxSlots = worker?.max_concurrent_tasks ?? 5;
|
||||
const activeCount = activeTasks.length;
|
||||
const isBackingOff = worker?.metadata?.is_backing_off;
|
||||
const isDecommissioning = worker?.decommission_requested;
|
||||
|
||||
Reference in New Issue
Block a user