"""SQL queries for /api/v1/analytics endpoints. One function per endpoint. All return plain dicts/lists ready for JSON. Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP. """ from __future__ import annotations import logging from decimal import Decimal from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) def _f(value: Any) -> float | None: if value is None: return None if isinstance(value, Decimal): return float(value) return value def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]: rows = ( db.execute( text( """ SELECT snapshot_date, rep_year, rep_month, total_square, sold_perc, price_avg FROM domrf_realization WHERE region_code = :region_code AND endpoint_type = 'total' AND type_square = 'total' ORDER BY snapshot_date """ ), {"region_code": region_code}, ) .mappings() .all() ) return [ { "snapshot_date": r["snapshot_date"].isoformat(), "rep_year": r["rep_year"], "rep_month": r["rep_month"], "total_square_th_sqm": _f(r["total_square"]), "sold_perc": _f(r["sold_perc"]), "price_avg": _f(r["price_avg"]), } for r in rows ] def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]: """source: 'portfolio' (что строится) or 'deals' (реально покупают).""" if source == "portfolio": rows = ( db.execute( text( """ SELECT room_count_type, flat_count, area_sqm, percent FROM domrf_region_aggregates WHERE region_id = :region_id AND snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_region_aggregates WHERE region_id = :region_id ) AND room_count_type <> 'TOTAL' ORDER BY CASE room_count_type WHEN 'ONE' THEN 1 WHEN 'TWO' THEN 2 WHEN 'THREE' THEN 3 WHEN 'FOUR' THEN 4 END """ ), {"region_id": region_id}, ) .mappings() .all() ) return [ { "bucket": { "ONE": "1-к", "TWO": "2-к", "THREE": "3-к", "FOUR": "4+", }.get(r["room_count_type"], r["room_count_type"]), "flat_count": r["flat_count"], "area_sqm": _f(r["area_sqm"]), "percent": r["percent"], "avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None, } for r in rows ] # deals: bucketize по area_per_unit = area / deal_count (rosreestr # с 2025Q1 публикует пакетные ДДУ одной строкой с суммарной area). # deal_count — это число квартир в строке; bucket по сырой area без # деления попадал в 80+ м² для большинства аггрегаций → перекошенный # «парадокс портфеля» (70% 80+ вместо реальных 5%). rows = ( db.execute( text( """ WITH per_unit AS ( SELECT (area / deal_count) AS area_per_unit, price_per_sqm, deal_count FROM rosreestr_deals WHERE region_code = :region_id AND doc_type = 'ДДУ' AND realestate_type_code = '002001003000' AND area > 0 AND deal_count > 0 AND (area / deal_count) BETWEEN 15 AND 200 AND price_per_sqm > 0 AND period_start_date >= '2025-07-01' ), bucketed AS ( SELECT CASE WHEN area_per_unit < 30 THEN '1-Студия' WHEN area_per_unit < 45 THEN '2-1-к' WHEN area_per_unit < 60 THEN '3-2-к' WHEN area_per_unit < 80 THEN '4-3-к' ELSE '5-80+ м²' END AS bucket, price_per_sqm, deal_count FROM per_unit ) SELECT bucket, SUM(deal_count)::bigint AS deals, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqm) AS median_price FROM bucketed GROUP BY bucket ORDER BY bucket """ ), {"region_id": region_id}, ) .mappings() .all() ) pretty = { "1-Студия": "Студии 15-30", "2-1-к": "1-к 30-45", "3-2-к": "2-к 45-60", "4-3-к": "3-к 60-80", "5-80+ м²": "80+ м²", } total = sum(r["deals"] or 0 for r in rows) or 1 return [ { "bucket": pretty[r["bucket"]], "deals": int(r["deals"] or 0), "percent": round((r["deals"] or 0) * 100 / total, 1), "median_price": _f(r["median_price"]), } for r in rows ] def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]: rows = ( db.execute( text( """ SELECT subject_desc AS year, total_square AS total_th_sqm, sold_perc, unsold_perc, unopened_perc FROM domrf_realization WHERE region_code = :region_code AND endpoint_type = 'ready_year' AND type_square = 'total' AND snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_realization WHERE region_code = :region_code AND endpoint_type = 'ready_year' ) ORDER BY subject """ ), {"region_code": region_code}, ) .mappings() .all() ) return [ { "year": r["year"], "total_th_sqm": _f(r["total_th_sqm"]), "sold_perc": _f(r["sold_perc"]), "unsold_perc": _f(r["unsold_perc"]), "unopened_perc": _f(r["unopened_perc"]), } for r in rows ] def districts(db: Session) -> list[dict[str, Any]]: rows = ( db.execute( text( """ SELECT d.district_name, d.zk_count, d.flat_count, d.area_m2, d.median_price_per_m2, d.mean_price_per_m2, COALESCE(cq.cad_quarter_count, 0) AS cad_quarter_count FROM ekb_districts d LEFT JOIN ( SELECT district_name, COUNT(*) FILTER (WHERE cad_quarter IS NOT NULL) AS cad_quarter_count FROM v_complex_full WHERE district_name IS NOT NULL GROUP BY district_name ) cq ON cq.district_name = d.district_name WHERE d.district_name <> 'не определён' ORDER BY d.zk_count DESC NULLS LAST """ ) ) .mappings() .all() ) return [ { "district_name": r["district_name"], "zk_count": r["zk_count"], "flat_count": r["flat_count"], "area_m2": _f(r["area_m2"]), "median_price_per_m2": _f(r["median_price_per_m2"]), "mean_price_per_m2": _f(r["mean_price_per_m2"]), "cad_quarter_count": int(r["cad_quarter_count"]), } for r in rows ] def yandex_listings(db: Session) -> dict[str, Any]: rows = ( db.execute( text( """ SELECT yid, name, developer, obj_class, finished_obj, unfinished_obj, price_from, price_to, address, latitude, longitude, snapshot_date FROM yandex_realty_zk ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC """ ) ) .mappings() .all() ) items = [ { "yid": r["yid"], "name": r["name"], "developer": r["developer"], "obj_class": r["obj_class"], "flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0), "price_from": _f(r["price_from"]), "price_to": _f(r["price_to"]), "address": r["address"], "lat": _f(r["latitude"]), "lon": _f(r["longitude"]), } for r in rows ] by_class: dict[str, int] = {} for it in items: by_class[it["obj_class"] or "—"] = by_class.get(it["obj_class"] or "—", 0) + 1 return { "snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None, "total": len(items), "by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())], "items": items, } def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]: """Top developers in Sverdl by sqm + Δ sold% over the available history. Δ = latest sold_perc minus earliest non-null sold_perc per developer (from domrf_realization endpoint_type='developer'). """ rows = ( db.execute( text( """ WITH dev_history AS ( SELECT subject AS developer_id, MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt, MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt FROM domrf_realization WHERE region_code = :region_code AND endpoint_type = 'developer' GROUP BY subject ), first_last AS ( SELECT h.developer_id, (SELECT sold_perc FROM domrf_realization r WHERE r.region_code = :region_code AND r.endpoint_type = 'developer' AND r.subject = h.developer_id AND r.snapshot_date = h.first_dt AND r.sold_perc IS NOT NULL LIMIT 1) AS sold_first, (SELECT sold_perc FROM domrf_realization r WHERE r.region_code = :region_code AND r.endpoint_type = 'developer' AND r.subject = h.developer_id AND r.snapshot_date = h.last_dt AND r.sold_perc IS NOT NULL LIMIT 1) AS sold_last, h.first_dt, h.last_dt FROM dev_history h ) SELECT m.developer_id, m.developer_name, m.jk_count, m.jk_flats_total, m.sverdl_sqm, m.sverdl_sold_pct, m.avg_area_sqm, m.pct_one, m.pct_three_plus, fl.sold_first, fl.sold_last, (fl.sold_last - fl.sold_first) AS sold_delta_pp, fl.first_dt, fl.last_dt, (SELECT COUNT(*) FROM complexes c WHERE c.developer_id = m.developer_id) AS complexes_count FROM v_developer_full_metrics m LEFT JOIN first_last fl ON fl.developer_id = m.developer_id WHERE m.sverdl_sqm IS NOT NULL ORDER BY m.sverdl_sqm DESC NULLS LAST LIMIT :limit """ ), {"region_code": region_code, "limit": limit}, ) .mappings() .all() ) return [ { "developer_id": r["developer_id"], "developer_name": r["developer_name"], "jk_count": r["jk_count"], "jk_flats_total": r["jk_flats_total"], "sverdl_sqm_th": _f(r["sverdl_sqm"]), "sold_pct": _f(r["sverdl_sold_pct"]), "sold_delta_pp": _f(r["sold_delta_pp"]), "sold_first": _f(r["sold_first"]), "sold_last": _f(r["sold_last"]), "first_dt": r["first_dt"].isoformat() if r["first_dt"] else None, "last_dt": r["last_dt"].isoformat() if r["last_dt"] else None, "avg_area_sqm": _f(r["avg_area_sqm"]), "pct_one": _f(r["pct_one"]), "pct_three_plus": _f(r["pct_three_plus"]), "complexes_count": int(r["complexes_count"] or 0), } for r in rows ] def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None: row = ( db.execute( text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"), {"dev": developer_id}, ) .mappings() .first() ) if not row: return None return { "developer_id": row["developer_id"], "developer_name": row["developer_name"], "jk_count": row["jk_count"], "jk_flats_total": row["jk_flats_total"], "jk_sqm_total": _f(row["jk_sqm_total"]), "jk_ekb": row["jk_ekb"], "jk_completed": row["jk_completed"], "jk_in_progress": row["jk_in_progress"], "jk_escrow": row["jk_escrow"], "agg_flats_total": row["agg_flats_total"], "agg_one_room": row["agg_one_room"], "agg_two_room": row["agg_two_room"], "agg_three_room": row["agg_three_room"], "agg_four_plus": row["agg_four_plus"], "pct_one": _f(row["pct_one"]), "pct_three_plus": _f(row["pct_three_plus"]), "avg_area_sqm": _f(row["avg_area_sqm"]), "sverdl_sqm_th": _f(row["sverdl_sqm"]), "sverdl_sold_pct": _f(row["sverdl_sold_pct"]), "sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]), "sverdl_price_avg": _f(row["sverdl_price_avg"]), } def developer_history( db: Session, developer_ids: list[str], region_code: int = 66, ) -> list[dict[str, Any]]: """Per-month sold_perc for one or more developers in the region.""" rows = ( db.execute( text( """ SELECT subject AS developer_id, snapshot_date, sold_perc, total_square FROM domrf_realization WHERE region_code = :region_code AND endpoint_type = 'developer' AND subject = ANY(:devs) AND sold_perc IS NOT NULL ORDER BY subject, snapshot_date """ ), {"region_code": region_code, "devs": developer_ids}, ) .mappings() .all() ) return [ { "developer_id": r["developer_id"], "snapshot_date": r["snapshot_date"].isoformat(), "sold_perc": _f(r["sold_perc"]), "total_th_sqm": _f(r["total_square"]), } for r in rows ] def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]: rows = ( db.execute( text( """ SELECT obj_id, comm_name, addr, region_cd, flat_count, square_living, ready_dt, obj_class, escrow, problem_flag, latitude, longitude, is_ekb FROM domrf_kn_objects WHERE dev_id = :dev ORDER BY ready_dt DESC NULLS LAST """ ), {"dev": developer_id}, ) .mappings() .all() ) return [ { "obj_id": r["obj_id"], "comm_name": r["comm_name"], "addr": r["addr"], "region_cd": r["region_cd"], "flat_count": r["flat_count"], "square_living": _f(r["square_living"]), "ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None, "obj_class": r["obj_class"], "escrow": r["escrow"], "problem_flag": r["problem_flag"], "lat": _f(r["latitude"]), "lon": _f(r["longitude"]), "is_ekb": r["is_ekb"], } for r in rows ] def prinzip_district_distribution( db: Session, developer_id: str = "6208_0" ) -> list[dict[str, Any]]: """Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons. Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district через простой COUNT — но в таблице нет геометрии районов. Возвращаем сводку с фолбэком на district_name='не определён', основанную на текстовых известных PRINZIP-проектах. Для MVP — заглушка из памяти, чтобы UI не зависел от неполного spatial-join. TODO: добавить geometry в ekb_districts. """ # Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping. known: list[dict[str, Any]] = [ {"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7}, {"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6}, {"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9}, {"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7}, {"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7}, {"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0}, {"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0}, {"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0}, ] return known def prinzip_insights() -> dict[str, Any]: """Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph).""" return { "headline": ( "PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), " "но портфель смещён в сегмент инвесторских студий-однушек, " "тогда как рынок голосует деньгами за семейные 60-90 м² " "и премиум 80+." ), "key_gaps": [ { "label": "Средний метраж", "prinzip": 38.1, "market": 49.0, "brusnika": 60.0, "forum": 61.0, "unit": "м²", }, { "label": "Доля 1-к", "prinzip": 75.4, "market": 52.0, "brusnika": 47.0, "forum": 44.3, "unit": "%", }, { "label": "Доля 3-к+", "prinzip": 5.4, "market": 13.0, "brusnika": 18.1, "forum": 21.5, "unit": "%", }, { "label": "sold% Свердл", "prinzip": 48.0, "market": 29.0, "brusnika": 47.0, "forum": 54.0, "unit": "%", }, ], "priorities": [ { "rank": 1, "title": "Семейные 60-90 м² (3-к)", "why": ( "Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). " "Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок " "при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — " "выше текущих 6.15 М CRM." ), }, { "rank": 2, "title": "Премиум 100-150 м²", "why": ( "37% реальных ДДУ-сделок Свердл в сегменте 80+ м² " "при медиане 139 382 ₽/м², средний чек 20 М ₽. " "Премиум кад.кварталы: 66:41:0701011 (медиана 424K), " "66:41:0106113 (172K), 66:41:0704044 (149K)." ), }, ], "where_to_build": [ { "district": "Академический", "why": ( "330 ЖК / 82К квартир — самый большой кластер ЕКБ, " "PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей." ), }, { "district": "Верх-Исетский (расширение)", "why": ( "Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², " "ниша бизнес 80-130 м²." ), }, { "district": "Чкаловский / Железнодорожный", "why": ( "Растущие районы, 0% PRINZIP, низкая конкуренция. " "Тест 60-80 м² без премиума." ), }, ], "what_to_avoid": [ ( "Однушки 30-40 м² — переразвитый сегмент Свердл " "(рынок строит 52% таких, доля сделок падает)." ), ( "Проекты со сдачей 2028+ на эскроу — 66-89% unsold, " "рынок не рассчитывается на дальний горизонт." ), ], "benchmarks": [ { "name": "Брусника", "model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."), }, { "name": "Холдинг Форум-групп", "model": ( "113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²." ), }, ], } # ── Per-object drill-in (поверх extras-таблиц из 51_schema_kn_extras.sql) ──── def object_detail(db: Session, obj_id: int) -> dict[str, Any] | None: """Базовая инфа объекта из domrf_kn_objects (последний snapshot). Также возвращает buildings_count из v_complex_buildings (0 если зданий нет). """ row = ( db.execute( text( """ SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd, o.dev_id, o.dev_name, o.floor_min, o.floor_max, o.flat_count, o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class, o.latitude, o.longitude, o.obj_status, o.snapshot_date, COALESCE(cb.buildings_count, 0) AS buildings_count FROM domrf_kn_objects o LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id WHERE o.obj_id = :obj ORDER BY o.snapshot_date DESC LIMIT 1 """ ), {"obj": obj_id}, ) .mappings() .first() ) if not row: return None return { "obj_id": row["obj_id"], "hobj_id": row["hobj_id"], "comm_name": row["comm_name"], "addr": row["addr"], "short_addr": row["short_addr"], "region_cd": row["region_cd"], "dev_id": row["dev_id"], "dev_name": row["dev_name"], "floor_min": row["floor_min"], "floor_max": row["floor_max"], "flat_count": row["flat_count"], "square_living": _f(row["square_living"]), "ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None, "site_status": row["site_status"], "escrow": row["escrow"], "obj_class": row["obj_class"], "latitude": _f(row["latitude"]), "longitude": _f(row["longitude"]), "obj_status": row["obj_status"], "snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None, "buildings_count": int(row["buildings_count"]), } def object_sale_graph( db: Session, obj_id: int, type_filter: str | None = None ) -> list[dict[str, Any]]: """Time-series продаж per-ЖК. Latest snapshot. NOTE: намеренно оставлен на domrf_kn_sale_graph — это внутренний per-object detail view для PRINZIP-аналитики (/api/v1/analytics/object/*). Миграция на objective_corpus_room_month требует отдельного pr: там другая гранулярность (corpus × room_bucket), а не obj_id. Данные stale (newest 2026-01) — приемлемо для исторического графика. """ where_type = "" params: dict[str, Any] = {"obj": obj_id} if type_filter: where_type = "AND type = :type_filter" params["type_filter"] = type_filter rows = ( db.execute( text( f""" SELECT obj_id, report_month, type, realised, contracted, area_sq, price_avg, snapshot_date FROM domrf_kn_sale_graph WHERE obj_id = :obj {where_type} AND snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph WHERE obj_id = :obj {where_type} ) ORDER BY type, report_month """ ), params, ) .mappings() .all() ) return [ { "report_month": r["report_month"].isoformat() if r["report_month"] else None, "type": r["type"], "realised": r["realised"], "contracted": r["contracted"], "area_sq": _f(r["area_sq"]), "price_avg": _f(r["price_avg"]), } for r in rows ] def object_sales_agg(db: Session, obj_id: int) -> list[dict[str, Any]]: """3 строки текущих агрегатов: apartments / nonliv / parking.""" rows = ( db.execute( text( """ SELECT type, name, total, realised, perc, snapshot_date FROM domrf_kn_sales_agg WHERE obj_id = :obj AND snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id = :obj ) ORDER BY CASE type WHEN 'apartments' THEN 1 WHEN 'nonliv' THEN 2 ELSE 3 END """ ), {"obj": obj_id}, ) .mappings() .all() ) return [ { "type": r["type"], "name": r["name"], "total": r["total"], "realised": r["realised"], "perc": _f(r["perc"]), } for r in rows ] def object_infrastructure( db: Session, obj_id: int, category: str | None = None, max_distance: int = 5000, ) -> list[dict[str, Any]]: """POI вокруг ЖК с фильтром по категории и радиусу.""" where_cat = "AND poi_category = :cat" if category else "" params: dict[str, Any] = {"obj": obj_id, "dist": max_distance} if category: params["cat"] = category rows = ( db.execute( text( f""" SELECT poi_name, poi_subtitle, poi_category, poi_address, poi_lat, poi_lon, distance_m FROM domrf_kn_infrastructure WHERE obj_id = :obj AND distance_m <= :dist {where_cat} AND snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_kn_infrastructure WHERE obj_id = :obj ) ORDER BY distance_m ASC """ ), params, ) .mappings() .all() ) return [ { "poi_name": r["poi_name"], "poi_subtitle": r["poi_subtitle"], "poi_category": r["poi_category"], "poi_address": r["poi_address"], "lat": _f(r["poi_lat"]), "lon": _f(r["poi_lon"]), "distance_m": _f(r["distance_m"]), } for r in rows ] def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str, Any]]: """Фото-метаданные, последние сверху.""" rows = ( db.execute( text( """ SELECT obj_file_id, ord_num, photo_url, photo_dttm, period_dt, size_bytes, photo_name, ready_desc, build_type, hidden, local_path FROM domrf_kn_photos WHERE obj_id = :obj AND COALESCE(hidden, FALSE) = FALSE ORDER BY period_dt DESC NULLS LAST, ord_num DESC NULLS LAST LIMIT :lim """ ), {"obj": obj_id, "lim": limit}, ) .mappings() .all() ) return [ { "obj_file_id": r["obj_file_id"], "ord_num": r["ord_num"], "photo_url": r["photo_url"], # Always serve thumbs through our backend — cached WebP, no upstream # latency, no Next.js dev-mode optimizer cold-hit cost. "thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb", "full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full", "photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None, "period_dt": r["period_dt"].isoformat() if r["period_dt"] else None, "size_bytes": r["size_bytes"], "photo_name": r["photo_name"], "ready_desc": r["ready_desc"], "build_type": r["build_type"], "local_path": r["local_path"], } for r in rows ] def object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None: """Extended object detail — adds all Wave A+B columns (22begh). Returns the same base fields as object_detail() PLUS the 30 new columns from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns that haven't been scraped yet return NULL. """ row = ( db.execute( text( """ SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd, o.dev_id, o.dev_name, o.dev_group_name, o.floor_min, o.floor_max, o.flat_count, o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class, o.latitude, o.longitude, o.obj_status, o.snapshot_date, o.energy_eff, o.wall_type, -- Building specs (22e) o.first_floor_type, o.section_count, o.elevators_passenger_count, o.elevators_cargo_count, o.parking_total_slots, o.guest_parking_inside_count, o.guest_parking_outside_count, o.ceiling_height_m, -- Apartment summary (22e) o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2, -- Yard (22e) o.playground_kids_count, o.playground_sport_count, o.has_bike_paths, o.trash_areas_count, -- OVZ (22e) o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift, -- Catalog/UI (22e) o.flat_area_min, o.flat_area_max, o.price_min_rub, o.price_max_rub, o.price_per_m2_min, o.price_per_m2_max, o.parking_provision_pct, o.project_published_at, o.project_declaration_num, -- Metro & scores (22e/22h) o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3, o.domrf_score_location, o.domrf_score_transport, o.domrf_score_infrastructure, COALESCE(cb.buildings_count, 0) AS buildings_count FROM domrf_kn_objects o LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id WHERE o.obj_id = :obj ORDER BY o.snapshot_date DESC LIMIT 1 """ ), {"obj": obj_id}, ) .mappings() .first() ) if not row: return None metro_top3 = row["metro_top3"] return { "obj_id": row["obj_id"], "hobj_id": row["hobj_id"], "comm_name": row["comm_name"], "addr": row["addr"], "short_addr": row["short_addr"], "region_cd": row["region_cd"], "dev_id": row["dev_id"], "dev_name": row["dev_name"], "dev_group_name": row["dev_group_name"], "floor_min": row["floor_min"], "floor_max": row["floor_max"], "flat_count": row["flat_count"], "square_living": _f(row["square_living"]), "ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None, "site_status": row["site_status"], "escrow": row["escrow"], "obj_class": row["obj_class"], "latitude": _f(row["latitude"]), "longitude": _f(row["longitude"]), "obj_status": row["obj_status"], "snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None, "energy_eff": row["energy_eff"], "wall_type": row["wall_type"], # Building specs "first_floor_type": row["first_floor_type"], "section_count": row["section_count"], "elevators_passenger_count": row["elevators_passenger_count"], "elevators_cargo_count": row["elevators_cargo_count"], "parking_total_slots": row["parking_total_slots"], "guest_parking_inside_count": row["guest_parking_inside_count"], "guest_parking_outside_count": row["guest_parking_outside_count"], "ceiling_height_m": _f(row["ceiling_height_m"]), # Apartment summary "finishing_variants_count": row["finishing_variants_count"], "has_free_planning": row["has_free_planning"], "avg_flat_area_m2": _f(row["avg_flat_area_m2"]), # Yard "playground_kids_count": row["playground_kids_count"], "playground_sport_count": row["playground_sport_count"], "has_bike_paths": row["has_bike_paths"], "trash_areas_count": row["trash_areas_count"], # OVZ "has_ramp": row["has_ramp"], "has_low_platforms": row["has_low_platforms"], "has_wheelchair_lift": row["has_wheelchair_lift"], # Catalog/UI "flat_area_min": _f(row["flat_area_min"]), "flat_area_max": _f(row["flat_area_max"]), "price_min_rub": row["price_min_rub"], "price_max_rub": row["price_max_rub"], "price_per_m2_min": _f(row["price_per_m2_min"]), "price_per_m2_max": _f(row["price_per_m2_max"]), "parking_provision_pct": _f(row["parking_provision_pct"]), "project_published_at": ( row["project_published_at"].isoformat() if row["project_published_at"] else None ), "project_declaration_num": row["project_declaration_num"], # Metro & scores "metro_nearest_name": row["metro_nearest_name"], "metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"], "metro_top3": metro_top3, # already jsonb → dict/list from psycopg3 "domrf_score_location": row["domrf_score_location"], "domrf_score_transport": row["domrf_score_transport"], "domrf_score_infrastructure": row["domrf_score_infrastructure"], "buildings_count": int(row["buildings_count"]), } def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]: """Per-rooms aggregation из domrf_kn_flats для объекта. Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL). Возвращает count total, count 'free', min/max area, min/max price. """ rows = ( db.execute( text( """ WITH latest AS ( SELECT MAX(snapshot_date) AS snap FROM domrf_kn_flats WHERE obj_id = :obj ) SELECT CASE WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%' OR LOWER(f.flat_type) LIKE '%nonliv%' THEN 'Нежилые' WHEN f.rooms = 0 THEN 'Студия' WHEN f.rooms = 1 THEN '1-комн.' WHEN f.rooms = 2 THEN '2-комн.' WHEN f.rooms = 3 THEN '3-комн.' ELSE (f.rooms::text || '-комн.') END AS room_label, COALESCE(f.rooms, -1) AS sort_key, COUNT(*) AS total_count, COUNT(*) FILTER (WHERE LOWER(f.status) = 'free' OR LOWER(f.status) LIKE '%свобод%') AS free_count, MIN(f.total_area) AS area_min, MAX(f.total_area) AS area_max, MIN(f.price_rub) FILTER (WHERE f.price_rub > 0) AS price_min, MAX(f.price_rub) FILTER (WHERE f.price_rub > 0) AS price_max FROM domrf_kn_flats f CROSS JOIN latest l WHERE f.obj_id = :obj AND f.snapshot_date = l.snap GROUP BY room_label, sort_key ORDER BY sort_key """ ), {"obj": obj_id}, ) .mappings() .all() ) return [ { "room_label": r["room_label"], "total_count": r["total_count"], "free_count": r["free_count"], "area_min": _f(r["area_min"]), "area_max": _f(r["area_max"]), "price_min": r["price_min"], "price_max": r["price_max"], } for r in rows ] def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]: """6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f).""" rows = ( db.execute( text( """ SELECT check_type, passed, checked_at FROM domrf_obj_checks WHERE obj_id = :obj ORDER BY check_type """ ), {"obj": obj_id}, ) .mappings() .all() ) return [ { "check_type": r["check_type"], "passed": r["passed"], "checked_at": r["checked_at"].isoformat() if r["checked_at"] else None, } for r in rows ] def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]: """PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at.""" rows = ( db.execute( text( """ SELECT doc_type, doc_num, posted_at, file_url, size_bytes FROM domrf_kn_documents WHERE obj_id = :obj ORDER BY doc_type, posted_at DESC NULLS LAST """ ), {"obj": obj_id}, ) .mappings() .all() ) return [ { "doc_type": r["doc_type"], "doc_num": r["doc_num"], "posted_at": r["posted_at"].isoformat() if r["posted_at"] else None, "file_url": r["file_url"], "size_bytes": r["size_bytes"], } for r in rows ] def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]: """Воронка по месяцам из materialized view.""" rows = ( db.execute( text( """ SELECT month, source, leads, engaged, converted, conv_pct FROM prinzip_funnel_monthly WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date ORDER BY month DESC, leads DESC """ ), {"months": months}, ) .mappings() .all() ) return [ { "month": r["month"].isoformat() if r["month"] else None, "source": r["source"], "leads": r["leads"], "engaged": r["engaged"], "converted": r["converted"], "conv_pct": _f(r["conv_pct"]), } for r in rows ] def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, Any]]: """Агрегаты по source за последние N месяцев.""" rows = ( db.execute( text( """ SELECT source, SUM(leads) AS leads, SUM(engaged) AS engaged, SUM(converted) AS converted, ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct FROM prinzip_funnel_monthly WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date GROUP BY source ORDER BY leads DESC """ ), {"months": months}, ) .mappings() .all() ) return [ { "source": r["source"], "leads": int(r["leads"] or 0), "engaged": int(r["engaged"] or 0), "converted": int(r["converted"] or 0), "conv_pct": _f(r["conv_pct"]), } for r in rows ] def prinzip_funnel_by_object(db: Session) -> list[dict[str, Any]]: """Conversion per ЖК.""" rows = ( db.execute( text( """ SELECT obj_id, comm_name, leads_count, deals_count, conv_pct, total_revenue, avg_deal_price FROM prinzip_funnel_by_object ORDER BY total_revenue DESC NULLS LAST """ ), ) .mappings() .all() ) return [ { "obj_id": r["obj_id"], "comm_name": r["comm_name"], "leads_count": r["leads_count"], "deals_count": r["deals_count"], "conv_pct": _f(r["conv_pct"]), "total_revenue": _f(r["total_revenue"]), "avg_deal_price": _f(r["avg_deal_price"]), } for r in rows ] def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]: """Список 28 PRINZIP-ЖК с агрегатами + apartments-velocity sparkline data. NOTE: sparkline (velocity CTE) намеренно оставлен на domrf_kn_sale_graph. objective_corpus_room_month не имеет obj_id — требует JOIN через objective_complex_mapping по project_name. Это отдельная задача рефакторинга admin-view. Данные stale (newest 2026-01), sparkline визуально OK для тренда. """ rows = ( db.execute( text( """ WITH agg AS ( SELECT obj_id, total, realised, perc FROM domrf_kn_sales_agg WHERE type = 'apartments' AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg) ), velocity AS ( SELECT obj_id, ARRAY_AGG(realised ORDER BY report_month) AS sparkline_realised, ARRAY_AGG(report_month::text ORDER BY report_month) AS months FROM domrf_kn_sale_graph WHERE type = 'apartments' AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph) GROUP BY obj_id ) SELECT o.obj_id, o.comm_name, o.addr, o.flat_count, o.square_living, o.ready_dt, o.site_status, a.total, a.realised, a.perc, v.sparkline_realised, v.months FROM domrf_kn_objects o LEFT JOIN agg a ON a.obj_id = o.obj_id LEFT JOIN velocity v ON v.obj_id = o.obj_id WHERE o.dev_id = '6208_0' AND o.snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_kn_objects WHERE dev_id = '6208_0' ) ORDER BY a.total DESC NULLS LAST """ ), ) .mappings() .all() ) return [ { "obj_id": r["obj_id"], "comm_name": r["comm_name"], "addr": r["addr"], "flat_count": r["flat_count"], "square_living": _f(r["square_living"]), "ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None, "site_status": r["site_status"], "total": r["total"], "realised": r["realised"], "perc": _f(r["perc"]), "sparkline_realised": ( list(r["sparkline_realised"]) if r["sparkline_realised"] else [] ), "months": list(r["months"]) if r["months"] else [], } for r in rows ] # ── Rule-based recommender (Уровень 1) ──────────────────────────────────────── # Pretty-name map shared with quartirography_deals(). Keep IDs sortable so # bucket ordering is deterministic in the response. _BUCKET_PRETTY: dict[str, str] = { "1-Студия": "Студии 15-30", "2-1-к": "1-к 30-45", "3-2-к": "2-к 45-60", "4-3-к": "3-к 60-80", "5-80+ м²": "80+ м²", } _BUCKET_SQL = text( """ -- ВАЖНО: rosreestr агрегирует пакетные ДДУ-сделки в одну строку. -- Например, 5 квартир по 40 м² одного покупателя → row с -- area=200, deal_count=5. Если bucket'ить по сырой area, такая -- запись попадает в «80+ м²» хотя реально это 5 квартир «1-к». -- Поэтому: -- * area_per_unit = area / deal_count (площадь одной квартиры) -- * COUNT через SUM(deal_count) — реальное число единиц жилья -- * Медианы взвешиваем по deal_count (PERCENTILE_DISC по разворачиванию -- не PostgreSQL-friendly; используем PERCENTILE_CONT — приближение, -- для редких outliers с deal_count >>1 расхождение <2%) WITH per_unit AS ( SELECT (area / deal_count) AS area_per_unit, price_per_sqm, deal_count FROM rosreestr_deals WHERE region_code = :rc AND doc_type = 'ДДУ' -- realestate_type_code 002001003000 = квартиры (жилые помещения). -- 001 = земельные участки, 002 = нежилые помещения. AND realestate_type_code = '002001003000' AND area > 10 -- ВНИМАНИЕ: с 2025Q1 rosreestr резко увеличил агрегацию строк -- (1 row = 30+ сделок, area = SUM по пакету). Фильтр по сырой -- area отрезает 95% свежих данных. Используем только per-unit -- фильтр (15..200 м² — реалистичный диапазон одной квартиры). AND deal_count > 0 AND (area / deal_count) BETWEEN 15 AND 200 AND price_per_sqm BETWEEN 30000 AND 1000000 AND period_start_date >= NOW() - (:months_window || ' months')::INTERVAL ), bucketed AS ( SELECT CASE WHEN area_per_unit < 30 THEN '1-Студия' WHEN area_per_unit < 45 THEN '2-1-к' WHEN area_per_unit < 60 THEN '3-2-к' WHEN area_per_unit < 80 THEN '4-3-к' ELSE '5-80+ м²' END AS bucket, area_per_unit, price_per_sqm, deal_count FROM per_unit ) SELECT bucket, SUM(deal_count)::bigint AS deals, SUM(area_per_unit * deal_count) / SUM(deal_count) AS area_avg, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_per_unit) AS area_median, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqm) AS price_median, PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p25, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p75 FROM bucketed GROUP BY bucket ORDER BY bucket """ ) def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]: return list( db.execute( _BUCKET_SQL, {"rc": region_code, "months_window": months_window}, ) .mappings() .all() ) # Industry-default elasticity used when sale_graph regression is not reliable # (n<30 or R²<0.1). Negative because higher price ⇒ slower sales. FALLBACK_ELASTICITY = -1.5 def _velocity_baseline( db: Session, *, region_code: int, district_name: str, target_class: str | None, ) -> dict[str, Any]: """Median monthly sales velocity (apartments/month per ЖК) from objective_corpus_room_month for objects in the same район+class over last 24 mo. Migrated from domrf_kn_sale_graph (stale since 2026-01) to objective_corpus_room_month (updated weekly via Objective API). objective_corpus_room_month.district matches domrf_kn_objects.district_name. class filter uses 'class' column (Комфорт/Бизнес/Стандарт). Returns dict {realised_per_month_median, realised_per_month_avg, objects_count, observations}. All-None means no data → caller falls back. """ # Objective class naming: "Комфорт", "Бизнес", "Стандарт" — capitalised. # domrf obj_class may differ in case; apply ILIKE for robustness. where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" params: dict[str, Any] = {"dn": district_name} if target_class: params["cls"] = target_class # region_code not used — objective_corpus_room_month covers only EKB (region 66). # district filter is sufficient for locality. If district returns no rows, # caller falls back to rosreestr_fallback path (unchanged behaviour). _ = region_code # retained in signature for backward compat row = ( db.execute( text( f""" WITH per_project_month AS ( SELECT project_name, report_month, SUM(deals_total_count) AS month_units FROM objective_corpus_room_month crm WHERE crm.district = :dn {where_class} AND crm.deals_total_count > 0 AND crm.report_month >= NOW() - INTERVAL '24 months' GROUP BY project_name, report_month ) SELECT AVG(month_units) AS avg_pm, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm, COUNT(DISTINCT project_name) AS objects, COUNT(*) AS observations FROM per_project_month """ ), params, ) .mappings() .first() ) if not row: return { "realised_per_month_avg": None, "realised_per_month_median": None, "objects_count": 0, "observations": 0, } return { "realised_per_month_avg": _f(row["avg_pm"]), "realised_per_month_median": _f(row["median_pm"]), "objects_count": int(row["objects"] or 0), "observations": int(row["observations"] or 0), } def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]: """Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок (конкуренты много продали, новый проект имеет место). <20% = свежий (много инвентаря на продажу, сложнее пробиться). Возвращает (median_pct, n_objects). None если <5 ЖК с perc. """ row = ( db.execute( text( """ SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median, COUNT(*) AS n FROM domrf_kn_sales_agg a JOIN domrf_kn_objects o ON o.obj_id = a.obj_id AND o.snapshot_date = a.snapshot_date WHERE a.type = 'apartments' AND a.perc IS NOT NULL AND o.district_name = :dn AND o.site_status = 'Строящиеся' """ ), {"dn": district_name}, ) .mappings() .first() ) if not row or (row["n"] or 0) < 5: return None, int(row["n"] or 0) if row else 0 return _f(row["sold_median"]), int(row["n"]) def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]: """Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 — остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+. Мигрировано с domrf_kn_sale_graph (stale 2026-01) на objective_corpus_room_month (обновляется еженедельно). deals_total_count заменяет realised (DDU + DKP всего по корпусу). Возвращает (ratio, recent_units, prior_units). None если данных мало. """ row = ( db.execute( text( """ SELECT SUM(crm.deals_total_count) FILTER (WHERE crm.report_month >= DATE '2025-07-01') AS recent, SUM(crm.deals_total_count) FILTER (WHERE crm.report_month BETWEEN DATE '2025-01-01' AND DATE '2025-06-30') AS prior FROM objective_corpus_room_month crm WHERE crm.district = :dn AND crm.deals_total_count > 0 """ ), {"dn": district_name}, ) .mappings() .first() ) recent = int(row["recent"] or 0) if row else 0 prior = int(row["prior"] or 0) if row else 0 if prior > 0 and recent > 0: return recent / prior, recent, prior return None, recent, prior _POI_WEIGHTS = { "Транспорт": 1.5, "Метро": 2.0, "Образование": 1.2, "Медицина": 1.3, "Спорт": 1.0, "Продукты": 0.8, "Развлечения": 0.7, "Новостройки": 0.0, # сами ЖК — не используем как amenity } def _district_poi_score(db: Session, *, district_name: str) -> float | None: """Среднее по ЖК района: weighted POI count в радиусе 1000м. Используем категории-веса (метро/медицина важнее, новостройки игнор). Возвращает None если в районе <3 ЖК с POI. """ weights_sql = " ".join( [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()] ) row = ( db.execute( text( f""" WITH per_obj AS ( SELECT i.obj_id, SUM(CASE {weights_sql} ELSE 0.5 END) FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000) AS weighted_poi FROM domrf_kn_infrastructure i JOIN domrf_kn_objects o ON o.obj_id = i.obj_id AND o.snapshot_date = i.snapshot_date WHERE o.district_name = :dn GROUP BY i.obj_id ) SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n FROM per_obj WHERE weighted_poi > 0 """ ), {"dn": district_name}, ) .mappings() .first() ) if not row or (row["n"] or 0) < 3: return None return _f(row["avg_score"]) def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None: """Средний POI score по всему ЕКБ — для нормировки district_poi_score.""" weights_sql = " ".join( [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()] ) row = ( db.execute( text( f""" WITH per_obj AS ( SELECT i.obj_id, SUM(CASE {weights_sql} ELSE 0.5 END) FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000) AS weighted_poi FROM domrf_kn_infrastructure i JOIN domrf_kn_objects o ON o.obj_id = i.obj_id AND o.snapshot_date = i.snapshot_date WHERE o.region_cd = :rc AND o.district_name IS NOT NULL GROUP BY i.obj_id ) SELECT AVG(weighted_poi) AS avg_score FROM per_obj WHERE weighted_poi > 0 """ ), {"rc": region_code}, ) .mappings() .first() ) return _f(row["avg_score"]) if row else None def _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, Any]: """Медианная кадастровая стоимость ₽/м² жилых строений в районе через spatial-join cad_buildings → ekb_districts_geom. Возвращает None полей, если в районе нет cad_buildings со cost_value. Используется как cross-check для market price из rosreestr_deals: cadastre_vs_market_pct > +50% (рынок сильно дороже кадастра, переоценка) или < -30% (рынок дешевле кадастра, аномалия) → warning badge на UI. """ row = ( db.execute( text( """ WITH district_geom AS ( SELECT geom FROM ekb_districts_geom WHERE district_name = :dn LIMIT 1 ), buildings_in AS ( SELECT cb.cost_value / NULLIF(cb.area, 0) AS price_per_m2 FROM cad_buildings cb JOIN district_geom dg ON ST_Intersects(dg.geom, cb.geom) WHERE cb.cost_value IS NOT NULL AND cb.area IS NOT NULL AND cb.area >= 100 -- floors INTEGER (Rosreestr ETL приводит к int); NULL = unknown. -- Считаем МКД если floors ≥3 или purpose содержит «многокв». AND (cb.floors >= 3 OR cb.purpose ILIKE '%многокв%') AND (cb.cost_value / NULLIF(cb.area, 0)) BETWEEN 5000 AND 500000 ) SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS median_per_m2, COUNT(*)::bigint AS n FROM buildings_in """ ), {"dn": district_name}, ) .mappings() .first() ) if not row or row["n"] == 0: return {"median_per_m2": None, "buildings_n": 0} return { "median_per_m2": _f(row["median_per_m2"]), "buildings_n": int(row["n"]), } def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]: """Последняя средневзвешенная ставка ИЖК из cbr_mortgage_series. ВАЖНО: возвращаем СРЕДНЕВЗВЕШЕННУЮ С льготами (семейная/IT/ДВ-ипотека) — это ~7-8%. РЫНОЧНАЯ ставка без льгот в БД отсутствует (она ~20% по публикациям ЦБ Янв 2026, но в наших cbr_mortgage_series этой серии нет). Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим 'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'. """ row = ( db.execute( text( """ SELECT value, period FROM cbr_mortgage_series WHERE title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%' AND title ILIKE '%в рублях, %' AND value IS NOT NULL AND value BETWEEN 1 AND 30 -- защита от мусорных ORDER BY period DESC LIMIT 1 """ ) ) .mappings() .first() ) if not row: return None, None return _f(row["value"]), row["period"] def _active_competitors_count( db: Session, *, region_code: int, district_name: str, target_class: str | None, ) -> tuple[int, str]: """N активно строящихся ЖК для нормировки velocity. Каскадный fallback: 1) (район + класс) — самый узкий 2) (район) без класса — если первый дал <2 3) весь регион — если второй дал <2 Возвращает (count, scope_used). Min 1 чтобы не делить на 0.""" def _q(where_extras: str, params: dict[str, Any]) -> int: n = db.execute( text( f""" SELECT COUNT(*) FROM domrf_kn_objects WHERE region_cd = :rc AND site_status = 'Строящиеся' {where_extras} """ ), params, ).scalar() return int(n or 0) # Tier 1: район + класс (через PostGIS-полигоны district_name) if target_class: n = _q( "AND district_name = :dn AND obj_class = :cls", {"rc": region_code, "dn": district_name, "cls": target_class}, ) if n >= 2: return n, "district+class" # Tier 2: район (без класса — могут быть ЖК где obj_class NULL) n = _q( "AND district_name = :dn", {"rc": region_code, "dn": district_name}, ) if n >= 2: return n, "district" # Tier 3: весь регион (когда район по сути не покрыт скрапером) n = _q("", {"rc": region_code}) if n >= 1: return n, "region" return 1, "fallback_singleton" def _velocity_baseline_per_bucket( db: Session, *, region_code: int, district_name: str, target_class: str | None, ) -> dict[str, float] | None: """Per-bucket median velocity (units/month per ЖК) из objective_corpus_room_month. Группирует по room_bucket → для каждого бакета вычисляет median(month_units) по проектам района/класса за последние 24 месяца. Маппинг room_bucket → _BUCKET_PRETTY ключи: студия/studio/0 → '1-Студия' 1 → '2-1-к' 2 → '3-2-к' 3 → '4-3-к' 4/5+ → '5-80+ м²' Возвращает dict {bucket_id → median velocity} только для бакетов с данными, или None если нет данных совсем (caller переходит на rosreestr-fallback). _ region_code retained for backward compat; objective data covers EKB only. """ _ = region_code where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" params: dict[str, Any] = {"dn": district_name} if target_class: params["cls"] = target_class rows = ( db.execute( text( f""" WITH bucket_mapped AS ( SELECT CASE WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0') THEN '1-Студия' WHEN crm.room_bucket = '1' THEN '2-1-к' WHEN crm.room_bucket = '2' THEN '3-2-к' WHEN crm.room_bucket = '3' THEN '4-3-к' WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²' ELSE NULL END AS bucket_id, crm.project_name, crm.report_month, crm.deals_total_count FROM objective_corpus_room_month crm WHERE crm.district = :dn {where_class} AND crm.deals_total_count > 0 AND crm.report_month >= NOW() - INTERVAL '24 months' ), per_project_bucket_month AS ( SELECT bucket_id, project_name, report_month, SUM(deals_total_count) AS month_units FROM bucket_mapped WHERE bucket_id IS NOT NULL GROUP BY bucket_id, project_name, report_month ) SELECT bucket_id, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm, COUNT(DISTINCT project_name) AS objects, COUNT(*) AS observations FROM per_project_bucket_month GROUP BY bucket_id """ ), params, ) .mappings() .all() ) if not rows: return None result: dict[str, float] = {} for r in rows: v = _f(r["median_pm"]) if v is not None and int(r["observations"] or 0) >= 3: result[r["bucket_id"]] = v return result if result else None def _elasticity_coef( db: Session, *, region_code: int, district_name: str, target_class: str | None, elasticity_window_months: int = 24, ) -> dict[str, Any]: """Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on objective_corpus_room_month observations for the same район+class. Returns elasticity (slope), R², n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak. Мигрировано с domrf_kn_sale_graph (stale 2026-01) на objective_corpus_room_month (обновляется еженедельно). Маппинг: realised → deals_total_count, price_avg → deals_total_avg_price_thousand_rub_per_m2. LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope не зависит от единиц (аддитивный сдвиг в LN пространстве). """ where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" params: dict[str, Any] = { "dn": district_name, "ew": elasticity_window_months, } if target_class: params["cls"] = target_class _ = region_code # retained for backward compat; objective data covers EKB only row = ( db.execute( text( f""" WITH pts AS ( SELECT LN(crm.deals_total_count::float8) AS y, LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x FROM objective_corpus_room_month crm WHERE crm.district = :dn {where_class} AND crm.deals_total_count > 0 AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0 AND crm.report_month >= NOW() - (:ew || ' months')::interval ) SELECT regr_slope(y, x) AS slope, regr_r2(y, x) AS r2, COUNT(*) AS n FROM pts """ ), params, ) .mappings() .first() ) n = int(row["n"]) if row and row["n"] is not None else 0 slope = _f(row["slope"]) if row else None r2 = _f(row["r2"]) if row else None if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0: return { "elasticity": round(slope, 4), "r2": round(r2, 4), "n": n, "source": "regression", } return { "elasticity": FALLBACK_ELASTICITY, "r2": r2 or 0.0, "n": n, "source": "fallback", } def _elasticity_per_bucket_coef( db: Session, *, region_code: int, district_name: str, target_class: str | None, fallback: dict[str, Any], elasticity_window_months: int = 24, ) -> dict[str, dict[str, Any]]: """Per-bucket эластичность (Tier 3): группируем objective_corpus_room_month по room_bucket — регрессия log-log для каждой группы. Студии vs 80+ м² реагируют на цену по-разному. Мигрировано с domrf_kn_sale_graph + domrf_kn_flats (stale 2026-01) на objective_corpus_room_month (обновляется еженедельно). objective_corpus_room_month уже содержит room_bucket напрямую — нет необходимости в MODE-агрегации domrf_kn_flats. Маппинг room_bucket → _BUCKET_PRETTY ключи: 'студия' → '1-Студия' '1' → '2-1-к' '2' → '3-2-к' '3' → '4-3-к' '4'/'5+' → '5-80+ м²' Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём общую эластичность из `fallback` со source='fallback_global'. """ where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" params: dict[str, Any] = { "dn": district_name, "ew": elasticity_window_months, } if target_class: params["cls"] = target_class _ = region_code # retained for backward compat; objective data covers EKB only rows = ( db.execute( text( f""" WITH pts AS ( SELECT CASE WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0') THEN '1-Студия' WHEN crm.room_bucket = '1' THEN '2-1-к' WHEN crm.room_bucket = '2' THEN '3-2-к' WHEN crm.room_bucket = '3' THEN '4-3-к' WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²' ELSE NULL END AS bucket, LN(crm.deals_total_count::float8) AS y, LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x FROM objective_corpus_room_month crm WHERE crm.district = :dn {where_class} AND crm.deals_total_count > 0 AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0 AND crm.report_month >= NOW() - (:ew || ' months')::interval ) SELECT bucket, regr_slope(y, x) AS slope, regr_r2(y, x) AS r2, COUNT(*)::bigint AS n FROM pts WHERE bucket IS NOT NULL GROUP BY bucket """ ), params, ) .mappings() .all() ) out: dict[str, dict[str, Any]] = {} fallback_e = float(fallback["elasticity"]) by_bucket = {r["bucket"]: r for r in rows} for bucket_id, bucket_pretty in _BUCKET_PRETTY.items(): r = by_bucket.get(bucket_id) n_b = int(r["n"]) if r and r["n"] is not None else 0 slope = _f(r["slope"]) if r else None r2 = _f(r["r2"]) if r else None if n_b >= 30 and slope is not None and r2 is not None and r2 >= 0.05 and slope < 0: out[bucket_pretty] = { "elasticity": round(slope, 4), "r2": round(r2, 4), "n": n_b, "source": "regression", } else: out[bucket_pretty] = { "elasticity": fallback_e, "r2": round(r2, 4) if r2 is not None else 0.0, "n": n_b, "source": "fallback_global", } return out def _noise_penalty_factor(db: Session, district_name: str | None) -> tuple[float, dict]: """Penalty к ценам исходя из плотности шумных объектов в районе. Returns: (factor in [0.90, 1.0], breakdown dict). Чем больше магистралей/жд/промзон — тем ниже factor (max -10%). """ if not district_name: return 1.0, {} row = ( db.execute( text( """ WITH district_noise AS ( SELECT n.source_type, n.road_class, COUNT(*) AS n FROM osm_noise_sources_ekb n JOIN ekb_districts d ON ST_Intersects(n.geom, d.geom) WHERE d.district_name = :dn GROUP BY 1, 2 ) SELECT COALESCE(SUM(n), 0) AS total_sources, COALESCE(SUM(CASE WHEN source_type = 'railway' THEN n END), 0) AS railway_n, COALESCE(SUM(CASE WHEN source_type = 'industrial' THEN n END), 0) AS industrial_n, COALESCE( SUM(CASE WHEN road_class IN ('motorway', 'trunk') THEN n END), 0 ) AS magistral_n FROM district_noise """ ), {"dn": district_name}, ) .mappings() .first() ) if not row or not row["total_sources"]: return 1.0, {"district": district_name, "noise_sources": 0} score = ( float(row["magistral_n"]) * 0.05 + float(row["railway_n"]) * 0.02 + float(row["industrial_n"]) * 0.03 ) penalty = min(0.10, max(0.0, score / 100)) factor = 1.0 - penalty return round(factor, 4), { "district": district_name, "magistral_n": int(row["magistral_n"]), "railway_n": int(row["railway_n"]), "industrial_n": int(row["industrial_n"]), "total_sources": int(row["total_sources"]), "penalty_pct": round(penalty * 100, 1), } def _competitors_two_dim( db: Session, *, region_code: int, district_name: str, target_class: str | None, ) -> tuple[int, int, float, str]: """Двумерный подсчёт активных конкурентов: - radius_n: ЖК в радиусе 3км от центроида района - district_only_n: ЖК в районе, но вне 3км радиуса - total_weighted = radius_n * 1.0 + district_only_n * 0.6 Returns (radius_n, district_only_n, total_weighted, scope). Если district_name не найден в ekb_districts — падает в старый _active_competitors_count с total_weighted = float(competitors). """ # Получаем центроид района для radius-фильтра centroid_row = ( db.execute( text( """ SELECT ST_AsText(ST_Centroid(geom)) AS centroid_wkt FROM ekb_districts WHERE district_name = :dn LIMIT 1 """ ), {"dn": district_name}, ) .mappings() .first() ) if not centroid_row or not centroid_row["centroid_wkt"]: # Fallback: используем старый одномерный счётчик n, scope = _active_competitors_count( db, region_code=region_code, district_name=district_name, target_class=target_class ) return 0, n, float(n), scope class_filter = "AND obj_class = :cls" if target_class else "" params: dict[str, Any] = { "rc": region_code, "dn": district_name, "centroid": centroid_row["centroid_wkt"], } if target_class: params["cls"] = target_class row = ( db.execute( text( f""" WITH active AS ( SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name FROM domrf_kn_objects WHERE region_cd = :rc AND site_status = 'Строящиеся' AND district_name = :dn {class_filter} ORDER BY obj_id, snapshot_date DESC NULLS LAST ), centroid AS ( SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt ) SELECT COUNT(*) FILTER ( WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography, c.pt, 3000 ) ) AS radius_n, COUNT(*) FILTER ( WHERE NOT ST_DWithin( ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography, c.pt, 3000 ) ) AS district_only_n FROM active a, centroid c """ ), params, ) .mappings() .first() ) radius_n = int(row["radius_n"] or 0) if row else 0 district_only_n = int(row["district_only_n"] or 0) if row else 0 total_weighted = radius_n * 1.0 + district_only_n * 0.6 if total_weighted < 1.0: # Нет конкурентов в районе — fallback к старому счётчику (регион) n, scope = _active_competitors_count( db, region_code=region_code, district_name=district_name, target_class=target_class ) return 0, n, float(max(n, 1)), scope return radius_n, district_only_n, max(total_weighted, 1.0), "district_2d" def _bucket_success_ranking( db: Session, district_name: str | None, target_class: str | None ) -> list[dict]: """Рейтинг bucket'ов по success_score из v_bucket_success_score. Возвращает список dict {bucket, success_score, n_deals, velocity_z, price_z, area_z}, sorted DESC by success_score. Пустой список если данных нет или district_name не передан. """ if not district_name: return [] rows = ( db.execute( text( """ SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z FROM v_bucket_success_score WHERE district_name = :dn AND obj_class = COALESCE(:cls, 'Comfort') ORDER BY success_score DESC """ ), {"dn": district_name, "cls": target_class}, ) .mappings() .all() ) return [ { "bucket": r["bucket"], "success_score": float(r["success_score"]) if r["success_score"] is not None else 0.0, "n_deals": int(r["n_deals"] or 0), "velocity_z": float(r["velocity_z"]) if r["velocity_z"] is not None else 0.0, "price_z": float(r["price_z"]) if r["price_z"] is not None else 0.0, "area_z": float(r["area_z"]) if r["area_z"] is not None else 0.0, } for r in rows ] def recommend_mix( db: Session, *, district_name: str, area_total_m2: float | None = None, target_class: str | None = None, months_window: int = 24, region_code: int = 66, price_factor: float = 1.0, target_months: int | None = None, ) -> dict[str, Any]: """Rule-based квартирография recommender v3.1-v3.4. City-wide bucket distribution from rosreestr_deals (последние N месяцев), скорректированная на район (через ekb_districts.median_price_per_m2) и класс (через yandex_realty_zk price-агрегаты per-class). v3.1: noise penalty (-10% max) по osm_noise_sources_ekb v3.2: hard-cap comparables по boundaries района v3.3: hard-cap 24 мес + elasticity_window_months = 24 v3.4: success-driven mix из v_bucket_success_score """ warnings: list[str] = [] # #24 Hard-cap: данные старше 24 мес нерелевантны (ставки ЦБ, ипотека менялись) if months_window > 24: logger.warning("recommend_mix: months_window=%d > 24, capped to 24", months_window) months_window = 24 elasticity_window_months = 24 # синхронизировано с share_window (issue #24) # 1) District lookup district_row = ( db.execute( text( """ SELECT district_name, zk_count, flat_count, median_price_per_m2, mean_price_per_m2 FROM ekb_districts WHERE district_name ILIKE :dn AND district_name <> 'не определён' LIMIT 1 """ ), {"dn": district_name.strip()}, ) .mappings() .first() ) if not district_row: return { "scope": {"district": district_name, "error": "district unknown"}, "buckets": [], "summary": { "total_revenue_rub": None, "weighted_avg_price_per_m2": None, "warnings": [f"Район '{district_name}' не найден в ekb_districts"], }, "comparables": [], } district_median = _f(district_row["median_price_per_m2"]) if district_median is None: warnings.append( f"В ekb_districts нет median_price_per_m2 для '{district_row['district_name']}'," " коэффициент района = 1.0" ) # 2) City-wide median baseline city_median = _f( db.execute( text( """ SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY median_price_per_m2) FROM ekb_districts WHERE median_price_per_m2 IS NOT NULL """ ) ).scalar() ) district_factor = ( district_median / city_median if (district_median and city_median and city_median > 0) else 1.0 ) # 3) Class multiplier через yandex_realty_zk + Comfort как BASELINE (×1.0). # Раньше делили class_avg/overall_avg где overall = смесь по 12 rows # → числа абсурдные (Elite ×1.22, Comfort+ ×0.66 < Comfort). # Теперь: ratio(class) = class_price_avg / comfort_price_avg. # Реалистичные индустриальные значения: Comfort=1.0, Comfort+=1.02, # Business=1.86, Elite=4.27 (на основе текущих 12 rows yandex_realty_zk). # yandex_realty_class_prices игнорируем — midpoint бессмыслен (ширина # диапазонов класса искажает result). # UI шлёт 'Comfort'/'Comfort+'/'Business'/'Elite' → realty_zk: 'COMFORT'/ # 'COMFORT_PLUS'/'BUSINESS'/'ELITE'. class_multiplier = 1.0 class_multiplier_source: str | None = None if target_class: zk_norm = { "Comfort": "COMFORT", "Comfort+": "COMFORT_PLUS", "Business": "BUSINESS", "Elite": "ELITE", }.get(target_class) if zk_norm: r = ( db.execute( text( """ SELECT AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg, AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg FROM yandex_realty_zk WHERE price_from IS NOT NULL AND price_from > 0 """ ), {"cls": zk_norm}, ) .mappings() .first() ) cavg = _f(r["class_avg"]) if r else None comfort_avg = _f(r["comfort_avg"]) if r else None if cavg and comfort_avg and comfort_avg > 0: class_multiplier = cavg / comfort_avg class_multiplier_source = "realty_zk_vs_comfort" else: warnings.append( f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'" " — коэффициент класса = 1.0" ) # 4) Bucket distribution from rosreestr_deals — city-wide, last N months. # Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории, # 24 мес даёт устойчивые per-bucket медианы для большинства региональных # ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес # (мах доступного окна), это последний rampe перед deferring к global. bucket_rows = _bucket_distribution(db, region_code, months_window) effective_window = months_window if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows): bucket_rows_27 = _bucket_distribution(db, region_code, 27) if bucket_rows_27: bucket_rows = bucket_rows_27 effective_window = 27 warnings.append( f"Окно расширено до 27 мес: при {months_window} мес хотя бы один" " бакет имел <30 сделок — оценка была бы шумной" ) total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1 # 5) Build buckets with adjusted prices + optional allocation buckets: list[dict[str, Any]] = [] weighted_num = 0.0 # Σ area_avg × share × price weighted_den = 0.0 # Σ area_avg × share total_revenue = 0.0 have_revenue = False for r in bucket_rows: bid = r["bucket"] deals = int(r["deals"] or 0) share = round(deals * 100 / total_deals, 1) area_avg = _f(r["area_avg"]) or 0.0 area_med = _f(r["area_median"]) or 0.0 p_med_city = _f(r["price_median"]) or 0.0 p25_city = _f(r["price_p25"]) or 0.0 p75_city = _f(r["price_p75"]) or 0.0 adj = district_factor * class_multiplier p_med = round(p_med_city * adj, 2) p25 = round(p25_city * adj, 2) p75 = round(p75_city * adj, 2) units_planned: int | None = None revenue_planned: float | None = None if area_total_m2 and area_avg > 0: allocated = area_total_m2 * (share / 100.0) units_planned = max(1, round(allocated / area_avg)) revenue_planned = round(units_planned * area_avg * p_med, 2) total_revenue += revenue_planned have_revenue = True weighted_num += area_avg * share * p_med weighted_den += area_avg * share if deals < 30: warnings.append( f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок" f" за {effective_window} мес — оценка с большой погрешностью" ) buckets.append( { "bucket": _BUCKET_PRETTY.get(bid, bid), "share_pct": share, "deal_count": deals, "area_avg_m2": round(area_avg, 1), "area_median_m2": round(area_med, 1), "price_median_per_m2": p_med, "price_p25_per_m2": p25, "price_p75_per_m2": p75, "units_planned": units_planned, "revenue_planned_rub": revenue_planned, } ) weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None # 5b) Velocity baseline (apartments/month per ЖК) + price elasticity. # Both are required for the live "цена↔темп" calculator on the frontend. # Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный # баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class — # игнорируем target_class фильтр на уровне velocity/elasticity/comparable # запросов, иначе obj_pool пустой и всё падает в fallback. has_class_data = bool( db.execute( text( """ SELECT 1 FROM domrf_kn_objects WHERE region_cd = :rc AND district_name = :dn AND obj_class IS NOT NULL LIMIT 1 """ ), {"rc": region_code, "dn": district_row["district_name"]}, ).scalar() ) target_class_for_geo = target_class if has_class_data else None if target_class and not has_class_data: warnings.append( f"obj_class не заполнен для ЖК района {district_row['district_name']}" f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable" " (но class_multiplier из yandex_realty_zk применяется к ценам)." ) vel = _velocity_baseline( db, region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, ) sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"] # velocity_source label: "objective" when data available, "rosreestr_fallback" otherwise. # Value key kept as "sale_graph" in output for frontend backward-compat (no breaking change). # After fix #574: per-bucket objective data (_velocity_baseline_per_bucket) is used even # when aggregate sale_graph_vel_pm is None. velocity_source reflects aggregate source; # per-bucket source is tracked in bucket["velocity_source"] added below. velocity_source = "objective" if sale_graph_vel_pm is not None else "rosreestr_fallback" elast = _elasticity_coef( db, region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, elasticity_window_months=elasticity_window_months, ) elasticity = elast["elasticity"] if elast["source"] == "fallback": warnings.append( f"Эластичность цена↔темп взята по умолчанию ({elasticity})" f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}" " (недостаточно для регрессии)." ) # Tier 3: per-bucket эластичность. Регрессия sale_graph по # «доминирующему bucket» каждого ЖК. Если для bucket'а данных мало — # подставляем общую elasticity. Малые сегменты (1-2 студии в районе) # таким образом не выкидываются — используем общую кривую. elast_per_bucket = _elasticity_per_bucket_coef( db, region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, fallback=elast, elasticity_window_months=elasticity_window_months, ) # 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n. # total_weighted используется как divisor в rosreestr-fallback. competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = ( _competitors_two_dim( db, region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, ) ) # Обратная совместимость: одномерный счётчик для warnings competitors = round(competitors_weighted) if competitors_scope == "fallback_singleton": warnings.append( f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}" f" ни в регионе {region_code} — нормировка отключена (как для монополиста)." ) elif competitors_scope not in ("district+class", "district_2d"): scope_label = { "district": f"районе {district_row['district_name']} (без класса)", "region": f"регионе {region_code} (вне района)", }.get(competitors_scope, competitors_scope) warnings.append( f"Конкурентов класса '{target_class or '*'}' в районе мало —" f" нормировка по {competitors} ЖК в {scope_label}." ) # 5b-2) Per-bucket market velocity (fix #574: per-bucket formula, realistic срок). # # BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем # bucket_v = market_vel_pm × share/100 → all buckets scale proportionally → # aggregate velocity ≡ market_vel_pm независимо от mix (slider static bug). # При competitors_district≈1 получаем 159 кв/мес (темп РЫНКА, не проекта). # # FIX: per-bucket velocity вычисляется независимо для каждого бакета: # objective path: _velocity_baseline_per_bucket → median per ЖК per room_bucket # rosreestr fallback: bucket_deals / months / n_comp (district+class competitors) # # Это позволяет mix-слайдерам реально менять aggregate KPI, т.к. velocities # студий, 1к и т.д. теперь независимые константы, не производные от share. # Objective path: per-bucket velocities из objective_corpus_room_month obj_per_bucket = _velocity_baseline_per_bucket( db, region_code=region_code, district_name=district_row["district_name"], target_class=target_class_for_geo, ) # n_comp — district+class конкуренты (_active_competitors_count каскад, # уже вычислено выше как competitors_weighted). Делим темп РЫНКА района/класса # на число активных ЖК этого района/класса → среднерыночный темп одного проекта. # НЕ region-wide (~442 ЖК) — это давало срок 379-1180 мес (нереалистично). n_comp = max(round(competitors_weighted), 1) # district+class competitors, NOT region-wide # Aggregate market_vel_pm (сохраняем для scope/output, не для bucket расчётов) if sale_graph_vel_pm is not None: market_vel_pm = sale_graph_vel_pm else: # Rosreestr fallback aggregate: district+class deals / months / n_comp = # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес). market_vel_pm = ( (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0 ) warnings.append( f"Нет objective-данных для района/класса — темп по rosreestr ÷ " f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)." ) # 5b-2.5) Per-bucket market velocity (fix #574). # # Objective path: используем per-bucket velocities из objective_corpus_room_month. # Для бакетов без данных в objective — fallback к rosreestr per-bucket. # Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors). # КРИТИЧНО: bucket_deal_counts — это MARKET deal counts (независимы от share_pct # пользователя), поэтому per-bucket velocities — независимые константы → # mix-слайдеры реально влияют на aggregate KPI (static-mix bug fix). bucket_deal_counts = {r["bucket"]: int(r["deals"] or 0) for r in bucket_rows} bucket_market_velocities: dict[str, float] = {} for b in buckets: bkey = b["bucket"] bkt_id = next((k for k, v in _BUCKET_PRETTY.items() if v == bkey), bkey) # Objective per-bucket (preferred): median units/month per ЖК в этом бакете if obj_per_bucket and bkt_id in obj_per_bucket: bucket_market_velocities[bkey] = obj_per_bucket[bkt_id] else: # Rosreestr fallback per-bucket: market bucket_deals / months / n_comp. # n_comp — district+class competitors (НЕ region-wide ~442), иначе срок # выходит 379-1180 мес. bucket_deal_counts — market (не user share). raw_deals = bucket_deal_counts.get(bkt_id, 0) bucket_market_velocities[bkey] = raw_deals / max(effective_window, 1) / n_comp # 5b-2.5) Дополнительные district-specific signals (Tier 2): # sat_factor — насколько зрелый рынок района (median sold% активных # ЖК). >50% = зрелый, новый проект имеет место, +bonus. # <20% = свежий, много инвентаря, -penalty. # trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы # экстремум не разрушал расчёты. # poi_factor — weighted POI density района / city avg. ±5% на цены. sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"]) sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0 trend_ratio, trend_recent, trend_prior = _district_velocity_trend( db, district_name=district_row["district_name"] ) trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0 poi_score = _district_poi_score(db, district_name=district_row["district_name"]) city_avg_poi = _city_avg_poi_score(db, region_code=region_code) # Cadastre cross-check: медианная кадастровая стоимость ₽/м² района через # cad_buildings → ekb_districts spatial-join. Аномалии (рынок vs кадастр) # выводятся как warning-цена в RecommendVelocityPanel. cadastre = _district_cadastre_baseline(db, district_name=district_row["district_name"]) poi_factor = ( 1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05 if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0) else 1.0 ) mortgage_rate, mortgage_period = _current_mortgage_rate(db) # #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"]) # #25 Success-driven ranking из v_bucket_success_score success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class) # 5b-3) Per-bucket project velocity at price_factor=1.0: # bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region. # После fix #574: каждый бакет имеет независимую скорость # (не производную от share) → mix-слайдеры реально меняют KPI. # project_velocity = bucket_market_v × sat_factor × trend_factor # sat — зрелый рынок ускоряет; trend — текущая # динамика (горит/остывает). # adjusted = project_velocity × price_factor^elasticity # months_to_sellout = units_planned / adjusted # Цены корректируются на poi_factor (развитость района = премиум) # и noise_penalty (шумное окружение = дисконт). pf_pow = price_factor**elasticity if price_factor > 0 else 1.0 macro_velocity_mult = sat_factor * trend_factor # Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт combined_price_factor = poi_factor * noise_penalty total_units = 0 for b in buckets: bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) bkt_id_for_src = next( (k for k, v in _BUCKET_PRETTY.items() if v == b["bucket"]), b["bucket"] ) b["velocity_source"] = ( "objective_per_bucket" if (obj_per_bucket and bkt_id_for_src in obj_per_bucket) else "rosreestr_fallback" ) bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4) b["velocity_per_month"] = bucket_velocity # Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty). be = elast_per_bucket.get(b["bucket"]) or {} bucket_elasticity = float(be.get("elasticity", elasticity)) bucket_pf_pow = price_factor**bucket_elasticity if price_factor > 0 else 1.0 b["elasticity"] = bucket_elasticity b["elasticity_r2"] = be.get("r2", 0.0) b["elasticity_n"] = be.get("n", 0) b["elasticity_source"] = be.get("source", "fallback_global") # POI-корректировка + noise penalty на цены (ВСЕ p25/median/p75) b["price_median_per_m2"] = round(b["price_median_per_m2"] * combined_price_factor, 2) b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * combined_price_factor, 2) b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * combined_price_factor, 2) b["is_top_success"] = False if b["units_planned"] and bucket_velocity > 0: # Revenue тоже пересчитываем после combined-correction (linear scale). if b["revenue_planned_rub"] is not None: b["revenue_planned_rub"] = round( b["revenue_planned_rub"] * combined_price_factor, 2 ) adjusted_velocity = bucket_velocity * bucket_pf_pow b["months_to_sellout"] = ( round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None ) total_units += b["units_planned"] else: b["months_to_sellout"] = None # Итог revenue + weighted_avg_price после POI-correction + noise penalty. if have_revenue: total_revenue *= combined_price_factor if weighted_avg_price is not None: weighted_avg_price = round(weighted_avg_price * combined_price_factor, 2) # #25 Success-driven mix: поднимаем долю top-success bucket'а на 10%, # пропорционально уменьшаем остальные. Условие: success_score > 0 AND n_deals >= 30. if success_ranking: top = next( (r for r in success_ranking if r["success_score"] > 0 and r["n_deals"] >= 30), None, ) if top: top_bucket_name = top["bucket"] # Найти bucket в списке по имени top_b = next((b for b in buckets if b["bucket"] == top_bucket_name), None) if top_b is not None: boost = top_b["share_pct"] * 0.10 # +10% top_b["share_pct"] = round(top_b["share_pct"] + boost, 1) top_b["is_top_success"] = True # Пропорционально уменьшаем остальные чтобы sum = 100 other_sum = sum(b["share_pct"] for b in buckets if b["bucket"] != top_bucket_name) if other_sum > 0: scale = (100.0 - top_b["share_pct"]) / other_sum for b in buckets: if b["bucket"] != top_bucket_name: b["share_pct"] = round(b["share_pct"] * scale, 1) # 5c) Inverse mode: target_months → required price_factor. # Tier 3: используем weighted-by-units эластичность (per-bucket эластичности # → агрегатная только когда нужна одна цифра). При smooth-buckets разница # с глобальной невелика, но если bucket-mix сильно перекошен в одну сторону — # weighted-эластичность точнее отражает поведение портфеля. required_price_factor: float | None = None weighted_elasticity = elasticity if total_units > 0: weighted_elasticity = ( sum( (b.get("elasticity") or elasticity) * (b.get("units_planned") or 0) for b in buckets ) / total_units ) if target_months and total_units > 0: base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets) if base_total_velocity > 0 and weighted_elasticity != 0: required_velocity = total_units / target_months ratio = required_velocity / base_total_velocity try: required_price_factor = round(ratio ** (1.0 / weighted_elasticity), 4) except Exception: required_price_factor = None if required_price_factor and required_price_factor < 0.7: warnings.append( f"Целевой срок {target_months} мес требует скидки" f" >{round((1 - required_price_factor) * 100)}% — рассмотри" " сдвиг ассортимента в сторону ликвидных бакетов." ) # 5d) Liquidity score (0-100): % units sold within 24 months. liquidity_24mo: float | None = None if total_units > 0: sold_24mo = 0.0 for b in buckets: mts = b["months_to_sellout"] up = b["units_planned"] or 0 if up <= 0 or mts is None or mts <= 0: continue frac = min(1.0, 24.0 / mts) sold_24mo += frac * up liquidity_24mo = round(sold_24mo / total_units * 100, 1) # 5e) Aggregate KPIs. Total months_to_sellout считаем как сумму # bucket-уровневых adjusted velocities (каждая со своим pf_pow по своей # эластичности) — иначе перекос в bucket-mix искажает агрегат. months_to_sellout_total: float | None = None base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets) adjusted_total_v = 0.0 for b in buckets: v = b.get("velocity_per_month") or 0 be = b.get("elasticity") bpf = price_factor**be if (be is not None and price_factor > 0) else pf_pow adjusted_total_v += v * bpf if total_units > 0 and adjusted_total_v > 0: months_to_sellout_total = round(total_units / adjusted_total_v, 1) avg_ticket = ( round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None ) # 6) Comparable ЖК — same district (parsed from addr) and class. # #22 Hard-cap по границам: фильтруем по ST_Within чтобы исключить ЖК # у границы района, формально в domrf по district_name, но реально за # пределами полигона (координаты из v_complex_full). ЖК без координат # (latitude/longitude NULL) — пропускаем через LEFT JOIN + фильтр. cmp_rows = ( db.execute( text( """ WITH latest_agg AS ( SELECT obj_id, MAX(snapshot_date) AS snap FROM domrf_kn_sales_agg WHERE type = 'apartments' GROUP BY obj_id ), vcf_dedup AS ( -- один ряд на canonical_name: берём с наибольшим cad_buildings_n SELECT DISTINCT ON (lower(canonical_name)) lower(canonical_name) AS name_key, cad_quarter, latitude, longitude, cad_buildings_n FROM v_complex_full ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST ), district_geom AS ( SELECT geom FROM ekb_districts WHERE district_name = :dn LIMIT 1 ), latest_obj AS ( -- domrf_kn_objects содержит ~3 snapshot'а на obj_id; -- берём только самый свежий, иначе comparables дублируются SELECT DISTINCT ON (obj_id) * FROM domrf_kn_objects WHERE region_cd = :rc AND district_name = :dn AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls) ORDER BY obj_id, snapshot_date DESC NULLS LAST ) SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count, a.perc AS sold_perc, c.cad_quarter, c.latitude AS lat, c.longitude AS lon, c.cad_buildings_n AS buildings_n FROM latest_obj o LEFT JOIN latest_agg la ON la.obj_id = o.obj_id LEFT JOIN domrf_kn_sales_agg a ON a.obj_id = la.obj_id AND a.snapshot_date = la.snap AND a.type = 'apartments' LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name) WHERE ( -- hard-cap по границам района: только если координаты известны И -- точка внутри полигона. Без координат — включаем (нет данных для отсева) c.latitude IS NULL OR c.longitude IS NULL OR ST_Within( ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326), (SELECT geom FROM district_geom) ) ) ORDER BY o.flat_count DESC NULLS LAST LIMIT 5 """ ), { "rc": region_code, "dn": district_row["district_name"], "cls": target_class_for_geo, }, ) .mappings() .all() ) # 7) Headline для CEO — одна строка с тремя главными цифрами headline_parts: list[str] = [] if have_revenue: headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽") if months_to_sellout_total: headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес") if avg_ticket: headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽") if base_total_v > 0: # Tempo = sum bucket-adjusted velocities (каждая со своим pf_pow по своей # эластичности). Это согласовано с months_to_sellout_total выше. tempo = adjusted_total_v if adjusted_total_v > 0 else base_total_v * pf_pow headline_parts.append( f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес" ) if liquidity_24mo is not None: headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100") headline = " · ".join(headline_parts) if headline_parts else None return { "scope": { "district": district_row["district_name"], "district_zk_count": district_row["zk_count"], "district_median_price_per_m2": district_median, "district_factor": round(district_factor, 4), "class_multiplier": round(class_multiplier, 4), "class_multiplier_source": class_multiplier_source, "target_class": target_class, "months_window": months_window, "effective_window_months": effective_window, "region_code": region_code, "total_deals": total_deals if bucket_rows else 0, "market_velocity_per_month": ( round(market_vel_pm, 3) if market_vel_pm is not None else None ), "velocity_source": velocity_source, # fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback "n_competitors": n_comp, "velocity_observations": vel["observations"], "velocity_objects": vel["objects_count"], "competitors_count": competitors, "competitors_scope": competitors_scope, "competitors_radius_n": competitors_radius_n, "competitors_district_only_n": competitors_district_only_n, "saturation_median": sat_median, "saturation_n": sat_n, "sat_factor": round(sat_factor, 4), "velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None), "trend_recent_units": trend_recent, "trend_prior_units": trend_prior, "trend_factor": round(trend_factor, 4), "poi_score": round(poi_score, 1) if poi_score is not None else None, "poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None), "poi_factor": round(poi_factor, 4), "mortgage_rate_pct": mortgage_rate, "mortgage_rate_period": mortgage_period, "elasticity": elasticity, "elasticity_r2": elast["r2"], "elasticity_n": elast["n"], "elasticity_source": elast["source"], "elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None), "elasticity_per_bucket": elast_per_bucket, # Окна источников данных — для прозрачности и UI-tooltip. # share_window_months — окно по rosreestr_deals для bucket-shares # и market velocity (input months_window, может расшириться до 27). # elasticity_window_months — синхронизировано с share_window (issue #24). "share_window_months": effective_window, "elasticity_window_months": elasticity_window_months, # Noise penalty (issue #22) "noise_penalty": noise_penalty, "noise_breakdown": noise_breakdown, # Success ranking (issue #25) "success_ranking": success_ranking, "cadastre_median_per_m2": ( round(cadastre["median_per_m2"], 0) if cadastre["median_per_m2"] is not None else None ), "cadastre_buildings_n": cadastre["buildings_n"], "cadastre_vs_market_pct": ( round( (district_median - cadastre["median_per_m2"]) / cadastre["median_per_m2"] * 100.0, 1, ) if (cadastre["median_per_m2"] and cadastre["median_per_m2"] > 0 and district_median) else None ), "price_factor_applied": round(price_factor, 4), "required_price_factor": required_price_factor, "target_months": target_months, "data_caveat": ( "MVP: bucket-распределение город-wide (регион 66). Район влияет" " только на ценовой коэффициент. v2 добавит per-district demand" " при заведении PostGIS-полигонов." ), }, "buckets": buckets, "summary": { "total_revenue_rub": round(total_revenue, 2) if have_revenue else None, "weighted_avg_price_per_m2": weighted_avg_price, "total_units_planned": total_units if total_units > 0 else None, "months_to_sellout_total": months_to_sellout_total, "avg_ticket_rub": avg_ticket, "liquidity_score_24mo": liquidity_24mo, "headline": headline, "warnings": warnings, }, "comparables": [ { "obj_id": r["obj_id"], "comm_name": r["comm_name"], "dev_name": r["dev_name"], "obj_class": r["obj_class"], "flat_count": r["flat_count"], "sold_perc": _f(r["sold_perc"]), "cad_quarter": r["cad_quarter"], "lat": _f(r["lat"]), "lon": _f(r["lon"]), "buildings_n": r["buildings_n"], } for r in cmp_rows ], } # ── Cadastral buildings per complex ────────────────────────────────────────── def complex_buildings(db: Session, obj_id: int) -> list[dict[str, Any]]: """Список зданий из cad_buildings для данного ЖК. Возвращает [] если ни одного здания не найдено. """ rows = ( db.execute( text( """ SELECT cad_num, floors, area, purpose, building_name, readable_address, ST_AsGeoJSON(geom) AS geom_geojson FROM cad_buildings WHERE complex_id = :obj_id ORDER BY cad_num """ ), {"obj_id": obj_id}, ) .mappings() .all() ) import json as _json result: list[dict[str, Any]] = [] for r in rows: geom_raw = r["geom_geojson"] geom: dict[str, Any] | None = None if geom_raw: try: geom = _json.loads(geom_raw) except (ValueError, TypeError): geom = None result.append( { "cad_num": r["cad_num"], "floors": r["floors"], "area": _f(r["area"]), "purpose": r["purpose"], "building_name": r["building_name"], "readable_address": r["readable_address"], "geom_geojson": geom, } ) return result