-- 164_mv_sales_tracker_velocity_absorption.sql
-- Issue #61 — Velocity materialized views for Site Finder Velocity Score (4th scoring
-- criterion) + recommend_mix smart unit-mix. Foundation for sellout forecast.
--
-- B2-1 data source ("шахматки" / sales-tracker): the Объектив scraper
-- (backend/app/workers/tasks/scrape_objective.py) → tables:
--   objective_lots          — 1.12M rows, one row per tracked lot (current state),
--                             carries district / rooms_int / area_pd / sales_start_date /
--                             is_sold / registration_date / contract_date / price_per_m2_rub.
--   objective_lots_history  — 974k rows, daily-ish per-lot snapshots
--                             (snapshot_date, is_sold, status, prices).
-- Snapshot history depth (as of 2026-06-17): 3 captures 2026-05-17 / 05-19 / 06-03 (spans
-- >2 weeks, sold count moved 193188->194893 => measurable absorption). Cohort/absorption
-- resolution improves automatically as the weekly scraper accumulates more snapshots.
--
-- -- MV 1: mv_sales_tracker_velocity_by_district --------------------------------------
--   Grain: (district, sale_month). One row per district per month.
--   Dedup: a lot appears in multiple snapshots within a month -> we keep that lot's LATEST
--          snapshot within the month (DISTINCT ON lot, snapshot_date DESC) before
--          aggregating, so total_count is lots-tracked-that-month (not snapshot rows).
--   Metrics: total_count, sold_count, avg_sold_price_per_m2, avg_sold_price_total,
--            sold_share (velocity proxy for SF Velocity Score).
--
-- -- MV 2: mv_sales_tracker_absorption_curves ----------------------------------------
--   Grain: (rooms_int, area_bucket, months_since_start). Cumulative sold% as f(months
--          from first_seen). "first_seen" = objective_lots.sales_start_date (true sales
--          launch — richer/longer than the 3-snapshot window). Sold-month anchor =
--          COALESCE(registration_date, contract_date). months_since_start clamped >= 0
--          (712 noise rows have anchor < start). 99.98% of sold lots carry both dates.
--          cohort_size = all lots in (rooms, area_bucket) cohort; cum_sold = sold lots
--          whose months_since_start <= the row's bucket; cum_sold_pct = cum_sold/cohort.
--   This is snapshot-sparsity-independent (driven by registration dates, not snapshots),
--          so the curve is usable today and the foundation for sellout forecast.
--
-- REFRESH CONCURRENTLY: both MVs get a UNIQUE index on their full grain immediately after
--   creation (on empty MV -> instant), enabling non-blocking weekly REFRESH CONCURRENTLY.
--   Scheduled via Celery beat `mv-sales-tracker-refresh-weekly` (Mon 04:30 MSK) ->
--   task app.workers.tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs.
--
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking (one-shot, NN order).
-- Dependencies on existing objects: objective_lots, objective_lots_history (read-only).
-- No views depend on these MVs at creation time.
--
-- WARN: re-apply (DR / lost _schema_migrations / dev local) DROP ... CASCADE снесёт MV +
--       зависимости. После re-apply ПЕРВЫЙ refresh = non-concurrent (CONCURRENTLY падает
--       на пустой/не-populated MV). _schema_migrations нормально предотвращает re-apply.

BEGIN;

-- ====================================================================================
-- MV 1: velocity by district x month
-- ====================================================================================
DROP MATERIALIZED VIEW IF EXISTS mv_sales_tracker_velocity_by_district CASCADE;

CREATE MATERIALIZED VIEW mv_sales_tracker_velocity_by_district AS
WITH lot_month AS (
    -- One row per (lot, month): the lot's latest snapshot within that month.
    SELECT DISTINCT ON (h.objective_lot_id, date_trunc('month', h.snapshot_date))
        l.district                                          AS district,
        date_trunc('month', h.snapshot_date)::date          AS sale_month,
        h.objective_lot_id,
        h.is_sold,
        h.price_per_m2_rub,
        h.price_calculated_total_rub
    FROM objective_lots_history h
    JOIN objective_lots l ON l.objective_lot_id = h.objective_lot_id
    WHERE l.district IS NOT NULL
    ORDER BY h.objective_lot_id,
             date_trunc('month', h.snapshot_date),
             h.snapshot_date DESC
)
SELECT
    district,
    sale_month,
    count(*)::int                                                       AS total_count,
    count(*) FILTER (WHERE is_sold)::int                                AS sold_count,
    round(
        count(*) FILTER (WHERE is_sold)::numeric
        / NULLIF(count(*), 0), 4
    )                                                                    AS sold_share,
    round(avg(price_per_m2_rub) FILTER (WHERE is_sold), 2)              AS avg_sold_price_per_m2,
    round(avg(price_calculated_total_rub) FILTER (WHERE is_sold), 2)    AS avg_sold_price_total
FROM lot_month
GROUP BY district, sale_month
WITH NO DATA;

-- UNIQUE index on full grain -> enables REFRESH CONCURRENTLY (created on empty MV = instant)
CREATE UNIQUE INDEX mv_sales_tracker_velocity_by_district_pk
    ON mv_sales_tracker_velocity_by_district (district, sale_month);

CREATE INDEX mv_sales_tracker_velocity_district_idx
    ON mv_sales_tracker_velocity_by_district (district);

REFRESH MATERIALIZED VIEW mv_sales_tracker_velocity_by_district;

COMMENT ON MATERIALIZED VIEW mv_sales_tracker_velocity_by_district IS
  'Issue #61. Per (district, month) sold/total/avg-sold-price from objective_lots_history '
  'snapshots (Obektiv shahmatka), deduped to latest snapshot per lot per month. '
  'Feeds Site Finder Velocity Score. Refresh weekly CONCURRENTLY.';

-- ====================================================================================
-- MV 2: absorption curves by room_count x area_bucket x months-from-first-seen
-- ====================================================================================
DROP MATERIALIZED VIEW IF EXISTS mv_sales_tracker_absorption_curves CASCADE;

CREATE MATERIALIZED VIEW mv_sales_tracker_absorption_curves AS
WITH base AS (
    -- One row per lot. area_bucket from area_pd; months_since_start = whole months between
    -- sales_start_date and the sold anchor (reg/contract). Unsold lots have NULL anchor.
    SELECT
        l.rooms_int,
        CASE
            WHEN l.area_pd < 30  THEN '<30'
            WHEN l.area_pd < 45  THEN '30-45'
            WHEN l.area_pd < 60  THEN '45-60'
            WHEN l.area_pd < 80  THEN '60-80'
            ELSE '80+'
        END                                                             AS area_bucket,
        l.is_sold,
        CASE
            WHEN l.is_sold
             AND l.sales_start_date IS NOT NULL
             AND COALESCE(l.registration_date, l.contract_date) IS NOT NULL
            THEN GREATEST(
                0,
                (date_part('year',  age(COALESCE(l.registration_date, l.contract_date),
                                         l.sales_start_date)) * 12
                 + date_part('month', age(COALESCE(l.registration_date, l.contract_date),
                                          l.sales_start_date)))::int
            )
        END                                                             AS months_since_start
    FROM objective_lots l
    WHERE l.rooms_int IS NOT NULL
      AND l.area_pd  IS NOT NULL
      AND l.sales_start_date IS NOT NULL
),
cohort AS (
    SELECT rooms_int, area_bucket, count(*)::int AS cohort_size
    FROM base
    GROUP BY rooms_int, area_bucket
),
sold_at_month AS (
    SELECT rooms_int, area_bucket, months_since_start, count(*)::int AS sold_in_month
    FROM base
    WHERE is_sold AND months_since_start IS NOT NULL
    GROUP BY rooms_int, area_bucket, months_since_start
)
SELECT
    s.rooms_int,
    s.area_bucket,
    s.months_since_start,
    c.cohort_size,
    -- cumulative sold up to and including this month-offset (per cohort)
    SUM(s.sold_in_month) OVER (
        PARTITION BY s.rooms_int, s.area_bucket
        ORDER BY s.months_since_start
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )::int                                                              AS cum_sold,
    round(
        SUM(s.sold_in_month) OVER (
            PARTITION BY s.rooms_int, s.area_bucket
            ORDER BY s.months_since_start
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        )::numeric / NULLIF(c.cohort_size, 0), 4
    )                                                                    AS cum_sold_pct
FROM sold_at_month s
JOIN cohort c ON c.rooms_int = s.rooms_int AND c.area_bucket = s.area_bucket
WITH NO DATA;

-- UNIQUE index on full grain -> enables REFRESH CONCURRENTLY
CREATE UNIQUE INDEX mv_sales_tracker_absorption_curves_pk
    ON mv_sales_tracker_absorption_curves (rooms_int, area_bucket, months_since_start);

CREATE INDEX mv_sales_tracker_absorption_cohort_idx
    ON mv_sales_tracker_absorption_curves (rooms_int, area_bucket);

REFRESH MATERIALIZED VIEW mv_sales_tracker_absorption_curves;

COMMENT ON MATERIALIZED VIEW mv_sales_tracker_absorption_curves IS
  'Issue #61. Cumulative sold-pct as f(months from sales_start_date) per (rooms_int, '
  'area_bucket). Anchor = COALESCE(registration_date, contract_date) from objective_lots. '
  'Foundation for recommend_mix + sellout forecast. Refresh weekly CONCURRENTLY.';

COMMIT;
