feat(domrf): persist domrf_kn_objects.free_flats (#950 PR A) #1002
2 changed files with 95 additions and 2 deletions
|
|
@ -91,6 +91,29 @@ def _to_bool(v: Any) -> bool | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(v: Any) -> int | None:
|
||||||
|
"""Coerce a DOM.РФ numeric value into int. None / non-numeric / empty → None.
|
||||||
|
Accepts int, float (truncated), and numeric strings ('12', '12.0')."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(v, int):
|
||||||
|
return v
|
||||||
|
if isinstance(v, float):
|
||||||
|
try:
|
||||||
|
return int(v) # int(inf) → OverflowError, int(nan) → ValueError
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
s = v.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(s))
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _to_date(v: Any) -> date | None:
|
def _to_date(v: Any) -> date | None:
|
||||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
||||||
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
|
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
|
||||||
|
|
@ -269,8 +292,10 @@ def _norm_object(row: dict[str, Any], region_cd: int | None = None) -> dict[str,
|
||||||
]
|
]
|
||||||
|
|
||||||
# freeFlatsInfo.priceMin — price of cheapest available flat in the object
|
# freeFlatsInfo.priceMin — price of cheapest available flat in the object
|
||||||
|
# freeFlatsInfo.numberFlats — кол-во квартир в текущей продаже (#950 Layer2).
|
||||||
free_info = row.get("freeFlatsInfo") if isinstance(row.get("freeFlatsInfo"), dict) else {}
|
free_info = row.get("freeFlatsInfo") if isinstance(row.get("freeFlatsInfo"), dict) else {}
|
||||||
price_min_rub = _g(free_info, "priceMin") if free_info else None
|
price_min_rub = _g(free_info, "priceMin") if free_info else None
|
||||||
|
free_flats = _to_int(_g(free_info, "numberFlats")) if free_info else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"obj_id": _g(row, "objId", "obj_id", "id"),
|
"obj_id": _g(row, "objId", "obj_id", "id"),
|
||||||
|
|
@ -302,6 +327,7 @@ def _norm_object(row: dict[str, Any], region_cd: int | None = None) -> dict[str,
|
||||||
"project_declaration_num": _g(row, "rpdNum"),
|
"project_declaration_num": _g(row, "rpdNum"),
|
||||||
"project_published_at": _to_date(_g(row, "objPublDt")),
|
"project_published_at": _to_date(_g(row, "objPublDt")),
|
||||||
"price_min_rub": price_min_rub,
|
"price_min_rub": price_min_rub,
|
||||||
|
"free_flats": free_flats,
|
||||||
"dev_group_name": _g(dev, "groupName"),
|
"dev_group_name": _g(dev, "groupName"),
|
||||||
# 22e + 22h: metro (nearest station from kn-API, single object)
|
# 22e + 22h: metro (nearest station from kn-API, single object)
|
||||||
"metro_nearest_name": metro_nearest_name,
|
"metro_nearest_name": metro_nearest_name,
|
||||||
|
|
@ -429,7 +455,7 @@ UPSERT_OBJECT_SQL = text(
|
||||||
obj_class, wall_type, energy_eff,
|
obj_class, wall_type, energy_eff,
|
||||||
latitude, longitude, obj_status,
|
latitude, longitude, obj_status,
|
||||||
section_count, project_declaration_num, project_published_at,
|
section_count, project_declaration_num, project_published_at,
|
||||||
price_min_rub, dev_group_name,
|
price_min_rub, free_flats, dev_group_name,
|
||||||
metro_nearest_name, metro_nearest_walk_minutes, metro_top3,
|
metro_nearest_name, metro_nearest_walk_minutes, metro_top3,
|
||||||
first_floor_type, elevators_passenger_count, elevators_cargo_count,
|
first_floor_type, elevators_passenger_count, elevators_cargo_count,
|
||||||
parking_total_slots, guest_parking_inside_count, guest_parking_outside_count,
|
parking_total_slots, guest_parking_inside_count, guest_parking_outside_count,
|
||||||
|
|
@ -448,7 +474,7 @@ UPSERT_OBJECT_SQL = text(
|
||||||
:obj_class, :wall_type, :energy_eff,
|
:obj_class, :wall_type, :energy_eff,
|
||||||
:latitude, :longitude, :obj_status,
|
:latitude, :longitude, :obj_status,
|
||||||
:section_count, :project_declaration_num, :project_published_at,
|
:section_count, :project_declaration_num, :project_published_at,
|
||||||
:price_min_rub, :dev_group_name,
|
:price_min_rub, :free_flats, :dev_group_name,
|
||||||
:metro_nearest_name, :metro_nearest_walk_minutes, CAST(:metro_top3 AS jsonb),
|
:metro_nearest_name, :metro_nearest_walk_minutes, CAST(:metro_top3 AS jsonb),
|
||||||
:first_floor_type, :elevators_passenger_count, :elevators_cargo_count,
|
:first_floor_type, :elevators_passenger_count, :elevators_cargo_count,
|
||||||
:parking_total_slots, :guest_parking_inside_count, :guest_parking_outside_count,
|
:parking_total_slots, :guest_parking_inside_count, :guest_parking_outside_count,
|
||||||
|
|
@ -484,6 +510,7 @@ UPSERT_OBJECT_SQL = text(
|
||||||
EXCLUDED.project_published_at, domrf_kn_objects.project_published_at
|
EXCLUDED.project_published_at, domrf_kn_objects.project_published_at
|
||||||
),
|
),
|
||||||
price_min_rub = EXCLUDED.price_min_rub,
|
price_min_rub = EXCLUDED.price_min_rub,
|
||||||
|
free_flats = EXCLUDED.free_flats,
|
||||||
dev_group_name = COALESCE(EXCLUDED.dev_group_name, domrf_kn_objects.dev_group_name),
|
dev_group_name = COALESCE(EXCLUDED.dev_group_name, domrf_kn_objects.dev_group_name),
|
||||||
metro_nearest_name = COALESCE(
|
metro_nearest_name = COALESCE(
|
||||||
EXCLUDED.metro_nearest_name, domrf_kn_objects.metro_nearest_name
|
EXCLUDED.metro_nearest_name, domrf_kn_objects.metro_nearest_name
|
||||||
|
|
|
||||||
66
data/sql/124_domrf_kn_free_flats.sql
Normal file
66
data/sql/124_domrf_kn_free_flats.sql
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
-- 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_<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;
|
||||||
Loading…
Add table
Reference in a new issue