gendesign/data/sql/68_schema_objective.sql
lekss361 aaa5d44e77 feat(objective): integrate api.objctv.ru — auth, schema, weekly sync
backend/app/services/scrapers/objective.py:
  ObjectiveClient — GetToken (Bearer cached в Redis 25 min TTL) +
  GetReport v2 с UseDdu/UseDkp. 4 высокоуровневых метода:
  report_corpuses_summary, report_lots_summary, report_corpuses_per_flat,
  report_lots_per_flat. Retry 401/429/5xx, rate-limit 500ms, brotli.
backend/app/workers/tasks/scrape_objective.py:
  Celery task sync_objective_group — еженедельно тянет 4 канон. отчёта по
  группе, raw payload в objective_raw_reports.
celery_app.py: +include + beat «0 5 * * mon».
data/sql/68_schema_objective.sql: 6 таблиц — runs, raw_reports (jsonb),
oks (готовые ТЭП + escrow/debt), lots, lots_history (per-flat per-day),
complex_mapping (Objective ↔ domrf_kn_objects, is_reviewed workflow).
data/sql/69_objective_smoke.py: stand-alone GetToken + 4 отчёта →
data/raw/objective_smoke/<ts>/. Используется один раз чтобы понять
реальную схему payload перед написанием parser-слоя.
config: OBJECTIVE_API_KEY, OBJECTIVE_DEFAULT_GROUP=Екатеринбург,
OBJECTIVE_SYNC_CRON='0 5 * * mon'.
2026-05-07 21:04:00 +03:00

333 lines
20 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.

-- 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.';