diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index adb42328..e3c72620 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -341,6 +341,123 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +# D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга +# и аудита; severity пороги матчатся с acceptance #36. +PIPELINE_RADIUS_M = 5000 +PIPELINE_HORIZON_MONTHS = 24 +PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low +PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high +PIPELINE_TOP_OBJECTS_LIMIT = 10 + + +def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: + """D4 (#36) — собрать pipeline_24mo aggregate из rows domrf_kn_objects. + + Метрики: + - objects_count, flats_total + - by_class: {economy: int, comfort: int, business: int, unknown: int} + - by_quarter: {"2026-Q1": {objects: N, flats: M}, ...} + - severity: low / medium / high (см. PIPELINE_SEVERITY_* пороги) + - top_objects: PIPELINE_TOP_OBJECTS_LIMIT крупнейших ЖК по flat_count + + NB: `obj_class` в production часто NULL (см. + `fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN`) — by_class dominated by + "unknown" пока не закрыт D6/#38. + + Используется для UI pipeline-bar и severity badge. + """ + if not rows: + return { + "objects_count": 0, + "flats_total": 0, + "by_class": {}, + "by_quarter": [], + "severity": "none", + "top_objects": [], + "note": ( + f"Нет ЖК в pipeline {PIPELINE_HORIZON_MONTHS}мес в радиусе " + f"{PIPELINE_RADIUS_M // 1000}км — низкая будущая конкуренция" + ), + } + + by_class: dict[str, int] = {} + by_quarter: dict[str, dict[str, int]] = {} + flats_total = 0 + + for r in rows: + cls = (r["obj_class"] or "unknown").lower().strip() or "unknown" + flats = int(r["flat_count"]) if r["flat_count"] else 0 + flats_total += flats + by_class[cls] = by_class.get(cls, 0) + flats + + ready = r["ready_dt"] + if ready: + q = (ready.month - 1) // 3 + 1 + key = f"{ready.year}-Q{q}" + slot = by_quarter.setdefault(key, {"objects": 0, "flats": 0}) + slot["objects"] += 1 + slot["flats"] += flats + + # Severity (#36 acceptance) + if flats_total < PIPELINE_SEVERITY_MEDIUM_THRESHOLD: + severity = "low" + elif flats_total < PIPELINE_SEVERITY_HIGH_THRESHOLD: + severity = "medium" + else: + severity = "high" + + severity_label = { + "low": "низкая", + "medium": "средняя", + "high": "высокая", + }[severity] + + # Sort quarters chronologically + quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())] + + # Top objects — по flat_count desc. + # Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает + # внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.) + # в API response. Не security issue, но schema-leak. + top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[ + :PIPELINE_TOP_OBJECTS_LIMIT + ] + top_objects: list[dict[str, Any]] = [] + for r in top_rows: + ready_dt = r.get("ready_dt") + distance_m = r.get("distance_m") + top_objects.append( + { + "obj_id": r["obj_id"], + "comm_name": r.get("comm_name"), + "dev_name": r.get("dev_name"), + "obj_class": r.get("obj_class"), + "flat_count": r.get("flat_count"), + # ISO date string для JSON; distance_m — explicit None guard + # (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе + # упадёт в JSON serialization). + "ready_dt": ready_dt.isoformat() if ready_dt else None, + "distance_m": round(float(distance_m)) if distance_m is not None else None, + } + ) + + return { + "objects_count": len(rows), + "flats_total": flats_total, + "by_class": by_class, + "by_quarter": quarters_sorted, + "severity": severity, + "severity_label": severity_label, + "top_objects": top_objects, + "radius_km": PIPELINE_RADIUS_M // 1000, + "horizon_months": PIPELINE_HORIZON_MONTHS, + "note": ( + "Будущая конкуренция за покупателя: planned_commissioning от Росреестра " + "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный." + ), + } + + # P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие # для ЖК; будут править — храним в одном месте) _GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум) @@ -1045,6 +1162,51 @@ def analyze_parcel( .all() ) + # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес + # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class + # + по кварталам сдачи. Константы — см. PIPELINE_* выше. + # NB: full seq scan на ~3000 строк OK; при росте — нужен GIST/index на + # (latitude, longitude) — отдельный issue для database-expert. + pipeline_rows = ( + db.execute( + text(""" + WITH latest_obj AS ( + SELECT DISTINCT ON (obj_id) * + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + AND ready_dt IS NOT NULL + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ) + SELECT obj_id, + comm_name, + dev_name, + obj_class, + flat_count, + ready_dt, + ST_Distance( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + :radius_m + ) + AND ready_dt >= CURRENT_DATE + AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) + ORDER BY ready_dt ASC + """), + { + "wkt": geom_wkt, + "radius_m": PIPELINE_RADIUS_M, + "horizon_months": str(PIPELINE_HORIZON_MONTHS), + }, + ) + .mappings() + .all() + ) + # 6) Centroid координаты для внешних API (air quality / wind) centroid_row = ( db.execute( @@ -1468,9 +1630,6 @@ def analyze_parcel( # Convention: оба top-list'а отсортированы "dominant first": # positives → most-positive first (factors_sorted desc → [:3]) # negatives → most-negative first (sort negatives asc → [:3]) - # Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же - # результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc - # короче и не зависит от тонкостей slicing. score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] negatives_only = [f for f in factors_sorted if f["contribution"] < 0] score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3] @@ -1505,6 +1664,9 @@ def analyze_parcel( zoning=zoning, ) + # D4 (#36): aggregate pipeline_24mo + pipeline_24mo = _aggregate_pipeline(pipeline_rows) + return { "cad_num": cad_num, "source": source, @@ -1532,6 +1694,8 @@ def analyze_parcel( "note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0", }, "competitors": [dict(c) for c in competitor_rows], + # D4 (#36): 24-month pipeline competition + "pipeline_24mo": pipeline_24mo, "noise": { "score": round(noise_score, 2), "estimated_db": round(noise_db_max, 1), diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index 727bfcec..b7215d3f 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -3,6 +3,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { MarketTrendBlock } from "./MarketTrendBlock"; import { CompetitorTable } from "./CompetitorTable"; +import { Pipeline24moBlock } from "./Pipeline24moBlock"; import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock"; interface Props { @@ -12,10 +13,15 @@ interface Props { export function MarketTab({ data }: Props) { const hasTrend = "market_trend" in data; const hasRecommendation = "success_recommendation" in data; - const hasAny = hasTrend || hasRecommendation || data.competitors.length > 0; + const hasPipeline = data.pipeline_24mo !== undefined; + const hasAny = + hasTrend || hasRecommendation || hasPipeline || data.competitors.length > 0; return (
| + ЖК + | ++ Класс + | ++ Квартир + | ++ Сдача + | +
|---|---|---|---|
| + {obj.comm_name ?? obj.dev_name ?? "—"} + | ++ {obj.obj_class ? fmtClass(obj.obj_class) : "—"} + | ++ {obj.flat_count?.toLocaleString("ru-RU") ?? "—"} + | ++ {fmtMonth(obj.ready_dt)} + | +