-- Storage для данных API Объектива (api.objctv.ru). -- -- Схема ВЫРОВНЕНА по реальной форме payload, наблюдаемой на пробных вызовах -- (data/raw/objective_test/20260507_*/), а не по гипотезам. -- -- Реальная форма данных: -- 1. /v2/Report/GetReport?ReportType=Сводные&ReportName=Корпуса -- → {"result": [ {Месяц, Проект, Девелопер, Район, Класс, Корпус, -- "Количество комнат (Данные Объектива)", -- Лотов по ПД шт, Площадь по ПД м2, -- Сделки ДДУ/ДКП/всего × {count, count_priced, vol, vol_priced, -- sum_priced_млн, avg_price_per_m2_тыс, -- avg_area}, -- Объем предложения шт/м2, Сумм. стоимость в продаже млн, -- Средняя цена м2 в продаже тыс -- }, ...] } -- → long-формат: одна строка = (месяц × корпус × room_bucket) per-row. -- → corp_sum.json для одного ЖК Парк Культуры за 7 дней = 8 строк (4 bucket × 2 мес). -- -- 2. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Лоты -- → {"result": [ {Id, "Id проекта", Проект, Корпус, Адрес, Класс, Секция, Этаж, -- "Номер лота", "Вид помещения", Статус, Продано, -- "Количество комнат(Сайт девелопера/ПД/Данные объектива)", -- "Площадь, м2(Сайт девелопера/ПД)", -- "Расчетный бюджет лота, Р", "Цена за м2, Р", -- "Цена предложения, Р", "Дельта цена, Р/%", -- "Дата договора", "Дата регистрации", "Тип сделки", -- "Тип покупателя", "Номер регистрации", -- "Тип обременения", Банк, "Дата начала обременения", -- "Дата актуальности данных из ЕГРН"}, ...] } -- → per-flat snapshot. `Id` (Objective lot_id) — stable, можно использовать как UPSERT-ключ. -- -- 3. /v2/Report/GetReport?ReportType=Сводные&ReportName=Лоты — HTTP 500 на нашем тарифе -- 4. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Корпуса — HTTP 500 на нашем тарифе -- Эти 2 отчёта недоступны / не реализованы у нас. Если откроют — добавим. -- -- Стратегия storage: -- - RAW: objective_raw_reports (jsonb payload) — страховка от ломки схемы. -- - NORMALIZED: -- - objective_corpus_room_month (long-формат из corp_sum) -- - objective_lots (текущая запись per-flat, UPSERT по Objective.Id) -- - objective_lots_history (append-only снапшоты per-flat per snapshot_date) -- - MAPPING: objective_complex_mapping связывает Objective.ComplexName с domrf_kn_objects.obj_id. -- -- Идемпотентно через IF NOT EXISTS. CREATE EXTENSION IF NOT EXISTS postgis; -- ── 0. Runs (журнал sync) ──────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS objective_scrape_runs ( run_id BIGSERIAL PRIMARY KEY, started_at timestamptz NOT NULL DEFAULT NOW(), finished_at timestamptz, heartbeat_at timestamptz, group_name TEXT NOT NULL, reports_ok INT NOT NULL DEFAULT 0, reports_failed INT NOT NULL DEFAULT 0, rows_corpus_room INT NOT NULL DEFAULT 0, rows_lots INT NOT NULL DEFAULT 0, rows_history INT NOT NULL DEFAULT 0, requests_count INT NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running', -- running | done | failed | zombie error TEXT, triggered_by TEXT NOT NULL DEFAULT 'beat' -- beat | manual | resume ); CREATE INDEX IF NOT EXISTS objective_runs_status_idx ON objective_scrape_runs(status, started_at DESC); COMMENT ON TABLE objective_scrape_runs IS 'Журнал запусков sync с api.objctv.ru. Один прогон = один (group_name) пакет отчётов.'; -- ── 1. RAW payloads ────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS objective_raw_reports ( raw_id BIGSERIAL PRIMARY KEY, run_id BIGINT REFERENCES objective_scrape_runs(run_id) ON DELETE SET NULL, fetched_at timestamptz NOT NULL DEFAULT NOW(), page TEXT NOT NULL, report_section TEXT NOT NULL, report_type TEXT NOT NULL, report_name TEXT NOT NULL, group_name TEXT NOT NULL, complex_name TEXT, start_date DATE, end_date DATE, use_ddu BOOLEAN, use_dkp BOOLEAN, api_version TEXT NOT NULL DEFAULT 'v2', payload jsonb NOT NULL, payload_size INT NOT NULL, rows_extracted INT -- сколько rows распарсено в normalized слой ); CREATE INDEX IF NOT EXISTS objective_raw_combo_idx ON objective_raw_reports(report_section, report_type, report_name, group_name, fetched_at DESC); CREATE INDEX IF NOT EXISTS objective_raw_dates_idx ON objective_raw_reports(start_date, end_date) WHERE start_date IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_raw_run_idx ON objective_raw_reports(run_id); COMMENT ON TABLE objective_raw_reports IS 'Сырой JSON-payload каждого GetReport-вызова. Источник для re-parse при ' 'изменениях схемы Objective или переноса normalized-слоя на новую версию.'; -- ── 2. NORMALIZED — Сводные/Корпуса (long: month × corpus × room_bucket) ──── -- Заменяет старую objective_oks (была спроектирована под per-corpus с -- rooms[N]_count колонками, не подходит под реальный long-формат). CREATE TABLE IF NOT EXISTS objective_corpus_room_month ( id BIGSERIAL PRIMARY KEY, -- Ключ записи (month × corpus × room_bucket) report_month DATE NOT NULL, -- 1-е число месяца из "Месяц": "апрель-2026" group_name TEXT NOT NULL, project_name TEXT NOT NULL, -- "Парк Культуры" developer TEXT, -- "PRINZIP" district TEXT, -- "Автовокзал" class TEXT, -- "Комфорт" / "Бизнес" / "Стандарт" / ... corpus_name TEXT NOT NULL, -- "1.1" room_bucket TEXT NOT NULL, -- "студия" | "1" | "2" | "3" | "4" | "5+" rooms_int SMALLINT, -- нормализованный 0=студия, 1, 2, 3, 4, 5 -- Динамика стройки (повторяется в каждой строке, but stable per-corpus per snapshot) sales_start_date DATE, plan_completion_date DATE, -- "Планируемая дата ввода" actual_completion_date DATE, -- "Фактическая дата ввода" months_in_realization INT, -- Capacity по ПД lots_pd_count INT, -- "Лотов по ПД, шт." lots_pd_area NUMERIC, -- "Площадь по ПД, м2." -- Сделки ДДУ deals_ddu_count INT, deals_ddu_count_priced INT, -- из них с известной ценой deals_ddu_vol_m2 NUMERIC, deals_ddu_vol_m2_priced NUMERIC, deals_ddu_sum_mln_rub NUMERIC, -- "млн.Р" deals_ddu_avg_price_thousand_rub_per_m2 NUMERIC, -- "тыс.Р/м2" deals_ddu_avg_area_m2 NUMERIC, -- Сделки ДКП deals_dkp_count INT, deals_dkp_count_priced INT, deals_dkp_vol_m2 NUMERIC, deals_dkp_vol_m2_priced NUMERIC, deals_dkp_sum_mln_rub NUMERIC, deals_dkp_avg_price_thousand_rub_per_m2 NUMERIC, deals_dkp_avg_area_m2 NUMERIC, -- Сделки всего (= ДДУ + ДКП) deals_total_count INT, deals_total_count_priced INT, deals_total_vol_m2 NUMERIC, deals_total_vol_m2_priced NUMERIC, deals_total_sum_mln_rub NUMERIC, deals_total_avg_price_thousand_rub_per_m2 NUMERIC, deals_total_avg_area_m2 NUMERIC, -- Текущее предложение (stock на конец месяца) offer_count INT, -- "Объем предложения, шт." offer_area_m2 NUMERIC, -- "Объем предложения, м2." offer_sum_mln_rub NUMERIC, -- "Суммарная стоимость лотов в продаже, млн.Р" offer_avg_price_thousand_rub_per_m2 NUMERIC, -- "Средняя цена м2 лота в продаже, тыс.Р/м2" -- Audit raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, raw_props jsonb, fetched_at timestamptz NOT NULL DEFAULT NOW(), -- Один (месяц, корпус, room_bucket, group) — одна запись. UNIQUE (report_month, group_name, project_name, corpus_name, room_bucket) ); CREATE INDEX IF NOT EXISTS objective_crm_project_month_idx ON objective_corpus_room_month(project_name, report_month DESC); CREATE INDEX IF NOT EXISTS objective_crm_district_class_idx ON objective_corpus_room_month(district, class, report_month DESC); CREATE INDEX IF NOT EXISTS objective_crm_developer_idx ON objective_corpus_room_month(developer, report_month DESC); CREATE INDEX IF NOT EXISTS objective_crm_rooms_idx ON objective_corpus_room_month(rooms_int, report_month DESC); COMMENT ON TABLE objective_corpus_room_month IS 'Сводные/Корпуса в реальном long-формате: (месяц × корпус × количество комнат). ' 'Главное преимущество над domrf_kn — готовая monthly history с разбивкой по mix ' '(studio/1/2/3/4) с явными метриками: остатки, средняя цена, средняя площадь.'; -- ── 3. NORMALIZED — Поквартирные/Лоты ──────────────────────────────────────── CREATE TABLE IF NOT EXISTS objective_lots ( id BIGSERIAL PRIMARY KEY, objective_lot_id BIGINT NOT NULL UNIQUE, -- "Id" в payload — stable Objective ID objective_project_id BIGINT, -- "Id проекта" -- Identification project_name TEXT NOT NULL, developer TEXT, city TEXT, district TEXT, corpus_name TEXT, address TEXT, class TEXT, -- "комфорт" в lots_pf (lowercase!) vs "Комфорт" в corp_sum section TEXT, floor INT, lot_number TEXT, -- "Номер лота" — может быть "1а" etc, поэтому TEXT premise_kind TEXT, -- "квартира" | "нежилое" | "паркинг" | "кладовая" -- Stage sales_start_date DATE, plan_completion_date DATE, actual_completion_date DATE, readiness_pct SMALLINT, -- "17%" → 17 construction_stage TEXT, -- "строящийся" | "сдан" | ... finishing TEXT, -- "Отделка по корпусу" — "под чистовую" | "с отделкой" | ... -- Status status TEXT, -- "резерв" | "свободна" | "продан" | "бронь" is_sold BOOLEAN, -- "Продано": "да"/"нет" → true/false -- Mix (3 источника от Объектива) rooms_dev_site TEXT, -- "Количество комнат(Сайт девелопера)" rooms_pd TEXT, -- "Количество комнат(ПД)" rooms_objective TEXT, -- "Количество комнат(Данные объектива)" — самое нормализованное rooms_int SMALLINT, -- нормализуем objective: студия→0, "1"→1, ... -- Areas area_dev_site NUMERIC, area_pd NUMERIC, -- Pricing price_calculated_total_rub NUMERIC, -- "Расчетный бюджет лота, Р" price_per_m2_rub NUMERIC, -- "Цена за м2, Р" price_offer_total_rub NUMERIC, -- "Цена предложения, Р" price_delta_rub NUMERIC, -- "Дельта цена, Р" price_delta_pct NUMERIC, -- "Дельта цена, %" pricing_method TEXT, -- "Способ определения цены" price_set_date DATE, -- "Дата установки цены" price_actual_date DATE, -- "Дата актуальности цены" -- Deal contract_date DATE, -- "Дата договора" registration_date DATE, -- "Дата регистрации" contract_type TEXT, -- "Тип сделки": ДДУ | ДКП | ... buyer_type TEXT, -- "Тип покупателя" registration_number TEXT, -- "Номер регистрации" -- Encumbrance / mortgage encumbrance_type TEXT, -- "Тип обременения" bank_name TEXT, -- "Банк" encumbrance_start_date DATE, -- "Дата начала обременения" egrn_actual_date DATE, -- "Дата актуальности данных из ЕГРН" -- Audit raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, raw_props jsonb, snapshot_date DATE NOT NULL, -- дата снимка (= когда мы синкнули) fetched_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS objective_lots_project_idx ON objective_lots(project_name, snapshot_date DESC); CREATE INDEX IF NOT EXISTS objective_lots_status_idx ON objective_lots(status) WHERE status IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_lots_rooms_area_idx ON objective_lots(rooms_int, area_pd) WHERE area_pd IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_lots_district_class_idx ON objective_lots(district, class) WHERE district IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_lots_bank_idx ON objective_lots(bank_name) WHERE bank_name IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_lots_reg_date_idx ON objective_lots(registration_date) WHERE registration_date IS NOT NULL; COMMENT ON TABLE objective_lots IS 'Per-flat snapshot из Объектива (Поквартирные/Лоты). UPSERT по objective_lot_id. ' 'Текущее (самое последнее) состояние каждой квартиры. Полная history — ' 'в objective_lots_history.'; -- ── 4. NORMALIZED — История квартир (append-only snapshots) ───────────────── -- Объектив НЕ отдаёт per-day price/status timeline в одном вызове. -- Зато при еженедельном sync мы можем собирать сами: при каждом вызове -- делаем INSERT в _history для каждого изменившегося лота. Это даст нам -- weekly-resolution timeline price/status/area per flat — достаточно для -- elasticity и velocity-anomaly детектора. CREATE TABLE IF NOT EXISTS objective_lots_history ( id BIGSERIAL PRIMARY KEY, objective_lot_id BIGINT NOT NULL, -- ссылка на objective_lots.objective_lot_id snapshot_date DATE NOT NULL, -- день sync status TEXT, is_sold BOOLEAN, price_calculated_total_rub NUMERIC, price_per_m2_rub NUMERIC, price_offer_total_rub NUMERIC, price_delta_pct NUMERIC, area_pd NUMERIC, contract_date DATE, registration_date DATE, contract_type TEXT, bank_name TEXT, raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, fetched_at timestamptz NOT NULL DEFAULT NOW(), -- На каждый (lot, snapshot_date) — ровно одна запись. UNIQUE (objective_lot_id, snapshot_date) ); CREATE INDEX IF NOT EXISTS objective_lots_history_lot_idx ON objective_lots_history(objective_lot_id, snapshot_date DESC); CREATE INDEX IF NOT EXISTS objective_lots_history_date_idx ON objective_lots_history(snapshot_date); CREATE INDEX IF NOT EXISTS objective_lots_history_reg_idx ON objective_lots_history(registration_date) WHERE registration_date IS NOT NULL; COMMENT ON TABLE objective_lots_history IS 'Append-only weekly snapshots каждого лота. Главное преимущество для ' 'velocity-anomaly detection и для elasticity (точность регрессии × лет).'; -- ── 5. MAPPING — Objective ↔ domrf_kn_objects ──────────────────────────────── CREATE TABLE IF NOT EXISTS objective_complex_mapping ( id BIGSERIAL PRIMARY KEY, objective_complex_name TEXT NOT NULL, -- "Парк Культуры" objective_project_id BIGINT, -- из payload "Id проекта" objective_group TEXT NOT NULL, domrf_obj_id BIGINT, -- domrf_kn_objects.obj_id; NULL = не сматчили is_reviewed BOOLEAN NOT NULL DEFAULT FALSE, match_method TEXT, -- 'exact_name' | 'fuzzy_name' | 'manual' | 'spatial' match_score NUMERIC, -- 0..1 для fuzzy note TEXT, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW(), UNIQUE (objective_complex_name, objective_group) ); CREATE INDEX IF NOT EXISTS objective_mapping_domrf_idx ON objective_complex_mapping(domrf_obj_id) WHERE domrf_obj_id IS NOT NULL; CREATE INDEX IF NOT EXISTS objective_mapping_unreviewed_idx ON objective_complex_mapping(is_reviewed) WHERE is_reviewed = FALSE; CREATE INDEX IF NOT EXISTS objective_mapping_project_id_idx ON objective_complex_mapping(objective_project_id) WHERE objective_project_id IS NOT NULL; COMMENT ON TABLE objective_complex_mapping IS 'Маппинг Objective.ComplexName ↔ domrf_kn_objects.obj_id. is_reviewed=TRUE — ' 'человек подтвердил соответствие. Auto-matching пишет с is_reviewed=FALSE.';