From c67c8138afe648a3d5edc6fe3eee8a30f6c935bb Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 00:02:54 +0300 Subject: [PATCH] feat(sf-b5): extend /analyze with EGRN + encumbrance + red_lines + risks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SF-B5: добавить 15 новых полей в POST /parcels/{cad_num}/analyze: - egrn: cadastral_value_rub/per_m2, land_category, permitted_use_text, last_egrn_update_date, area_m2, ownership_type, right_type, status, address - encumbrance: has_zouit, zouit_types, zouit_count (из cad_zouit) - red_lines: intersects, count (из cad_red_lines via ST_Intersects) - metro: nearest_top3 = null placeholder (заполнится после merge metro scraper) - district_price_per_m2_min/max/median/sample_size (из objective_lots по району) - risks: flood_zone (cad_risk_zones), noise_score, geology_risk_label (proxy) Не трогает competitors block (OBJ-3 + PR #328 уже работают). --- backend/app/api/v1/parcels.py | 221 ++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 9a315b14..b0720350 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1701,6 +1701,217 @@ def analyze_parcel( except Exception as e: logger.warning("parcel_meta query failed for %s: %s", cad_num, e) + # B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5) + egrn_block: dict[str, Any] = {} + try: + egrn_row = ( + db.execute( + text(""" + SELECT cost_value AS cadastral_value_rub, + cost_index AS cost_index_per_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS permitted_use_text, + cost_registration_date AS last_egrn_update_date, + land_record_area AS area_m2, + ownership_type, + right_type, + status, + readable_address, + registration_date + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + if egrn_row: + _cad_val = ( + float(egrn_row["cadastral_value_rub"]) + if egrn_row["cadastral_value_rub"] is not None + else None + ) + _area_m2 = float(egrn_row["area_m2"]) if egrn_row["area_m2"] is not None else None + _idx = egrn_row["cost_index_per_m2"] + _cad_per_m2: float | None = None + if _idx is not None: + _cad_per_m2 = float(_idx) + elif _cad_val is not None and _area_m2 and _area_m2 > 0: + _cad_per_m2 = round(_cad_val / _area_m2, 2) + egrn_block = { + "cadastral_value_rub": _cad_val, + "cadastral_value_per_m2": _cad_per_m2, + "land_category": egrn_row["land_category"], + "permitted_use_text": egrn_row["permitted_use_text"], + "last_egrn_update_date": ( + egrn_row["last_egrn_update_date"].isoformat() + if egrn_row["last_egrn_update_date"] is not None + else None + ), + "area_m2": _area_m2, + "ownership_type": egrn_row["ownership_type"], + "right_type": egrn_row["right_type"], + "parcel_status": egrn_row["status"], + "address": egrn_row["readable_address"], + "registration_date": ( + egrn_row["registration_date"].isoformat() + if egrn_row["registration_date"] is not None + else None + ), + } + except Exception as e: + logger.warning("egrn_block query failed for %s: %s", cad_num, e) + + # B5-2) Encumbrance block — ЗОУИТ из cad_zouit (SF-B5) + encumbrance_block: dict[str, Any] = { + "has_zouit": False, + "zouit_types": [], + "zouit_count": 0, + } + try: + zouit_rows = ( + db.execute( + text(""" + SELECT type_zone, name_by_doc + FROM cad_zouit + WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) + ORDER BY id + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ) + if zouit_rows: + _zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]}) + encumbrance_block = { + "has_zouit": True, + "zouit_types": _zouit_types, + "zouit_count": len(zouit_rows), + } + except Exception as e: + logger.warning("encumbrance_block query failed for %s: %s", cad_num, e) + + # B5-3) Red lines block — пересечение с cad_red_lines (SF-B5) + red_lines_block: dict[str, Any] = {"intersects": False, "count": 0} + try: + rl_row = ( + db.execute( + text(""" + SELECT COUNT(*) AS cnt + FROM cad_red_lines + WHERE ST_Intersects( + geom::geometry, + ST_GeomFromText(:wkt, 4326) + ) + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + if rl_row: + _rl_cnt = int(rl_row["cnt"]) + red_lines_block = { + "intersects": _rl_cnt > 0, + "count": _rl_cnt, + } + except Exception as e: + logger.warning("red_lines_block query failed for %s: %s", cad_num, e) + + # B5-4) Metro placeholder — заполнится после merge 22h metro scraper + metro_block: dict[str, Any] = {"nearest_top3": None} + + # B5-5) District price ranges из objective_lots (SF-B5) + district_price_block: dict[str, Any] = { + "district_price_per_m2_min": None, + "district_price_per_m2_max": None, + "district_price_per_m2_median": None, + "district_price_sample_size": None, + } + if district_row and district_row["district_name"]: + try: + dp_row = ( + db.execute( + text(""" + SELECT + MIN(price_per_m2_rub) AS price_min, + MAX(price_per_m2_rub) AS price_max, + PERCENTILE_CONT(0.5) WITHIN GROUP ( + ORDER BY price_per_m2_rub + ) AS price_median, + COUNT(*) AS sample_size + FROM objective_lots + WHERE district = CAST(:dn AS text) + AND price_per_m2_rub IS NOT NULL + AND price_per_m2_rub BETWEEN 30000 AND 600000 + """), + {"dn": district_row["district_name"]}, + ) + .mappings() + .first() + ) + if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0: + district_price_block = { + "district_price_per_m2_min": ( + round(float(dp_row["price_min"])) if dp_row["price_min"] else None + ), + "district_price_per_m2_max": ( + round(float(dp_row["price_max"])) if dp_row["price_max"] else None + ), + "district_price_per_m2_median": ( + round(float(dp_row["price_median"])) if dp_row["price_median"] else None + ), + "district_price_sample_size": int(dp_row["sample_size"]), + } + except Exception as e: + logger.warning("district_price_block query failed for %s: %s", cad_num, e) + + # B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5) + risks_block: dict[str, Any] = { + "flood_zone": False, + "noise_score": round(noise_score, 2), + "geology_risk_label": None, + } + try: + flood_row = ( + db.execute( + text(""" + SELECT COUNT(*) AS cnt + FROM cad_risk_zones + WHERE ST_Intersects( + geom::geometry, + ST_GeomFromText(:wkt, 4326) + ) + AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%' + OR risk_type ILIKE '%затоп%') + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + _flood = bool(flood_row and int(flood_row["cnt"]) > 0) + # Geology proxy через hydrology flood_risk_flag (уже посчитан выше) + _geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False + _has_flood = _flood or _geo_flood + # geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low + if _has_flood: + _geo_label: str | None = "high" + elif noise_db_max >= 65.0: + _geo_label = "medium" + else: + _geo_label = "low" + risks_block = { + "flood_zone": _has_flood, + "noise_score": round(noise_score, 2), + "geology_risk_label": _geo_label, + } + except Exception as e: + logger.warning("risks_block query failed for %s: %s", cad_num, e) + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None try: @@ -2299,6 +2510,16 @@ def analyze_parcel( }, # #254: custom POI scoring — user-defined points (via X-Session-Id header). "custom_poi_score_items": custom_poi_items, + # SF-B5: EGRN + encumbrance + red_lines + metro + district prices + risks + "egrn": egrn_block, + "encumbrance": encumbrance_block, + "red_lines": red_lines_block, + "metro": metro_block, + "district_price_per_m2_min": district_price_block["district_price_per_m2_min"], + "district_price_per_m2_max": district_price_block["district_price_per_m2_max"], + "district_price_per_m2_median": district_price_block["district_price_per_m2_median"], + "district_price_sample_size": district_price_block["district_price_sample_size"], + "risks": risks_block, }