feat(site-finder): D4 pipeline 24mo — future competition (#36)

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).
This commit is contained in:
lekss361 2026-05-12 01:00:46 +03:00
parent a35a3d02ea
commit ade511bff3
4 changed files with 558 additions and 2 deletions

View file

@ -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),

View file

@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* D4 (#36) — Pipeline 24mo */}
{data.pipeline_24mo && <Pipeline24moBlock data={data.pipeline_24mo} />}
{/* Market trend */}
{hasTrend && (
<div

View file

@ -0,0 +1,385 @@
"use client";
import { useState } from "react";
import type { Pipeline24mo } from "@/types/site-finder";
interface Props {
data: Pipeline24mo;
}
const SEVERITY_COLOR: Record<
Pipeline24mo["severity"],
{ bg: string; fg: string; border: string }
> = {
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<string, string> = {
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 (
<div
style={{
border: `1px solid ${c.border}`,
background: "#fff",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
title="Pipeline 24 месяца — ЖК, которые сдаются в окне 24мес в радиусе 5км"
>
Конкуренты на 24 мес (5 км)
</span>
<span
style={{
padding: "2px 10px",
borderRadius: 12,
fontSize: 12,
fontWeight: 600,
color: c.fg,
background: c.bg,
border: `1px solid ${c.border}`,
}}
>
{data.severity_label ?? data.severity} ·{" "}
{data.flats_total.toLocaleString("ru-RU")} квартир
</span>
</div>
{data.objects_count === 0 ? (
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км.
</div>
) : (
<>
{/* Summary numbers */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))",
gap: 10,
fontSize: 12,
color: "#374151",
}}
>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Объектов</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.objects_count}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>
Квартир суммарно
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.flats_total.toLocaleString("ru-RU")}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Горизонт</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.horizon_months ?? 24} мес · {data.radius_km ?? 5} км
</div>
</div>
</div>
{/* By-class breakdown */}
{Object.keys(data.by_class).length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
fontSize: 12,
}}
>
{Object.entries(data.by_class)
.sort(([, a], [, b]) => b - a)
.map(([cls, flats]) => (
<div
key={cls}
style={{
padding: "3px 10px",
borderRadius: 6,
background: "#f3f4f6",
color: "#374151",
}}
>
{fmtClass(cls)}:{" "}
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
{flats.toLocaleString("ru-RU")}
</strong>
</div>
))}
</div>
)}
{/* By-quarter pipeline bar */}
{data.by_quarter.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
marginBottom: 6,
}}
>
По кварталам сдачи
</div>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 4,
height: 80,
}}
>
{data.by_quarter.map((q) => {
const heightPct =
maxFlatsPerQuarter > 0
? Math.max(6, (q.flats / maxFlatsPerQuarter) * 100)
: 6;
return (
<div
key={q.quarter}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
}}
>
<div
style={{
width: "100%",
height: `${heightPct}%`,
background: c.fg,
opacity: 0.5,
borderRadius: "3px 3px 0 0",
}}
title={`${q.quarter}: ${q.objects} ЖК / ${q.flats} квартир`}
/>
<div
style={{
fontSize: 10,
color: "#6b7280",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{q.quarter}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Top objects toggle */}
{data.top_objects.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть список"
: `Топ-${data.top_objects.length} ЖК pipeline`}
</button>
{expanded && (
<div
style={{
marginTop: 10,
maxHeight: 240,
overflowY: "auto",
border: "1px solid #f3f4f6",
borderRadius: 6,
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead
style={{
background: "#f9fafb",
position: "sticky",
top: 0,
}}
>
<tr>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
ЖК
</th>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
Класс
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Квартир
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Сдача
</th>
</tr>
</thead>
<tbody>
{data.top_objects.map((obj) => (
<tr
key={obj.obj_id}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td style={{ padding: "6px 10px", color: "#374151" }}>
{obj.comm_name ?? obj.dev_name ?? "—"}
</td>
<td style={{ padding: "6px 10px", color: "#6b7280" }}>
{obj.obj_class ? fmtClass(obj.obj_class) : "—"}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
fontVariantNumeric: "tabular-nums",
color: "#374151",
}}
>
{obj.flat_count?.toLocaleString("ru-RU") ?? "—"}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{obj.ready_dt ? obj.ready_dt.substring(0, 7) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</>
)}
{data.note && (
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
{data.note}
</div>
)}
</div>
);
}

View file

@ -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<string, number>;
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 =