-- 124_domrf_kn_free_flats.sql -- #950 (Site Finder v2 / 3-layer склад, EPIC 6) — PR A: persist free_flats. -- -- Добавляет domrf_kn_objects.free_flats = freeFlatsInfo.numberFlats из kn-API dom.рф -- (кол-во квартир ЖК В ТЕКУЩЕЙ ПРОДАЖЕ). Это единственный детерминированный сигнал, -- разблокирующий object-level Layer2: hidden = flat_count − free_flats -- (flat_count = всего квартир по проектной декларации; free_flats = выставлено сейчас). -- -- До #950 payload-блок freeFlatsInfo парсился ТОЛЬКО ради priceMin → price_min_rub -- (миграция 113); numberFlats отбрасывался. Поле free_flats — sibling price_min_rub: -- bigint→int, та же null-safety, тот же EXCLUDED-overwrite в UPSERT_OBJECT_SQL. -- -- Backfill: сырой list объектов kn-API сохраняется как JSONB в domrf_raw_endpoints -- (section='kn_api', endpoint='kn_object_place_'; см. domrf_kn._insert_raw), -- каждый элемент массива несёт objId + freeFlatsInfo.numberFlats. Поэтому historical -- строки можно заполнить БЕЗ повторного скрейпа — join raw-элементов к -- domrf_kn_objects по (obj_id, snapshot_date). Детерминированно, без LLM. -- -- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз). -- Idempotent: ADD COLUMN IF NOT EXISTS + backfill только WHERE free_flats IS NULL. -- Migration order: эта SQL-миграция применяется ПЕРВОЙ, backend domrf_kn.py — следом. -- free_flats НЕnullable НЕ делаем: historical строки до бекфилла/без raw остаются NULL, -- L2-сервис в этом случае падает на fallback (flat_count − sold). BEGIN; -- ── 1. Колонка ───────────────────────────────────────────────────────────────── ALTER TABLE domrf_kn_objects ADD COLUMN IF NOT EXISTS free_flats integer; COMMENT ON COLUMN domrf_kn_objects.free_flats IS 'Кол-во квартир в текущей продаже (freeFlatsInfo.numberFlats из kn-API dom.рф). ' 'NULL = не заполнено (старый снапшот до #950). Для Layer2 hidden = flat_count − free_flats.'; -- ── 2. Backfill из сохранённого raw-payload ──────────────────────────────────── -- domrf_raw_endpoints.payload — JSONB-массив сырых object-строк kn-API. Разворачиваем -- его, тянем objId + freeFlatsInfo.numberFlats и матчим к уже записанным строкам по -- (obj_id, snapshot_date). Только WHERE free_flats IS NULL — повторный прогон no-op. -- Guard: payload должен быть массивом; objId и numberFlats — числовые строки (регексп -- отсекает null/мусор ДО CAST, иначе invalid input syntax for type integer). Digit-cap -- {1,9}/{1,18} держит numberFlats в int4, objId в bigint — отсекает overflow ДО CAST -- (числа-кандидаты семантически ограничены квартирами одного ЖК, но guard явный). -- DISTINCT ON (obj_id, snapshot_date): один objId не дублируется в одном снапшоте, но -- на случай нескольких place-endpoint'ов на дату берём первый детерминированно. WITH raw_flats AS ( SELECT DISTINCT ON (obj_id, r.snapshot_date) CAST(elem->>'objId' AS bigint) AS obj_id, r.snapshot_date AS snapshot_date, CAST(elem->'freeFlatsInfo'->>'numberFlats' AS integer) AS free_flats FROM domrf_raw_endpoints AS r CROSS JOIN LATERAL jsonb_array_elements(r.payload) AS elem WHERE r.section = 'kn_api' AND r.endpoint LIKE 'kn_object_place_%' AND jsonb_typeof(r.payload) = 'array' AND elem->>'objId' ~ '^[0-9]{1,18}$' AND elem->'freeFlatsInfo'->>'numberFlats' ~ '^[0-9]{1,9}$' ORDER BY obj_id, r.snapshot_date ) UPDATE domrf_kn_objects AS o SET free_flats = rf.free_flats FROM raw_flats AS rf WHERE o.obj_id = rf.obj_id AND o.snapshot_date = rf.snapshot_date AND o.free_flats IS NULL; COMMIT;