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

* 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).

* fix(site-finder): address PR #90 auto-review feedback

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.

* fix(site-finder): address PR #90 auto-review minor feedback

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.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-12 08:34:02 +03:00 committed by GitHub
parent 1f8fd77825
commit f4a865060f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 603 additions and 4 deletions

View file

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

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,394 @@
"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: "Не указан",
// 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;
}
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",
}}
>
{fmtMonth(obj.ready_dt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</>
)}
{data.note && (
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
{data.note}
</div>
)}
</div>
);
}

View file

@ -171,6 +171,39 @@ 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;
// 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 {
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;
@ -277,6 +310,8 @@ export interface ParcelAnalysis {
confidence_label?: "high" | "medium" | "low";
confidence_breakdown?: Record<string, number>;
confidence_caveats?: string[];
// D4 (#36) — 24-month project pipeline competition
pipeline_24mo?: Pipeline24mo;
}
export type PoiCategory =