feat(tradein): SQL migrations 009-018 — houses + listings extension + IMV cache (#440)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 23s
Deploy Trade-In / deploy (push) Successful in 27s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped

This commit is contained in:
lekss361 2026-05-23 11:41:44 +00:00
parent 43a8ec426e
commit a2147872ed
10 changed files with 615 additions and 0 deletions

View file

@ -0,0 +1,72 @@
-- 009_houses.sql
-- CREATE TABLE houses — multi-source house cache (Avito / CIAN / etc.)
--
-- Dependencies: 002_core_tables.sql (listings_set_geom function must exist)
-- Apply after: 008_crm_fields.sql
-- Apply before: 010_houses_alter.sql, 011_listings_alter.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.2
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS houses (
id bigserial PRIMARY KEY,
source text NOT NULL, -- 'avito' / 'cian' / ...
ext_house_id text NOT NULL, -- Avito's house ID e.g. '3171365'
-- Identity
url text NOT NULL, -- /catalog/houses/<slug>/<id>
slug text, -- ul_akademika_postovskogo_17a
-- Geo (точные coords из developmentData.coords)
address text,
lat double precision,
lon double precision,
geom geometry(Point, 4326), -- PostGIS для ST_DWithin
-- Метаданные дома
year_built int,
house_type text, -- panel/brick/monolith/monolith_brick/other
house_class text, -- comfort/business/premium/economy
total_floors int,
total_units int, -- кол-во квартир/апартаментов
-- Лифты + удобства (из detail page / houses catalog developmentData)
passenger_elevators int,
cargo_elevators int,
has_concierge boolean,
closed_yard boolean,
-- Рейтинг
rating numeric(2,1), -- 4.8 (1 decimal)
reviews_count int, -- кол-во отзывов
-- Pin'ы соседних домов (для map preview) — JSONB
map_pins jsonb DEFAULT '[]'::jsonb, -- [{lat, lng, type}, ...]
-- Lifecycle
raw_payload jsonb,
first_seen_at timestamptz NOT NULL DEFAULT NOW(),
last_scraped_at timestamptz NOT NULL DEFAULT NOW(),
UNIQUE (source, ext_house_id)
);
-- Spatial index (обязателен для ST_Intersects/ST_DWithin)
CREATE INDEX IF NOT EXISTS houses_geom_idx ON houses USING GIST (geom);
CREATE INDEX IF NOT EXISTS houses_source_ext_idx ON houses (source, ext_house_id);
CREATE INDEX IF NOT EXISTS houses_last_scrape_idx ON houses (last_scraped_at DESC);
-- Trigger: auto-set geom из lat/lon (reuse listings_set_geom из 002_core_tables.sql)
DROP TRIGGER IF EXISTS houses_set_geom_trg ON houses;
CREATE TRIGGER houses_set_geom_trg
BEFORE INSERT OR UPDATE OF lat, lon ON houses
FOR EACH ROW EXECUTE FUNCTION listings_set_geom();
COMMENT ON TABLE houses IS
'Кэш домов из Houses Catalog endpoint. 1 запись на (source, ext_house_id). '
'Re-scrape если last_scraped_at > 30 дней. Geo через listings_set_geom trigger.';
COMMIT;

View file

@ -0,0 +1,58 @@
-- 010_houses_alter.sql
-- ALTER TABLE houses — добавить 15+ полей из Houses Catalog endpoint
-- (material_walls, parking_type, rating_distribution, raw_characteristics, etc.)
--
-- Dependencies: 009_houses.sql (houses table must exist)
-- Apply after: 009_houses.sql
-- Apply before: 011_listings_alter.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25
--
-- Идемпотентно: ADD COLUMN IF NOT EXISTS безопасен при повторном запуске.
BEGIN;
ALTER TABLE houses
-- Идентификатор хеша (из Avito internal base64-encoded ID)
ADD COLUMN IF NOT EXISTS avito_id_hash text, -- base64 "MTE3LjM3MDg5MA"
-- Адрес (два формата)
ADD COLUMN IF NOT EXISTS short_address text, -- "Свердловская область, Екатеринбург"
ADD COLUMN IF NOT EXISTS full_address text, -- полный с улицей
-- Материалы и конструкция
ADD COLUMN IF NOT EXISTS material_walls text, -- "Монолитный" / "Железобетонные"
ADD COLUMN IF NOT EXISTS material_floors text, -- "Железобетонные перекрытия"
-- Инженерные системы
ADD COLUMN IF NOT EXISTS hot_water text, -- "Центральное" / "Автономное"
-- Парковка и придомовая территория
ADD COLUMN IF NOT EXISTS parking_type text, -- "Подземная / За шлагбаумом во дворе"
ADD COLUMN IF NOT EXISTS has_playground boolean, -- "Детская площадка"
-- Застройщик
ADD COLUMN IF NOT EXISTS developer_name text, -- "ЛСР Недвижимость" (может быть NULL)
ADD COLUMN IF NOT EXISTS developer_key text, -- Avito internal key застройщика
-- Инфраструктура
ADD COLUMN IF NOT EXISTS infrastructure_summary text, -- "Больница, детский сад, магазин, школа"
ADD COLUMN IF NOT EXISTS infrastructure_walk_distance text, -- "в 5 минутах ходьбы"
-- Рейтинг (детальный) — дополняет houses.rating (numeric(2,1))
ADD COLUMN IF NOT EXISTS rating_score numeric(4,2), -- 4.666 (точное значение до сотых)
ADD COLUMN IF NOT EXISTS rating_string text, -- "4,7" (как отображает Avito)
ADD COLUMN IF NOT EXISTS rating_distribution jsonb, -- [{score:5,count:4},{score:4,count:1},...]
-- Карта и дополнительные данные
ADD COLUMN IF NOT EXISTS map_pins jsonb, -- перезапись поля из 009 (соседние дома + POI)
ADD COLUMN IF NOT EXISTS raw_characteristics jsonb; -- весь expandParams — для retrofit новых полей
COMMENT ON COLUMN houses.rating_score IS
'Точный рейтинг до сотых (напр. 4.666). Avito округляет до 4.7 для отображения (rating_string).';
COMMENT ON COLUMN houses.rating_distribution IS
'Распределение оценок: [{score: 5, count: 4}, {score: 4, count: 1}, ...] из Houses Catalog.';
COMMENT ON COLUMN houses.raw_characteristics IS
'Сырые expandParams из Houses Catalog для retrofit новых полей без re-scrape.';
COMMIT;

View file

@ -0,0 +1,96 @@
-- 011_listings_alter.sql
-- ALTER TABLE listings — добавить ~30 полей для Avito detail page + domoteka (Росреестр)
--
-- Dependencies:
-- 002_core_tables.sql (listings table)
-- 009_houses.sql (houses table — FK house_id_fk)
--
-- Apply after: 009_houses.sql
-- Apply before: 012_sellers.sql, 013_listings_alter_seller.sql
--
-- Sources:
-- decisions/Schema_Avito_Houses_Listings_History.md sec 13 (listings ALTER)
-- decisions/Schema_Avito_Houses_Listings_History.md sec 29 (owners_count/owners_at_least)
--
-- Note: owners_2plus (boolean) из sec 13 заменён на owners_count + owners_at_least (sec 29).
-- Идемпотентно: ADD COLUMN IF NOT EXISTS безопасен при повторном запуске.
BEGIN;
ALTER TABLE listings
-- ── House linking ──────────────────────────────────────────────────────
ADD COLUMN IF NOT EXISTS house_source text, -- 'avito' / 'cian'
ADD COLUMN IF NOT EXISTS house_ext_id text, -- Avito's '3171365'
ADD COLUMN IF NOT EXISTS house_url text, -- /catalog/houses/<slug>/<id>
ADD COLUMN IF NOT EXISTS house_id_fk bigint REFERENCES houses(id),
ADD COLUMN IF NOT EXISTS listing_segment text, -- 'vtorichka' / 'novostroyki'
-- ── Характеристики квартиры (из detail page "О квартире") ─────────────
ADD COLUMN IF NOT EXISTS kitchen_area_m2 numeric(6,2),
ADD COLUMN IF NOT EXISTS balcony_loggia text, -- balcony/loggia/both/none
-- (заменяет has_balcony boolean для detail)
ADD COLUMN IF NOT EXISTS room_layout text, -- isolated/adjacent/mixed
ADD COLUMN IF NOT EXISTS bathroom_type text, -- combined/separate/both/multi
ADD COLUMN IF NOT EXISTS windows_view text, -- yard/street/both/park
-- ── Условия продажи ────────────────────────────────────────────────────
ADD COLUMN IF NOT EXISTS sale_type text, -- free/alternative/auction
ADD COLUMN IF NOT EXISTS mortgage_available boolean,
-- ── Метрики просмотров (Avito tracks viewership) ──────────────────────
ADD COLUMN IF NOT EXISTS views_total int,
ADD COLUMN IF NOT EXISTS views_today int,
ADD COLUMN IF NOT EXISTS publish_date date, -- "18 мая в 13:40" → 2025-05-18
-- ── Текстовое описание ─────────────────────────────────────────────────
ADD COLUMN IF NOT EXISTS description text, -- 1000+ chars обычно
-- ── Domoteka / Росреестр verification badges ──────────────────────────
-- Note: owners_2plus (boolean из sec 13) заменён на owners_count + owners_at_least (sec 29)
ADD COLUMN IF NOT EXISTS owners_count int, -- 1 = точно 1; 2 = '2 или больше'
ADD COLUMN IF NOT EXISTS owners_at_least boolean, -- true если Avito выдал "X или больше"
ADD COLUMN IF NOT EXISTS last_owner_change_date date, -- "Последняя смена собственника YYYY-MM-DD"
ADD COLUMN IF NOT EXISTS encumbrances_clean boolean, -- "Не найдены ограничения и обременения"
ADD COLUMN IF NOT EXISTS registry_match boolean, -- "Совпадают площадь, адрес и этаж"
-- ── Metro + location internal IDs ─────────────────────────────────────
ADD COLUMN IF NOT EXISTS metro_stations jsonb DEFAULT '[]'::jsonb,
-- [{"name":"Чкаловская","minutes":"11-15","mode":"walk"},...]
ADD COLUMN IF NOT EXISTS avito_location_id int, -- 654070 (ЕКБ internal ID)
-- ── Detail-page enrichment tracking ───────────────────────────────────
ADD COLUMN IF NOT EXISTS detail_enriched_at timestamptz; -- когда обогатили с detail page
-- Индексы для аналитических запросов
CREATE INDEX IF NOT EXISTS listings_house_ext_id_idx ON listings (house_source, house_ext_id)
WHERE house_ext_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS listings_house_id_fk_idx ON listings (house_id_fk)
WHERE house_id_fk IS NOT NULL;
CREATE INDEX IF NOT EXISTS listings_kitchen_idx ON listings (kitchen_area_m2)
WHERE kitchen_area_m2 IS NOT NULL;
CREATE INDEX IF NOT EXISTS listings_views_idx ON listings (views_total DESC)
WHERE views_total IS NOT NULL;
-- Partial index для фильтра "без обременений" — используется estimator
CREATE INDEX IF NOT EXISTS listings_encumbrances_idx ON listings (encumbrances_clean)
WHERE encumbrances_clean = true;
-- Partial index для очереди detail-enrichment (пустой = нужно обогатить)
CREATE INDEX IF NOT EXISTS listings_detail_enrich_idx ON listings (detail_enriched_at)
WHERE detail_enriched_at IS NULL;
-- Partial index для "только с одним собственником" (clean title filter)
CREATE INDEX IF NOT EXISTS listings_owners_idx ON listings (owners_count)
WHERE owners_count IS NOT NULL;
COMMENT ON COLUMN listings.house_ext_id IS
'Avito house ID из /catalog/houses/<slug>/<id>. NULL для ~18% карточек без ссылки на дом.';
COMMENT ON COLUMN listings.balcony_loggia IS
'Тип балкона: balcony/loggia/both/none. Детальнее чем has_balcony (boolean). Из detail page.';
COMMENT ON COLUMN listings.owners_count IS
'Кол-во собственников из domoteka badge. 1=точно один; 2=''2 или больше'' (Avito скрывает точное для privacy).';
COMMENT ON COLUMN listings.owners_at_least IS
'true если badge содержит "X или больше" — значит owners_count является нижней границей.';
COMMENT ON COLUMN listings.metro_stations IS
'Список станций метро: [{"name":"Чкаловская","minutes":"11-15","mode":"walk"},...]. Из detail page.';
COMMENT ON COLUMN listings.detail_enriched_at IS
'Timestamp обогащения с detail page. NULL = ещё не обогащено (используется как queue flag).';
COMMIT;

View file

@ -0,0 +1,41 @@
-- 012_sellers.sql
-- CREATE TABLE sellers — кэш продавцов с Avito (компании и частные лица)
--
-- Dependencies: 002_core_tables.sql (listings table exists — FK будет в 013)
-- Apply after: 011_listings_alter.sql
-- Apply before: 013_listings_alter_seller.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (NEW sellers table)
-- Note: logos НЕ сохраняем (требование 2026-05-23 — без картинок).
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS sellers (
id bigserial PRIMARY KEY,
source text NOT NULL, -- 'avito' / 'cian'
ext_seller_id text, -- из seller.url ("/brands/domrf66")
name text NOT NULL, -- "Самолёт Плюс Екатеринбург"
seller_type text, -- "Компания" / "Частное лицо"
on_platform_since text, -- "На Авито с июля 2012" (raw text, не дата)
profile_url text, -- "/brands/domrf66"
-- logos НЕ сохраняем (user req: без картинок)
raw_payload jsonb,
first_seen_at timestamptz NOT NULL DEFAULT NOW(),
last_seen_at timestamptz NOT NULL DEFAULT NOW(),
UNIQUE (source, ext_seller_id)
);
CREATE INDEX IF NOT EXISTS sellers_source_idx ON sellers (source, ext_seller_id);
COMMENT ON TABLE sellers IS
'Кэш продавцов Avito (компании + частные лица). 1 запись на (source, ext_seller_id). '
'Logos не сохраняются. Связь с listings через sellers.id (FK в 013_listings_alter_seller.sql).';
COMMENT ON COLUMN sellers.on_platform_since IS
'Raw текст из Avito (напр. "На Авито с июля 2012"). Не парсим в дату — слишком вариативно.';
COMMIT;

View file

@ -0,0 +1,26 @@
-- 013_listings_alter_seller.sql
-- ALTER TABLE listings — добавить FK на sellers
--
-- Dependencies:
-- 002_core_tables.sql (listings table)
-- 012_sellers.sql (sellers table must exist для FK)
--
-- Apply after: 012_sellers.sql
-- Apply before: 014_house_reviews.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (ALTER listings — seller link)
--
-- Идемпотентно: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS.
BEGIN;
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS seller_id_fk bigint REFERENCES sellers(id);
CREATE INDEX IF NOT EXISTS listings_seller_idx ON listings (seller_id_fk)
WHERE seller_id_fk IS NOT NULL;
COMMENT ON COLUMN listings.seller_id_fk IS
'FK на sellers.id. NULL если продавец не спарсен. Для анализа активности агентств.';
COMMIT;

View file

@ -0,0 +1,57 @@
-- 014_house_reviews.sql
-- CREATE TABLE house_reviews — отзывы жильцов на дома (из Houses Catalog)
--
-- Dependencies: 009_houses.sql (houses table — FK house_id)
-- Apply after: 013_listings_alter_seller.sql
-- Apply before: 015_scrape_runs.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (NEW house_reviews table)
-- Note: author_avatar и images НЕ сохраняем (требование 2026-05-23 — без картинок).
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS house_reviews (
id bigserial PRIMARY KEY,
house_id bigint NOT NULL REFERENCES houses(id) ON DELETE CASCADE,
source text NOT NULL DEFAULT 'avito',
ext_review_id bigint NOT NULL, -- value.id из Avito API
-- Автор (без аватара)
author_name text, -- "Мария"
-- author_avatar НЕ сохраняем (user req: без картинок)
-- Мета-данные отзыва
review_title text, -- "Новый, уютный, теплый..."
score int CHECK (score BETWEEN 1 AND 5),
model_experience text, -- "Живу в своей квартире" / "Часто там бываю"
rated_date date, -- parsed из "9 октября 2025"
-- Текст отзыва (три секции из textSections[])
text_main text, -- textSections[0].text если title=""
text_pros text, -- textSections где title="Преимущества"
text_cons text, -- textSections где title="Недостатки"
-- images НЕ сохраняем (user req: без картинок — raw_payload содержит URLs для retrofit)
raw_payload jsonb, -- полный объект из API для retrofit
scraped_at timestamptz NOT NULL DEFAULT NOW(),
UNIQUE (source, ext_review_id)
);
CREATE INDEX IF NOT EXISTS hr_house_idx ON house_reviews (house_id, rated_date DESC);
CREATE INDEX IF NOT EXISTS hr_source_ext_idx ON house_reviews (source, ext_review_id);
COMMENT ON TABLE house_reviews IS
'Отзывы жильцов на дома из Avito Houses Catalog. 1 запись на (source, ext_review_id). '
'Аватары и фото не сохраняются — используйте raw_payload для retrofit.';
COMMENT ON COLUMN house_reviews.model_experience IS
'Опыт автора: "Живу в своей квартире" / "Часто там бываю" / etc. (raw текст из Avito).';
COMMENT ON COLUMN house_reviews.text_pros IS
'Секция "Преимущества" из textSections. NULL если автор не заполнил.';
COMMENT ON COLUMN house_reviews.text_cons IS
'Секция "Недостатки" из textSections. NULL если автор не заполнил.';
COMMIT;

View file

@ -0,0 +1,65 @@
-- 015_scrape_runs.sql
-- CREATE TABLE scrape_runs — логирование scrape-сессий с heartbeat + lifecycle
--
-- Dependencies: нет (независимая таблица)
-- Apply after: 014_house_reviews.sql
-- Apply before: 016_listings_snapshots.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.3
-- Status enum: running / done / failed / zombie / skipped / banned
--
-- Паттерн совпадает с objective_scrape_runs из основной GenDesign БД.
-- Zombie detection: cron каждые 5 мин помечает runs где heartbeat_at < NOW() - 30 min.
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS scrape_runs (
id bigserial PRIMARY KEY,
source text NOT NULL, -- 'avito' / 'cian' / ...
run_type text NOT NULL, -- 'search' / 'house_catalog' / 'detail' / 'imv'
-- Scope (для search runs — геометрия поиска)
anchor_lat double precision,
anchor_lon double precision,
radius_m int,
segment text, -- 'vtorichka' / 'novostroyki'
-- Scope (для house_catalog/detail runs)
target_ext_id text, -- house_id или item_id
-- Lifecycle
started_at timestamptz NOT NULL DEFAULT NOW(),
heartbeat_at timestamptz NOT NULL DEFAULT NOW(), -- обновляется каждые N сек в process loop
finished_at timestamptz,
status text NOT NULL DEFAULT 'running'
CHECK (status IN ('running','done','failed','zombie','skipped','banned')),
error_text text,
-- Counters (для аналитики и debugging)
total_seen int DEFAULT 0, -- кол-во объявлений в выдаче
new_count int DEFAULT 0, -- новых (не было в БД)
returning_count int DEFAULT 0, -- видели → пропали → снова видим
disappeared_count int DEFAULT 0, -- пропали (last_seen > cutoff после run)
-- HTTP stats (для мониторинга rate limits)
http_requests int DEFAULT 0,
http_errors int DEFAULT 0
);
CREATE INDEX IF NOT EXISTS scrape_runs_source_started_idx ON scrape_runs (source, started_at DESC);
-- Partial index для zombie detection (только 'running' статус)
CREATE INDEX IF NOT EXISTS scrape_runs_zombies_idx ON scrape_runs (status, heartbeat_at)
WHERE status = 'running';
COMMENT ON TABLE scrape_runs IS
'Лог scrape-сессий. Heartbeat обновляется в process loop. '
'Zombie: status=running AND heartbeat_at < NOW() - 30 min — помечается cron.';
COMMENT ON COLUMN scrape_runs.status IS
'Enum: running=активен, done=успех, failed=ошибка, zombie=heartbeat устарел, '
'skipped=нет новых данных, banned=Avito вернул 403/captcha.';
COMMENT ON COLUMN scrape_runs.returning_count IS
'Объявления которые пропадали (is_active=false) и снова появились в выдаче.';
COMMIT;

View file

@ -0,0 +1,51 @@
-- 016_listings_snapshots.sql
-- CREATE TABLE listings_snapshots — append-only daily price/status history
--
-- Dependencies:
-- 002_core_tables.sql (listings table — FK listing_id)
-- 015_scrape_runs.sql (scrape_runs table — FK run_id)
--
-- Apply after: 015_scrape_runs.sql
-- Apply before: 017_house_placement_history.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.4
-- Pattern: аналог objective_lots_history из основной GenDesign БД.
--
-- ON CONFLICT DO UPDATE: берём последний snapshot за день (если за сутки несколько run'ов).
-- Cleanup policy: хранить вечно (data moat). Partitioning отложить на 1-2 года.
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS listings_snapshots (
listing_id bigint NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
snapshot_date date NOT NULL DEFAULT CURRENT_DATE,
run_id bigint REFERENCES scrape_runs(id),
-- Snapshot изменяющихся полей
price_rub bigint NOT NULL,
price_per_m2 int,
position_in_serp int, -- 1..60 — позиция в поисковой выдаче
status text, -- 'active' / 'closed' / null (unknown)
observed_at timestamptz NOT NULL DEFAULT NOW(),
-- 1 snapshot в день максимум (ON CONFLICT DO UPDATE в scrapers)
PRIMARY KEY (listing_id, snapshot_date)
);
CREATE INDEX IF NOT EXISTS listings_snapshots_run_idx ON listings_snapshots (run_id);
CREATE INDEX IF NOT EXISTS listings_snapshots_listing_date_idx ON listings_snapshots (listing_id, snapshot_date DESC);
-- Для "что нового/изменилось сегодня" запросов
CREATE INDEX IF NOT EXISTS listings_snapshots_date_idx ON listings_snapshots (snapshot_date DESC);
COMMENT ON TABLE listings_snapshots IS
'Append-only ежедневные снэпшоты listings. PRIMARY KEY (listing_id, snapshot_date) = max 1/день. '
'Pattern: objective_lots_history. ON CONFLICT DO UPDATE — берём последний за день.';
COMMENT ON COLUMN listings_snapshots.position_in_serp IS
'Позиция в поисковой выдаче (1-60). NULL для snapshot''ов из non-search sources.';
COMMENT ON COLUMN listings_snapshots.status IS
'''active'' = видели в run, ''closed'' = не нашли (пропало). NULL = неизвестно.';
COMMIT;

View file

@ -0,0 +1,70 @@
-- 017_house_placement_history.sql
-- CREATE TABLE house_placement_history — архив размещений из IMV API
-- (исторические объявления в конкретном доме, уже снятые с продажи)
--
-- Dependencies: 009_houses.sql (houses table — FK house_id, nullable)
-- Apply after: 016_listings_snapshots.sql
-- Apply before: 018_avito_imv_evaluations.sql
--
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 6 (UPDATE 2026-05-23)
-- Key notes:
-- - photo_url НЕ сохраняем (требование 2026-05-23 "без картинок")
-- - removed_date заполняется ТОЛЬКО из IMV API (в widget housePlacementHistory его НЕТ)
-- - source = 'avito_imv' для данных из IMV API, 'avito' для данных из Houses Catalog widget
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS house_placement_history (
id bigserial PRIMARY KEY,
source text NOT NULL, -- 'avito_imv' / 'avito'
house_id bigint REFERENCES houses(id), -- NULL если дом ещё не в houses table
-- Старый item ID (объявление уже снято → 404 на Avito)
ext_item_id text NOT NULL, -- e.g. '7489769108'
-- Описание (raw text + parsed fields)
title text, -- "1-к. квартира, 27,1 м², 2/5 эт."
rooms int,
area_m2 numeric(8,2),
floor int,
total_floors int,
-- Эволюция цены
start_price bigint, -- начальная ask price
start_price_date date, -- из unix timestamp startPriceDate
last_price bigint, -- финальная ask price (при снятии)
last_price_date date, -- из unix timestamp lastPriceDate
-- Дата снятия (ТОЛЬКО из IMV API — в widget housePlacementHistory отсутствует)
removed_date date, -- лучший proxy для deal_date
-- Точный days_on_market (от Avito, точнее нашего вычисления)
exposure_days int,
-- photo_url НЕ сохраняем (raw_payload содержит itemImage URL для retrofit)
raw_payload jsonb, -- полный объект из API
scraped_at timestamptz NOT NULL DEFAULT NOW(),
UNIQUE (source, ext_item_id)
);
CREATE INDEX IF NOT EXISTS hph_house_idx ON house_placement_history (house_id, last_price_date DESC)
WHERE house_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS hph_source_item_idx ON house_placement_history (source, ext_item_id);
-- Для "comparable sales в заданный период"
CREATE INDEX IF NOT EXISTS hph_removed_idx ON house_placement_history (removed_date DESC)
WHERE removed_date IS NOT NULL;
COMMENT ON TABLE house_placement_history IS
'Архив снятых объявлений (proxy для сделок) из IMV API + Houses Catalog. '
'removed_date заполняется только из IMV API. photo_url не сохраняется.';
COMMENT ON COLUMN house_placement_history.removed_date IS
'Дата снятия объявления с продажи. Лучший proxy для deal_date. '
'ТОЛЬКО из IMV API (из widget housePlacementHistory это поле отсутствует).';
COMMENT ON COLUMN house_placement_history.house_id IS
'FK на houses.id. NULL если при scrape дом ещё не был в houses table. '
'Backfill через UPDATE ... WHERE ext_item_id IN (...) при первом scrape дома.';
COMMIT;

View file

@ -0,0 +1,79 @@
-- 018_avito_imv_evaluations.sql
-- CREATE TABLE avito_imv_evaluations — кэш оценок Avito IMV API
--
-- Dependencies:
-- 001_trade_in_estimates.sql (trade_in_estimates table — FK estimate_id, nullable ON DELETE CASCADE)
--
-- Apply after: 017_house_placement_history.sql
-- Apply before: ничего (последняя миграция Stage 1)
--
-- Sources:
-- decisions/Schema_Avito_Houses_Listings_History.md sec 7 (avito_imv_evaluations DDL)
-- decisions/AvitoScraper_v2_Implementation_Plan.md (UPDATE: cache_key обязателен для Stage 3)
--
-- cache_key: sha256(address + rooms + area + floor + house_type + renovation + balcony + loggia)
-- TTL: 24 часа по cache_key (lookup в estimator перед вызовом IMV API)
-- IMV endpoint: POST /web/1/realty-imv/get-data (2 HTTP requests: coords/by_address → imv)
--
-- Идемпотентно: безопасно запускать повторно.
BEGIN;
CREATE TABLE IF NOT EXISTS avito_imv_evaluations (
id bigserial PRIMARY KEY,
estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE CASCADE,
-- Input snapshot (для cache lookup + аудита)
address text NOT NULL,
rooms int,
area_m2 numeric(8,2),
floor int,
floor_at_home int,
house_type text, -- panel/brick/monolith/monolith_brick/block/wood
renovation_type text, -- required/cosmetic/euro/designer
has_balcony boolean,
has_loggia boolean,
-- Geo (из /coords/by_address response)
geo_hash text, -- JWT token (для передачи в /realty-imv)
avito_address_id bigint,
avito_location_id bigint,
avito_metro_id int,
avito_district_id int,
lat double precision,
lon double precision,
-- Output (из /realty-imv/get-data → price block)
recommended_price bigint NOT NULL, -- price.recommendedPrice (центральная оценка)
lower_price bigint NOT NULL, -- price.lowerPrice (нижняя граница ~-3%)
higher_price bigint NOT NULL, -- price.higherPrice (верхняя граница ~+5%)
market_count int, -- price.count (кол-во активных в рынке)
-- Cache key (критично для on-demand cache — Stage 3)
-- sha256(address + rooms + area + floor + house_type + renovation_type + has_balcony + has_loggia)
cache_key text NOT NULL DEFAULT '',
raw_response jsonb, -- полный ответ /realty-imv/get-data для retrofit
fetched_at timestamptz NOT NULL DEFAULT NOW()
);
-- Основной index для on-demand cache lookup (Stage 3: get_or_fetch_imv)
CREATE INDEX IF NOT EXISTS imv_cache_key_idx ON avito_imv_evaluations (cache_key, fetched_at DESC);
CREATE INDEX IF NOT EXISTS imv_estimate_idx ON avito_imv_evaluations (estimate_id)
WHERE estimate_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS imv_address_idx ON avito_imv_evaluations (address);
COMMENT ON TABLE avito_imv_evaluations IS
'Кэш оценок Avito IMV API. TTL 24h по cache_key. '
'IMV = POST /web/1/realty-imv/get-data. Вызывается только из estimator (НЕ из cron-scrape).';
COMMENT ON COLUMN avito_imv_evaluations.cache_key IS
'sha256(address + rooms + area + floor + house_type + renovation_type + has_balcony + has_loggia). '
'Используется для lookup с TTL 24h перед вызовом Avito API.';
COMMENT ON COLUMN avito_imv_evaluations.recommended_price IS
'Центральная оценка Avito IMV. Используется как external benchmark для валидации нашей median оценки.';
COMMENT ON COLUMN avito_imv_evaluations.market_count IS
'Кол-во активных объявлений в рынке по параметрам запроса (confidence indicator). 800+ = высокий.';
COMMENT ON COLUMN avito_imv_evaluations.geo_hash IS
'JWT token из /coords/by_address. Передаётся в /realty-imv/get-data как geoHash. TTL неизвестен.';
COMMIT;