transceiver-db/sql/118-stock-velocity.sql
Rene Fichtmueller 0d7a92e749 feat: Abverkauf velocity engine — sql/118 + analyzer + API endpoints
- sql/118-stock-velocity.sql: new stock_velocity (UPSERT per tx×vendor)
  and stock_velocity_events tables with TimescaleDB-compatible indexes
- stock-velocity-analyzer.ts: computes sell-through from stock_observations
  time-series; detects sold/zulauf/data_gap events, trims top-10% outliers,
  predicts stockout date, assigns high/medium/low/insufficient confidence
- scheduler.ts: analyze:stock:velocity job at 04:30/12:30/20:30 UTC
- stock.ts: GET /api/stock/velocity (paginated, filterable by vendor/confidence/
  stockout_days) + GET /api/stock/velocity/:id (per-product with event history)
- First run: 208 products, 979 sell events, 2811 Zulauf events written
2026-05-14 00:24:58 +02:00

85 lines
4.6 KiB
SQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ══════════════════════════════════════════════════════════════════════════════
-- 118 — Stock Velocity & Sell-Through Analysis
--
-- Evaluates implied Abverkauf (sell-through) from time-series stock_observations:
-- • Negative stock delta → implied units sold (sell event)
-- • Positive stock delta after backorder → Zulauf (incoming replenishment)
-- • FS.com units_sold counter delta → high-confidence sell signal
--
-- Stores per-product velocity results in stock_velocity for API / dashboard use.
-- ══════════════════════════════════════════════════════════════════════════════
-- ── Main results table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS stock_velocity (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
transceiver_id UUID NOT NULL REFERENCES transceivers(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
computed_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Observation window
window_start TIMESTAMPTZ NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
obs_count INTEGER NOT NULL,
-- Sell-through metrics
avg_daily_sell_rate NUMERIC(12, 2), -- units/day (implied)
peak_daily_sell_rate NUMERIC(12, 2), -- highest single-interval rate
total_sell_events INTEGER DEFAULT 0,
total_units_sold_implied INTEGER DEFAULT 0,
-- FS.com direct counter (more reliable when available)
units_sold_counter_delta BIGINT, -- delta in FS.com units_sold between first/last obs
units_sold_daily_rate NUMERIC(12, 2), -- counter_delta / window_days
-- Zulauf (incoming stock / replenishment)
total_zulauf_events INTEGER DEFAULT 0,
total_units_zulauf INTEGER DEFAULT 0,
last_zulauf_at TIMESTAMPTZ,
next_expected_delivery DATE, -- backorder_estimated_date from latest obs
-- Current stock state (from latest observation)
current_qty INTEGER,
current_backorder_qty INTEGER,
current_price_net NUMERIC(10, 2),
-- Sell-through prediction
estimated_stockout_days NUMERIC(8, 1), -- NULL if no velocity or stock = 0
estimated_stockout_date DATE,
-- Signal quality
velocity_confidence TEXT CHECK (velocity_confidence IN ('high', 'medium', 'low', 'insufficient')),
-- high = ≥14 observations with meaningful deltas
-- medium = ≥5 observations
-- low = 24 observations
-- insufficient = only 1 observation or no change detected
UNIQUE (transceiver_id, vendor_id)
);
CREATE INDEX IF NOT EXISTS idx_stock_velocity_vendor ON stock_velocity (vendor_id);
CREATE INDEX IF NOT EXISTS idx_stock_velocity_computed ON stock_velocity (computed_at);
CREATE INDEX IF NOT EXISTS idx_stock_velocity_stockout ON stock_velocity (estimated_stockout_date)
WHERE estimated_stockout_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_stock_velocity_confidence ON stock_velocity (velocity_confidence);
COMMENT ON TABLE stock_velocity IS
'Computed sell-through velocity per transceiver per vendor, derived from '
'time-series stock_observations. Refreshed by analyze:stock:velocity job.';
-- ── Sell event log (raw events for trend analysis) ────────────────────────────
CREATE TABLE IF NOT EXISTS stock_velocity_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
transceiver_id UUID NOT NULL REFERENCES transceivers(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
event_at TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('sold', 'zulauf', 'unchanged', 'data_gap')),
units_delta INTEGER, -- negative = sold, positive = arrived
daily_rate NUMERIC(10, 2), -- implied rate for this interval
qty_before INTEGER,
qty_after INTEGER,
hours_elapsed NUMERIC(8, 2)
);
CREATE INDEX IF NOT EXISTS idx_velocity_events_tx ON stock_velocity_events (transceiver_id, vendor_id, event_at);
CREATE INDEX IF NOT EXISTS idx_velocity_events_type ON stock_velocity_events (event_type, event_at);