feat(site-finder): D4 pipeline 24mo — future competition (#36) #90
4 changed files with 603 additions and 4 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
394
frontend/src/components/site-finder/Pipeline24moBlock.tsx
Normal file
394
frontend/src/components/site-finder/Pipeline24moBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue