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:
parent
a35a3d02ea
commit
ade511bff3
4 changed files with 558 additions and 2 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
385
frontend/src/components/site-finder/Pipeline24moBlock.tsx
Normal file
385
frontend/src/components/site-finder/Pipeline24moBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue