From ade511bff3efc3e7ea968da3d9394ce8b58d29b6 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 01:00:46 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(site-finder):=20D4=20pipeline=2024mo?= =?UTF-8?q?=20=E2=80=94=20future=20competition=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (parcels.py): - Запрос к domrf_kn_objects в радиусе 5км с ready_dt BETWEEN NOW() AND NOW()+24mo. - _aggregate_pipeline() — сводка: objects_count, flats_total, by_class (эконом/ комфорт/бизнес/...), by_quarter (хронологически, для UI bar), severity (low <500 / medium <3000 / high) per spec, top_objects (десятка по flat_count desc). - Поле analyze.pipeline_24mo. Backward-compat — optional. Frontend: - Pipeline24moBlock.tsx — severity badge + 3 summary numbers (объектов, квартир, горизонт/радиус), by-class chips, гистограмма bar по кварталам сдачи (нормирована на max), разворачиваемый top-N список с классом + датой сдачи. - Добавлен в MarketTab выше "Market trend". - TS типы: Pipeline24mo, PipelineObject, PipelineQuarterSlot. Closes #36. Relates to #19 (Конкурентный 360 — закрыт ранее в #36 scope). --- backend/app/api/v1/parcels.py | 135 +++++- .../src/components/site-finder/MarketTab.tsx | 8 +- .../site-finder/Pipeline24moBlock.tsx | 385 ++++++++++++++++++ frontend/src/types/site-finder.ts | 32 ++ 4 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/site-finder/Pipeline24moBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 173e1af8..047f10e0 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None: "period": "1995-2024 (30 лет)", "model": "MRI-AGCM3-2-S", "source": "open-meteo-climate", - "note": ("Климатические нормали. " "Текущая погода — отдельный API."), + "note": ("Климатические нормали. Текущая погода — отдельный API."), } except Exception as e: logger.warning("seasonal weather fetch failed: %s", e) @@ -274,6 +274,95 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +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 (по flats_total) + - top_objects: первые 10 ближайших / крупнейших ЖК + + Используется для UI pipeline-bar и severity badge. + """ + if not rows: + return { + "objects_count": 0, + "flats_total": 0, + "by_class": {}, + "by_quarter": [], + "severity": "none", + "top_objects": [], + "note": "Нет ЖК в pipeline 24мес в радиусе 5км — низкая будущая конкуренция", + } + + 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 — порог 500 / 3000): + if flats_total < 500: + severity = "low" + elif flats_total < 3000: + 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 + top_objects = sorted( + [dict(r) for r in rows], + key=lambda r: r.get("flat_count") or 0, + reverse=True, + )[:10] + + # Serialize date for JSON + for obj in top_objects: + if obj.get("ready_dt"): + obj["ready_dt"] = obj["ready_dt"].isoformat() + if obj.get("distance_m"): + obj["distance_m"] = round(float(obj["distance_m"])) + + 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": 5, + "horizon_months": 24, + "note": ( + "Будущая конкуренция за покупателя: planned_commissioning от Росреестра " + "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный." + ), + } + + def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]: """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость. @@ -516,6 +605,45 @@ def analyze_parcel( .all() ) + # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес + # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class + # + по кварталам сдачи. + 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, + 5000 + ) + AND ready_dt >= CURRENT_DATE + AND ready_dt < CURRENT_DATE + INTERVAL '24 months' + ORDER BY ready_dt ASC + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ) + # 6) Centroid координаты для внешних API (air quality / wind) centroid_row = ( db.execute( @@ -906,6 +1034,9 @@ def analyze_parcel( score_final = score + center_bonus + # D4 (#36): aggregate pipeline_24mo + pipeline_24mo = _aggregate_pipeline(pipeline_rows) + return { "cad_num": cad_num, "source": source, @@ -928,6 +1059,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 (
+ {/* D4 (#36) — Pipeline 24mo */} + {data.pipeline_24mo && } + {/* Market trend */} {hasTrend && (
= { + none: { bg: "#f3f4f6", fg: "#6b7280", border: "#e5e7eb" }, + low: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, + medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, + high: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, +}; + +const CLASS_LABEL: Record = { + economy: "Эконом", + econom: "Эконом", + comfort: "Комфорт", + business: "Бизнес", + premium: "Премиум", + elite: "Элит", + standart: "Стандарт", + standard: "Стандарт", + unknown: "Не указан", + null: "Не указан", +}; + +function fmtClass(cls: string): string { + return CLASS_LABEL[cls.toLowerCase()] ?? cls; +} + +export function Pipeline24moBlock({ data }: Props) { + const [expanded, setExpanded] = useState(false); + const c = SEVERITY_COLOR[data.severity]; + + // Max flats per quarter — для нормализации высоты bar'а + const maxFlatsPerQuarter = data.by_quarter.reduce( + (m, q) => (q.flats > m ? q.flats : m), + 0, + ); + + return ( +
+
+ + Конкуренты на 24 мес (5 км) + + + {data.severity_label ?? data.severity} ·{" "} + {data.flats_total.toLocaleString("ru-RU")} квартир + +
+ + {data.objects_count === 0 ? ( +
+ Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км. +
+ ) : ( + <> + {/* Summary numbers */} +
+
+
Объектов
+
+ {data.objects_count} +
+
+
+
+ Квартир суммарно +
+
+ {data.flats_total.toLocaleString("ru-RU")} +
+
+
+
Горизонт
+
+ {data.horizon_months ?? 24} мес · {data.radius_km ?? 5} км +
+
+
+ + {/* By-class breakdown */} + {Object.keys(data.by_class).length > 0 && ( +
+ {Object.entries(data.by_class) + .sort(([, a], [, b]) => b - a) + .map(([cls, flats]) => ( +
+ {fmtClass(cls)}:{" "} + + {flats.toLocaleString("ru-RU")} + +
+ ))} +
+ )} + + {/* By-quarter pipeline bar */} + {data.by_quarter.length > 0 && ( +
+
+ По кварталам сдачи +
+
+ {data.by_quarter.map((q) => { + const heightPct = + maxFlatsPerQuarter > 0 + ? Math.max(6, (q.flats / maxFlatsPerQuarter) * 100) + : 6; + return ( +
+
+
+ {q.quarter} +
+
+ ); + })} +
+
+ )} + + {/* Top objects toggle */} + {data.top_objects.length > 0 && ( +
+ + {expanded && ( +
+ + + + + + + + + + + {data.top_objects.map((obj) => ( + + + + + + + ))} + +
+ ЖК + + Класс + + Квартир + + Сдача +
+ {obj.comm_name ?? obj.dev_name ?? "—"} + + {obj.obj_class ? fmtClass(obj.obj_class) : "—"} + + {obj.flat_count?.toLocaleString("ru-RU") ?? "—"} + + {obj.ready_dt ? obj.ready_dt.substring(0, 7) : "—"} +
+
+ )} +
+ )} + + )} + + {data.note && ( +
+ {data.note} +
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 4fe5dee8..daa4df15 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -137,6 +137,36 @@ export interface MarketTrend { radius_km: number; } +// D4 (#36) — 24-month project pipeline competition +export interface PipelineQuarterSlot { + quarter: string; + objects: number; + flats: number; +} + +export interface PipelineObject { + obj_id: number; + comm_name: string | null; + dev_name: string | null; + obj_class: string | null; + flat_count: number | null; + ready_dt: string | null; + distance_m: number; +} + +export interface Pipeline24mo { + objects_count: number; + flats_total: number; + by_class: Record; + by_quarter: PipelineQuarterSlot[]; + severity: "none" | "low" | "medium" | "high"; + severity_label?: string; + top_objects: PipelineObject[]; + radius_km?: number; + horizon_months?: number; + note?: string; +} + export interface ParcelLocation { distance_to_center_km: number; center_bonus: number; @@ -184,6 +214,8 @@ export interface ParcelAnalysis { score_without_center?: number; location?: ParcelLocation; success_recommendation?: ParcelSuccessRecommendation | null; + // D4 (#36) — 24-month project pipeline competition + pipeline_24mo?: Pipeline24mo; } export type PoiCategory = -- 2.45.3 From e452a1d75ef4926a66699acd3a3c6c1fc6960c42 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 01:18:36 +0300 Subject: [PATCH 2/3] fix(site-finder): address PR #90 auto-review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix (3): 1. distance_m falsy guard: `if obj.get("distance_m") is not None` вместо `if obj.get("distance_m")` — centroid-on-building даёт 0.0 (falsy float), raw Decimal иначе упал бы в JSON serialization. 2. SQL plan note добавлен про seq scan ~3000 строк OK; при росте — нужен GIST/index на (latitude, longitude) — отдельный issue для database-expert (будет создан separately). 3. obj_class NULL bug помечен в docstring _aggregate_pipeline с reference на fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN. D6/#38 — fix плановый. Cleanup (3 из 5): 4. CLASS_LABEL.null dead key убран — JSON null приходит as absent key, не "null" string. 6. Magic numbers вынесены: PIPELINE_RADIUS_M=5000, PIPELINE_HORIZON_MONTHS=24, PIPELINE_SEVERITY_MEDIUM_THRESHOLD=500, PIPELINE_SEVERITY_HIGH_THRESHOLD=3000, PIPELINE_TOP_OBJECTS_LIMIT=10. SQL query теперь через f-string подставляет их (защищённое от injection — это int литералы). 8. obj.ready_dt formatting через fmtMonth() с new Date + toLocaleDateString — robust к datetime suffix vs date-only, fallback к substring(0,7) при NaN. Не сделано (defer): 5. Async 3 HTTP calls (pre-existing pattern, нужен ThreadPoolExecutor refactor отдельным PR — затрагивает weather/air_quality fetch architecture). 7. ST_GeomFromText дважды — CSE справляется на этом масштабе. Per auto-review on ade511b. --- backend/app/api/v1/parcels.py | 50 +++++++++++++------ .../site-finder/Pipeline24moBlock.tsx | 13 ++++- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 047f10e0..311d1ad7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -274,6 +274,15 @@ 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. @@ -281,8 +290,12 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: - 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 (по flats_total) - - top_objects: первые 10 ближайших / крупнейших ЖК + - 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. """ @@ -294,7 +307,10 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: "by_quarter": [], "severity": "none", "top_objects": [], - "note": "Нет ЖК в pipeline 24мес в радиусе 5км — низкая будущая конкуренция", + "note": ( + f"Нет ЖК в pipeline {PIPELINE_HORIZON_MONTHS}мес в радиусе " + f"{PIPELINE_RADIUS_M // 1000}км — низкая будущая конкуренция" + ), } by_class: dict[str, int] = {} @@ -315,10 +331,10 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: slot["objects"] += 1 slot["flats"] += flats - # Severity (#36 acceptance — порог 500 / 3000): - if flats_total < 500: + # Severity (#36 acceptance) + if flats_total < PIPELINE_SEVERITY_MEDIUM_THRESHOLD: severity = "low" - elif flats_total < 3000: + elif flats_total < PIPELINE_SEVERITY_HIGH_THRESHOLD: severity = "medium" else: severity = "high" @@ -337,13 +353,15 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: [dict(r) for r in rows], key=lambda r: r.get("flat_count") or 0, reverse=True, - )[:10] + )[:PIPELINE_TOP_OBJECTS_LIMIT] - # Serialize date for JSON + # Serialize date for JSON; distance_m — explicit None check, не falsy, + # потому что centroid-on-building даёт 0.0 (falsy) и raw Decimal упал бы + # в JSON serialization. for obj in top_objects: if obj.get("ready_dt"): obj["ready_dt"] = obj["ready_dt"].isoformat() - if obj.get("distance_m"): + if obj.get("distance_m") is not None: obj["distance_m"] = round(float(obj["distance_m"])) return { @@ -354,8 +372,8 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: "severity": severity, "severity_label": severity_label, "top_objects": top_objects, - "radius_km": 5, - "horizon_months": 24, + "radius_km": PIPELINE_RADIUS_M // 1000, + "horizon_months": PIPELINE_HORIZON_MONTHS, "note": ( "Будущая конкуренция за покупателя: planned_commissioning от Росреестра " "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный." @@ -607,10 +625,12 @@ def analyze_parcel( # 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(""" + text(f""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) * FROM domrf_kn_objects @@ -632,10 +652,10 @@ def analyze_parcel( WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - 5000 + {PIPELINE_RADIUS_M} ) AND ready_dt >= CURRENT_DATE - AND ready_dt < CURRENT_DATE + INTERVAL '24 months' + AND ready_dt < CURRENT_DATE + INTERVAL '{PIPELINE_HORIZON_MONTHS} months' ORDER BY ready_dt ASC """), {"wkt": geom_wkt}, diff --git a/frontend/src/components/site-finder/Pipeline24moBlock.tsx b/frontend/src/components/site-finder/Pipeline24moBlock.tsx index ca5a87ad..70d60717 100644 --- a/frontend/src/components/site-finder/Pipeline24moBlock.tsx +++ b/frontend/src/components/site-finder/Pipeline24moBlock.tsx @@ -28,9 +28,18 @@ const CLASS_LABEL: Record = { standart: "Стандарт", standard: "Стандарт", unknown: "Не указан", - null: "Не указан", + // NB: JSON null приходит как absent key, не "null" string — отдельный + // обработчик не нужен. }; +function fmtMonth(iso: string | null): string { + if (!iso) return "—"; + // Прочнее чем substring(0,7) — игнорирует timezone и datetime suffix + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso.substring(0, 7); + return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" }); +} + function fmtClass(cls: string): string { return CLASS_LABEL[cls.toLowerCase()] ?? cls; } @@ -362,7 +371,7 @@ export function Pipeline24moBlock({ data }: Props) { fontVariantNumeric: "tabular-nums", }} > - {obj.ready_dt ? obj.ready_dt.substring(0, 7) : "—"} + {fmtMonth(obj.ready_dt)} ))} -- 2.45.3 From 8f804d15dd93779f3f6fd2d8f7d8b1ac08a6ef32 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 08:31:39 +0300 Subject: [PATCH 3/3] fix(site-finder): address PR #90 auto-review minor feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TS PipelineObject.distance_m — `number | null` для отражения defensive Python guard (`if obj.get("distance_m") is not None`). Comment объясняет почему. 2. Pipeline SQL: `text(f"...")` → `text("...")` + parameters. radius_m и horizon_months через `:param` placeholders + `cast(:horizon_months || ' months' AS interval)`. Consistency с остальными SQL в файле, plus защита от accidental injection при будущих изменениях. 3. top_objects: explicit field selection вместо `dict(r) for r in rows`. Раньше leak'ило все колонки из CTE `SELECT *` (latitude/longitude/ snapshot_date/region_cd/dev_id) в API response. Теперь только nominated fields: obj_id, comm_name, dev_name, obj_class, flat_count, ready_dt, distance_m. Schema clean. Per auto-review on 4e431bf. --- backend/app/api/v1/parcels.py | 52 ++++++++++++++++++++----------- frontend/src/types/site-finder.ts | 5 ++- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 05ac0fec..e3c72620 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -415,21 +415,31 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: # Sort quarters chronologically quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())] - # Top objects — по flat_count desc - top_objects = sorted( - [dict(r) for r in rows], - key=lambda r: r.get("flat_count") or 0, - reverse=True, - )[:PIPELINE_TOP_OBJECTS_LIMIT] - - # Serialize date for JSON; distance_m — explicit None check, не falsy, - # потому что centroid-on-building даёт 0.0 (falsy) и raw Decimal упал бы - # в JSON serialization. - for obj in top_objects: - if obj.get("ready_dt"): - obj["ready_dt"] = obj["ready_dt"].isoformat() - if obj.get("distance_m") is not None: - obj["distance_m"] = round(float(obj["distance_m"])) + # 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), @@ -1159,7 +1169,7 @@ def analyze_parcel( # (latitude, longitude) — отдельный issue для database-expert. pipeline_rows = ( db.execute( - text(f""" + text(""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) * FROM domrf_kn_objects @@ -1181,13 +1191,17 @@ def analyze_parcel( WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - {PIPELINE_RADIUS_M} + :radius_m ) AND ready_dt >= CURRENT_DATE - AND ready_dt < CURRENT_DATE + INTERVAL '{PIPELINE_HORIZON_MONTHS} months' + AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) ORDER BY ready_dt ASC """), - {"wkt": geom_wkt}, + { + "wkt": geom_wkt, + "radius_m": PIPELINE_RADIUS_M, + "horizon_months": str(PIPELINE_HORIZON_MONTHS), + }, ) .mappings() .all() diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index b2fafee8..e4b28fa9 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -185,7 +185,10 @@ export interface PipelineObject { obj_class: string | null; flat_count: number | null; ready_dt: string | null; - distance_m: number; + // backend имеет explicit `is not None` guard для distance_m (centroid-on- + // building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так + // что nullable отражает defensive Python path. + distance_m: number | null; } export interface Pipeline24mo { -- 2.45.3