feat(22k): object page — full exposure 9 blocks (sales/quartirography/metro/photos/docs/specs/checks) #310
10 changed files with 1113 additions and 16 deletions
|
|
@ -160,6 +160,45 @@ def object_detail(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/object/{obj_id}/full")
|
||||
def object_full_detail(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
obj_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Extended object info — все 30+ новых полей (22begh: specs/accessibility/metro/scores)."""
|
||||
result = q.object_full_detail(db, obj_id=obj_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="object not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/object/{obj_id}/quartirography")
|
||||
def object_quartirography(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
obj_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Квартирография ЖК: группировка flats по комнатности с count/area/price."""
|
||||
return q.object_flats_quartirography(db, obj_id=obj_id)
|
||||
|
||||
|
||||
@router.get("/object/{obj_id}/checks")
|
||||
def object_checks(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
obj_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""6 проверок «Проверено на наш.дом.рф» (22f)."""
|
||||
return q.object_obj_checks(db, obj_id=obj_id)
|
||||
|
||||
|
||||
@router.get("/object/{obj_id}/documents")
|
||||
def object_documents(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
obj_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""PDF документы из domrf_kn_documents (22i): декларации, разрешения, отчётность."""
|
||||
return q.object_documents(db, obj_id=obj_id)
|
||||
|
||||
|
||||
@router.get("/object/{obj_id}/buildings", response_model=list[ComplexBuilding])
|
||||
def object_buildings(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
|
|||
|
|
@ -842,6 +842,251 @@ def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str,
|
|||
]
|
||||
|
||||
|
||||
def object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
|
||||
"""Extended object detail — adds all Wave A+B columns (22begh).
|
||||
|
||||
Returns the same base fields as object_detail() PLUS the 30 new columns
|
||||
from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns
|
||||
that haven't been scraped yet return NULL.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
|
||||
o.dev_id, o.dev_name, o.dev_group_name,
|
||||
o.floor_min, o.floor_max, o.flat_count,
|
||||
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
|
||||
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
|
||||
o.energy_eff, o.wall_type,
|
||||
-- Building specs (22e)
|
||||
o.first_floor_type, o.section_count,
|
||||
o.elevators_passenger_count, o.elevators_cargo_count,
|
||||
o.parking_total_slots, o.guest_parking_inside_count,
|
||||
o.guest_parking_outside_count, o.ceiling_height_m,
|
||||
-- Apartment summary (22e)
|
||||
o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2,
|
||||
-- Yard (22e)
|
||||
o.playground_kids_count, o.playground_sport_count,
|
||||
o.has_bike_paths, o.trash_areas_count,
|
||||
-- OVZ (22e)
|
||||
o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift,
|
||||
-- Catalog/UI (22e)
|
||||
o.flat_area_min, o.flat_area_max,
|
||||
o.price_min_rub, o.price_max_rub,
|
||||
o.price_per_m2_min, o.price_per_m2_max,
|
||||
o.parking_provision_pct, o.project_published_at,
|
||||
o.project_declaration_num,
|
||||
-- Metro & scores (22e/22h)
|
||||
o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3,
|
||||
o.domrf_score_location, o.domrf_score_transport,
|
||||
o.domrf_score_infrastructure,
|
||||
COALESCE(cb.buildings_count, 0) AS buildings_count
|
||||
FROM domrf_kn_objects o
|
||||
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
|
||||
WHERE o.obj_id = :obj
|
||||
ORDER BY o.snapshot_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"obj": obj_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
metro_top3 = row["metro_top3"]
|
||||
|
||||
return {
|
||||
"obj_id": row["obj_id"],
|
||||
"hobj_id": row["hobj_id"],
|
||||
"comm_name": row["comm_name"],
|
||||
"addr": row["addr"],
|
||||
"short_addr": row["short_addr"],
|
||||
"region_cd": row["region_cd"],
|
||||
"dev_id": row["dev_id"],
|
||||
"dev_name": row["dev_name"],
|
||||
"dev_group_name": row["dev_group_name"],
|
||||
"floor_min": row["floor_min"],
|
||||
"floor_max": row["floor_max"],
|
||||
"flat_count": row["flat_count"],
|
||||
"square_living": _f(row["square_living"]),
|
||||
"ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
|
||||
"site_status": row["site_status"],
|
||||
"escrow": row["escrow"],
|
||||
"obj_class": row["obj_class"],
|
||||
"latitude": _f(row["latitude"]),
|
||||
"longitude": _f(row["longitude"]),
|
||||
"obj_status": row["obj_status"],
|
||||
"snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
|
||||
"energy_eff": row["energy_eff"],
|
||||
"wall_type": row["wall_type"],
|
||||
# Building specs
|
||||
"first_floor_type": row["first_floor_type"],
|
||||
"section_count": row["section_count"],
|
||||
"elevators_passenger_count": row["elevators_passenger_count"],
|
||||
"elevators_cargo_count": row["elevators_cargo_count"],
|
||||
"parking_total_slots": row["parking_total_slots"],
|
||||
"guest_parking_inside_count": row["guest_parking_inside_count"],
|
||||
"guest_parking_outside_count": row["guest_parking_outside_count"],
|
||||
"ceiling_height_m": _f(row["ceiling_height_m"]),
|
||||
# Apartment summary
|
||||
"finishing_variants_count": row["finishing_variants_count"],
|
||||
"has_free_planning": row["has_free_planning"],
|
||||
"avg_flat_area_m2": _f(row["avg_flat_area_m2"]),
|
||||
# Yard
|
||||
"playground_kids_count": row["playground_kids_count"],
|
||||
"playground_sport_count": row["playground_sport_count"],
|
||||
"has_bike_paths": row["has_bike_paths"],
|
||||
"trash_areas_count": row["trash_areas_count"],
|
||||
# OVZ
|
||||
"has_ramp": row["has_ramp"],
|
||||
"has_low_platforms": row["has_low_platforms"],
|
||||
"has_wheelchair_lift": row["has_wheelchair_lift"],
|
||||
# Catalog/UI
|
||||
"flat_area_min": _f(row["flat_area_min"]),
|
||||
"flat_area_max": _f(row["flat_area_max"]),
|
||||
"price_min_rub": row["price_min_rub"],
|
||||
"price_max_rub": row["price_max_rub"],
|
||||
"price_per_m2_min": _f(row["price_per_m2_min"]),
|
||||
"price_per_m2_max": _f(row["price_per_m2_max"]),
|
||||
"parking_provision_pct": _f(row["parking_provision_pct"]),
|
||||
"project_published_at": (
|
||||
row["project_published_at"].isoformat() if row["project_published_at"] else None
|
||||
),
|
||||
"project_declaration_num": row["project_declaration_num"],
|
||||
# Metro & scores
|
||||
"metro_nearest_name": row["metro_nearest_name"],
|
||||
"metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"],
|
||||
"metro_top3": metro_top3, # already jsonb → dict/list from psycopg3
|
||||
"domrf_score_location": row["domrf_score_location"],
|
||||
"domrf_score_transport": row["domrf_score_transport"],
|
||||
"domrf_score_infrastructure": row["domrf_score_infrastructure"],
|
||||
"buildings_count": int(row["buildings_count"]),
|
||||
}
|
||||
|
||||
|
||||
def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||
"""Per-rooms aggregation из domrf_kn_flats для объекта.
|
||||
|
||||
Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL).
|
||||
Возвращает count total, count 'free', min/max area, min/max price.
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT MAX(snapshot_date) AS snap
|
||||
FROM domrf_kn_flats
|
||||
WHERE obj_id = :obj
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN f.rooms IS NULL OR LOWER(f.type) LIKE '%нежил%'
|
||||
OR LOWER(f.type) LIKE '%nonliv%' THEN 'Нежилые'
|
||||
WHEN f.rooms = 0 THEN 'Студия'
|
||||
WHEN f.rooms = 1 THEN '1-комн.'
|
||||
WHEN f.rooms = 2 THEN '2-комн.'
|
||||
WHEN f.rooms = 3 THEN '3-комн.'
|
||||
ELSE (f.rooms::text || '-комн.')
|
||||
END AS room_label,
|
||||
COALESCE(f.rooms, -1) AS sort_key,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
|
||||
OR LOWER(f.status) LIKE '%свобод%')
|
||||
AS free_count,
|
||||
MIN(f.total_area) AS area_min,
|
||||
MAX(f.total_area) AS area_max,
|
||||
MIN(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||||
AS price_min,
|
||||
MAX(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||||
AS price_max
|
||||
FROM domrf_kn_flats f
|
||||
CROSS JOIN latest l
|
||||
WHERE f.obj_id = :obj
|
||||
AND f.snapshot_date = l.snap
|
||||
GROUP BY room_label, sort_key
|
||||
ORDER BY sort_key
|
||||
"""
|
||||
),
|
||||
{"obj": obj_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"room_label": r["room_label"],
|
||||
"total_count": r["total_count"],
|
||||
"free_count": r["free_count"],
|
||||
"area_min": _f(r["area_min"]),
|
||||
"area_max": _f(r["area_max"]),
|
||||
"price_min": r["price_min"],
|
||||
"price_max": r["price_max"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||
"""6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f)."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT check_type, passed, checked_at
|
||||
FROM domrf_obj_checks
|
||||
WHERE obj_id = :obj
|
||||
ORDER BY check_type
|
||||
"""
|
||||
),
|
||||
{"obj": obj_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"check_type": r["check_type"],
|
||||
"passed": r["passed"],
|
||||
"checked_at": r["checked_at"].isoformat() if r["checked_at"] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||
"""PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT doc_type, doc_num, posted_at, file_url, size_bytes
|
||||
FROM domrf_kn_documents
|
||||
WHERE obj_id = :obj
|
||||
ORDER BY doc_type, posted_at DESC NULLS LAST
|
||||
"""
|
||||
),
|
||||
{"obj": obj_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"doc_type": r["doc_type"],
|
||||
"doc_num": r["doc_num"],
|
||||
"posted_at": r["posted_at"].isoformat() if r["posted_at"] else None,
|
||||
"file_url": r["file_url"],
|
||||
"size_bytes": r["size_bytes"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
|
||||
"""Воронка по месяцам из materialized view."""
|
||||
rows = (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||
import { LazyMount } from "@/components/analytics/LazyMount";
|
||||
import { ObjectChecksBadges } from "@/components/analytics/ObjectChecksBadges";
|
||||
import { ObjectDocumentsList } from "@/components/analytics/ObjectDocumentsList";
|
||||
import { ObjectMetroList } from "@/components/analytics/ObjectMetroList";
|
||||
import { ObjectQuartirographyTable } from "@/components/analytics/ObjectQuartirographyTable";
|
||||
import { ObjectSpecsTable } from "@/components/analytics/ObjectSpecsTable";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
import {
|
||||
useObjectBuildings,
|
||||
useObjectDetail,
|
||||
useObjectChecks,
|
||||
useObjectDocuments,
|
||||
useObjectFullDetail,
|
||||
useObjectQuartirography,
|
||||
useObjectSalesAgg,
|
||||
} from "@/lib/analytics-api";
|
||||
|
||||
|
|
@ -69,15 +77,18 @@ export default function ObjectDrillInPage() {
|
|||
const params = useParams<{ id: string }>();
|
||||
const objId = params.id;
|
||||
|
||||
const detail = useObjectDetail(objId);
|
||||
const fullDetail = useObjectFullDetail(objId);
|
||||
const agg = useObjectSalesAgg(objId);
|
||||
const buildings = useObjectBuildings(objId);
|
||||
const quartirography = useObjectQuartirography(objId);
|
||||
const checks = useObjectChecks(objId);
|
||||
const documents = useObjectDocuments(objId);
|
||||
|
||||
const apartments = agg.data?.find((r) => r.type === "apartments");
|
||||
const parking = agg.data?.find((r) => r.type === "parking");
|
||||
const nonliv = agg.data?.find((r) => r.type === "nonliv");
|
||||
|
||||
const d = detail.data;
|
||||
const d = fullDetail.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -109,9 +120,10 @@ export default function ObjectDrillInPage() {
|
|||
</h1>
|
||||
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
|
||||
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
|
||||
{d?.escrow ? " · ✅ эскроу" : ""}
|
||||
{d?.escrow ? " · эскроу" : ""}
|
||||
</p>
|
||||
|
||||
{/* ── KPI row — распроданность ────────────────────────────────────────── */}
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<KpiCard
|
||||
label="Квартир"
|
||||
|
|
@ -142,15 +154,6 @@ export default function ObjectDrillInPage() {
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Машино-места"
|
||||
value={parking?.total?.toString() ?? "—"}
|
||||
hint={
|
||||
parking?.perc != null
|
||||
? `${parking.realised} продано (${parking.perc}%)`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Нежилые"
|
||||
value={nonliv?.total?.toString() ?? "—"}
|
||||
|
|
@ -160,6 +163,15 @@ export default function ObjectDrillInPage() {
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Машино-места"
|
||||
value={parking?.total?.toString() ?? "—"}
|
||||
hint={
|
||||
parking?.perc != null
|
||||
? `${parking.realised} продано (${parking.perc}%)`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Этажность"
|
||||
value={d?.floor_max?.toString() ?? "—"}
|
||||
|
|
@ -170,8 +182,58 @@ export default function ObjectDrillInPage() {
|
|||
value={d?.ready_dt?.slice(0, 7) ?? "—"}
|
||||
hint={d?.obj_class ?? undefined}
|
||||
/>
|
||||
{d?.price_min_rub != null ? (
|
||||
<KpiCard
|
||||
label="Цена от"
|
||||
value={(d.price_min_rub / 1_000_000).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
unit="млн ₽"
|
||||
/>
|
||||
) : null}
|
||||
{d?.price_max_rub != null ? (
|
||||
<KpiCard
|
||||
label="Цена до"
|
||||
value={(d.price_max_rub / 1_000_000).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
unit="млн ₽"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── DOM.РФ scores ───────────────────────────────────────────────────── */}
|
||||
{(d?.domrf_score_location != null ||
|
||||
d?.domrf_score_transport != null ||
|
||||
d?.domrf_score_infrastructure != null) && (
|
||||
<div
|
||||
style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 12 }}
|
||||
>
|
||||
{d?.domrf_score_location != null && (
|
||||
<KpiCard
|
||||
label="DOM.РФ: Расположение"
|
||||
value={String(d.domrf_score_location)}
|
||||
unit="/ 10"
|
||||
/>
|
||||
)}
|
||||
{d?.domrf_score_transport != null && (
|
||||
<KpiCard
|
||||
label="DOM.РФ: Транспорт"
|
||||
value={String(d.domrf_score_transport)}
|
||||
unit="/ 10"
|
||||
/>
|
||||
)}
|
||||
{d?.domrf_score_infrastructure != null && (
|
||||
<KpiCard
|
||||
label="DOM.РФ: Инфраструктура"
|
||||
value={String(d.domrf_score_infrastructure)}
|
||||
unit="/ 10"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 1. Динамика продаж ──────────────────────────────────────────────── */}
|
||||
<Section
|
||||
title="Динамика продаж"
|
||||
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
|
||||
|
|
@ -181,6 +243,34 @@ export default function ObjectDrillInPage() {
|
|||
</LazyMount>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. Квартирография ──────────────────────────────────────────────── */}
|
||||
<Section
|
||||
title="Квартирография"
|
||||
subtitle="Распределение по комнатности: count, площадь, цена. Из domrf_kn_flats (последний снимок)."
|
||||
>
|
||||
{quartirography.isLoading ? (
|
||||
<div style={{ height: 80, background: "#f3f4f6", borderRadius: 6 }} />
|
||||
) : (
|
||||
<ObjectQuartirographyTable rows={quartirography.data ?? []} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 3. Ближайшее метро ──────────────────────────────────────────────── */}
|
||||
{(d?.metro_nearest_name != null ||
|
||||
(d?.metro_top3 != null && d.metro_top3.length > 0)) && (
|
||||
<Section
|
||||
title="Метро"
|
||||
subtitle="Ближайшие станции метро из kn-API. Время — пешком."
|
||||
>
|
||||
<ObjectMetroList
|
||||
nearest_name={d.metro_nearest_name}
|
||||
nearest_walk_minutes={d.metro_nearest_walk_minutes}
|
||||
top3={d.metro_top3}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 4. Инфраструктура (POI) ─────────────────────────────────────────── */}
|
||||
<Section
|
||||
title="Инфраструктура (POI вокруг ЖК)"
|
||||
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
|
||||
|
|
@ -194,6 +284,7 @@ export default function ObjectDrillInPage() {
|
|||
</LazyMount>
|
||||
</Section>
|
||||
|
||||
{/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
|
||||
<Section
|
||||
title="Фото прогресса строительства"
|
||||
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
|
||||
|
|
@ -203,7 +294,37 @@ export default function ObjectDrillInPage() {
|
|||
</LazyMount>
|
||||
</Section>
|
||||
|
||||
{/* Корпуса ЖК — показываем только если есть данные */}
|
||||
{/* ── 6. Проверено на наш.дом.рф ─────────────────────────────────────── */}
|
||||
{!checks.isLoading && (checks.data?.length ?? 0) > 0 && (
|
||||
<Section
|
||||
title="Проверено на наш.дом.рф"
|
||||
subtitle="6 проверок безопасности от DOM.РФ."
|
||||
>
|
||||
<ObjectChecksBadges checks={checks.data ?? []} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 7. Документы ────────────────────────────────────────────────────── */}
|
||||
{!documents.isLoading && (documents.data?.length ?? 0) > 0 && (
|
||||
<Section
|
||||
title="Документы"
|
||||
subtitle="PDF-документы (декларации, разрешения, отчётность) из domrf_kn_documents."
|
||||
>
|
||||
<ObjectDocumentsList docs={documents.data ?? []} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 8. Все характеристики ───────────────────────────────────────────── */}
|
||||
{d && (
|
||||
<Section
|
||||
title="Все характеристики"
|
||||
subtitle="Полный набор полей из domrf_kn_objects (22e+22g): конструктив, дворовая инфраструктура, ОВЗ."
|
||||
>
|
||||
<ObjectSpecsTable d={d} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 9. Корпуса ЖК ───────────────────────────────────────────────────── */}
|
||||
{(d?.buildings_count ?? 0) > 0 &&
|
||||
buildings.data &&
|
||||
buildings.data.length > 0 && (
|
||||
|
|
@ -290,7 +411,6 @@ export default function ObjectDrillInPage() {
|
|||
</Section>
|
||||
)}
|
||||
|
||||
{/* Placeholder when buildings_count > 0 but data not yet loaded */}
|
||||
{(d?.buildings_count ?? 0) > 0 &&
|
||||
buildings.data &&
|
||||
buildings.data.length === 0 && (
|
||||
|
|
|
|||
45
frontend/src/components/analytics/ObjectChecksBadges.tsx
Normal file
45
frontend/src/components/analytics/ObjectChecksBadges.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ObjectCheckRow } from "@/types/analytics";
|
||||
|
||||
const CHECK_LABELS: Record<string, string> = {
|
||||
no_problems: "Нет проблемных объектов",
|
||||
docs: "Документы опубликованы",
|
||||
timing: "Сроки соблюдены",
|
||||
photos: "Актуальные фото",
|
||||
bankruptcy: "Не банкрот",
|
||||
declaration: "Декларация обновлена",
|
||||
};
|
||||
|
||||
export function ObjectChecksBadges({ checks }: { checks: ObjectCheckRow[] }) {
|
||||
if (checks.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||
Данные проверок ещё не загружены.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{checks.map((c) => (
|
||||
<div
|
||||
key={c.check_type}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 20,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background: c.passed ? "#dcfce7" : "#fee2e2",
|
||||
color: c.passed ? "#065f46" : "#991b1b",
|
||||
border: `1px solid ${c.passed ? "#bbf7d0" : "#fecaca"}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 15 }}>{c.passed ? "✓" : "✗"}</span>
|
||||
{CHECK_LABELS[c.check_type] ?? c.check_type}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/analytics/ObjectDocumentsList.tsx
Normal file
106
frontend/src/components/analytics/ObjectDocumentsList.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { ObjectDocumentRow } from "@/types/analytics";
|
||||
|
||||
function safeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number | null): string {
|
||||
if (bytes === null) return "";
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
const DOC_TYPE_ORDER: Record<string, number> = {
|
||||
декларация: 1,
|
||||
разрешение: 2,
|
||||
проектная: 3,
|
||||
отчётность: 4,
|
||||
прочее: 5,
|
||||
};
|
||||
|
||||
export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) {
|
||||
if (docs.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>Документы отсутствуют.</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by doc_type
|
||||
const byType = new Map<string, ObjectDocumentRow[]>();
|
||||
for (const d of docs) {
|
||||
const group = byType.get(d.doc_type) ?? [];
|
||||
group.push(d);
|
||||
byType.set(d.doc_type, group);
|
||||
}
|
||||
|
||||
const sortedTypes = [...byType.keys()].sort((a, b) => {
|
||||
const oa = DOC_TYPE_ORDER[a.toLowerCase()] ?? 99;
|
||||
const ob = DOC_TYPE_ORDER[b.toLowerCase()] ?? 99;
|
||||
return oa - ob || a.localeCompare(b, "ru");
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{sortedTypes.map((type) => {
|
||||
const group = byType.get(type)!;
|
||||
return (
|
||||
<div key={type}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
color: "#5b6066",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{group.map((doc, i) => (
|
||||
<div
|
||||
key={`${doc.doc_type}-${doc.doc_num ?? i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, lineHeight: 1 }}>📄</span>
|
||||
<a
|
||||
href={safeUrl(doc.file_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "#1e40af", textDecoration: "none" }}
|
||||
>
|
||||
{doc.doc_num ? `${doc.doc_num}` : `Документ ${i + 1}`}
|
||||
</a>
|
||||
{doc.posted_at ? (
|
||||
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||||
{doc.posted_at.slice(0, 10)}
|
||||
</span>
|
||||
) : null}
|
||||
{doc.size_bytes ? (
|
||||
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||||
{fmtSize(doc.size_bytes)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/analytics/ObjectMetroList.tsx
Normal file
85
frontend/src/components/analytics/ObjectMetroList.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { MetroEntry } from "@/types/analytics";
|
||||
|
||||
interface Props {
|
||||
nearest_name: string | null;
|
||||
nearest_walk_minutes: number | null;
|
||||
top3: MetroEntry[] | null;
|
||||
}
|
||||
|
||||
export function ObjectMetroList({
|
||||
nearest_name,
|
||||
nearest_walk_minutes,
|
||||
top3,
|
||||
}: Props) {
|
||||
// Build display list: prefer top3 array, fallback to nearest fields
|
||||
const entries: MetroEntry[] =
|
||||
top3 && top3.length > 0
|
||||
? top3
|
||||
: nearest_name
|
||||
? [
|
||||
{
|
||||
name: nearest_name,
|
||||
time: nearest_walk_minutes,
|
||||
line: null,
|
||||
color: null,
|
||||
isWalk: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||
Данные о метро отсутствуют.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{entries.map((m, i) => (
|
||||
<div
|
||||
key={`${m.name}-${i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 14px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e6e8ec",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
background: m.color ?? "#6b7280",
|
||||
flexShrink: 0,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{m.name}</div>
|
||||
{m.line ? (
|
||||
<div style={{ color: "#5b6066", fontSize: 12 }}>{m.line}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{m.time != null ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#5b6066",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.isWalk !== false ? "🚶" : "🚗"} {m.time} мин
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/analytics/ObjectQuartirographyTable.tsx
Normal file
115
frontend/src/components/analytics/ObjectQuartirographyTable.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { ObjectQuartirographyRow } from "@/types/analytics";
|
||||
|
||||
function fmtPrice(v: number | null): string {
|
||||
if (v === null) return "—";
|
||||
return (
|
||||
(v / 1_000_000).toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}) + " млн"
|
||||
);
|
||||
}
|
||||
|
||||
function fmtArea(v: number | null): string {
|
||||
if (v === null) return "—";
|
||||
return v.toLocaleString("ru-RU", { maximumFractionDigits: 1 }) + " м²";
|
||||
}
|
||||
|
||||
export function ObjectQuartirographyTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: ObjectQuartirographyRow[];
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||
Данные квартирографии отсутствуют.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const headers = ["Тип", "Всего", "В продаже", "Площадь", "Цена (от–до)"];
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: "#f9fafb" }}>
|
||||
{headers.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
borderBottom: "1px solid #e6e8ec",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.room_label} style={{ borderTop: "1px solid #f3f4f6" }}>
|
||||
<td style={{ padding: "8px 10px", fontWeight: 500 }}>
|
||||
{r.room_label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{r.total_count.toLocaleString("ru-RU")}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{r.free_count > 0 ? (
|
||||
<span style={{ color: "#0a7a3a", fontWeight: 500 }}>
|
||||
{r.free_count.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "#9ca3af" }}>0</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
color: "#5b6066",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{r.area_min !== null || r.area_max !== null
|
||||
? `${fmtArea(r.area_min)} – ${fmtArea(r.area_max)}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{r.price_min !== null || r.price_max !== null
|
||||
? `${fmtPrice(r.price_min)} – ${fmtPrice(r.price_max)}`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/analytics/ObjectSpecsTable.tsx
Normal file
204
frontend/src/components/analytics/ObjectSpecsTable.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { ObjectFullDetail } from "@/types/analytics";
|
||||
|
||||
interface SpecRow {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
function yn(v: boolean | null | undefined): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return v ? "Да" : "Нет";
|
||||
}
|
||||
|
||||
function num(v: number | null | undefined, suffix = ""): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return `${v.toLocaleString("ru-RU")}${suffix}`;
|
||||
}
|
||||
|
||||
function numFmt(
|
||||
v: number | null | undefined,
|
||||
decimals = 0,
|
||||
suffix = "",
|
||||
): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return (
|
||||
v.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}) + suffix
|
||||
);
|
||||
}
|
||||
|
||||
function price(v: number | null | undefined): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return (
|
||||
(v / 1_000_000).toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}) + " млн ₽"
|
||||
);
|
||||
}
|
||||
|
||||
function priceM2(v: number | null | undefined): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽/м²";
|
||||
}
|
||||
|
||||
export function ObjectSpecsTable({ d }: { d: ObjectFullDetail }) {
|
||||
const specs: SpecRow[] = [
|
||||
// Identity
|
||||
{ label: "Класс объекта", value: d.obj_class },
|
||||
{ label: "Класс энергоэффективности", value: d.energy_eff },
|
||||
{ label: "Материал стен", value: d.wall_type },
|
||||
{ label: "Первый этаж", value: d.first_floor_type },
|
||||
// Building
|
||||
{ label: "Подъездов", value: num(d.section_count) },
|
||||
{ label: "Этажей (max)", value: num(d.floor_max) },
|
||||
{ label: "Высота потолков", value: numFmt(d.ceiling_height_m, 2, " м") },
|
||||
{
|
||||
label: "Лифты пассажирские",
|
||||
value:
|
||||
d.elevators_passenger_count === 0
|
||||
? "Нет"
|
||||
: num(d.elevators_passenger_count),
|
||||
},
|
||||
{
|
||||
label: "Лифты грузовые",
|
||||
value:
|
||||
d.elevators_cargo_count === 0 ? "Нет" : num(d.elevators_cargo_count),
|
||||
},
|
||||
// Parking
|
||||
{
|
||||
label: "Машино-мест (парковка)",
|
||||
value: num(d.parking_total_slots),
|
||||
},
|
||||
{
|
||||
label: "Гостевые (приказ.)",
|
||||
value: num(d.guest_parking_inside_count),
|
||||
},
|
||||
{
|
||||
label: "Гостевые (улица)",
|
||||
value: num(d.guest_parking_outside_count),
|
||||
},
|
||||
{
|
||||
label: "Обеспеченность парковкой",
|
||||
value: numFmt(d.parking_provision_pct, 1, "%"),
|
||||
},
|
||||
// Apartments
|
||||
{ label: "Квартир всего", value: num(d.flat_count) },
|
||||
{
|
||||
label: "Площадь квартир",
|
||||
value:
|
||||
d.flat_area_min != null && d.flat_area_max != null
|
||||
? `${numFmt(d.flat_area_min, 1)} – ${numFmt(d.flat_area_max, 1)} м²`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Средняя площадь квартиры",
|
||||
value: numFmt(d.avg_flat_area_m2, 1, " м²"),
|
||||
},
|
||||
{
|
||||
label: "Вариантов отделки",
|
||||
value: num(d.finishing_variants_count),
|
||||
},
|
||||
{ label: "Свободная планировка", value: yn(d.has_free_planning) },
|
||||
// Price
|
||||
{
|
||||
label: "Цена (от)",
|
||||
value: price(d.price_min_rub),
|
||||
},
|
||||
{
|
||||
label: "Цена (до)",
|
||||
value: price(d.price_max_rub),
|
||||
},
|
||||
{
|
||||
label: "Цена/м² (от)",
|
||||
value: priceM2(d.price_per_m2_min),
|
||||
},
|
||||
{
|
||||
label: "Цена/м² (до)",
|
||||
value: priceM2(d.price_per_m2_max),
|
||||
},
|
||||
// Yard
|
||||
{
|
||||
label: "Детских площадок",
|
||||
value: num(d.playground_kids_count),
|
||||
},
|
||||
{
|
||||
label: "Спортивных площадок",
|
||||
value: num(d.playground_sport_count),
|
||||
},
|
||||
{ label: "Велодорожки", value: yn(d.has_bike_paths) },
|
||||
{
|
||||
label: "Мусорные площадки",
|
||||
value: num(d.trash_areas_count),
|
||||
},
|
||||
// OVZ
|
||||
{ label: "Пандус", value: yn(d.has_ramp) },
|
||||
{ label: "Понижающие платформы (ОВЗ)", value: yn(d.has_low_platforms) },
|
||||
{
|
||||
label: "Инвалидный подъёмник",
|
||||
value: yn(d.has_wheelchair_lift),
|
||||
},
|
||||
// Developer
|
||||
{ label: "Девелопер", value: d.dev_name },
|
||||
{ label: "Группа компаний", value: d.dev_group_name },
|
||||
{ label: "Эскроу", value: yn(d.escrow) },
|
||||
{
|
||||
label: "Декларация №",
|
||||
value: d.project_declaration_num,
|
||||
},
|
||||
{
|
||||
label: "Дата публикации проекта",
|
||||
value: d.project_published_at?.slice(0, 10) ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = specs.filter((s) => s.value !== null);
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||
Характеристики ещё не загружены.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.label} style={{ borderTop: "1px solid #f3f4f6" }}>
|
||||
<td
|
||||
style={{
|
||||
padding: "7px 12px 7px 0",
|
||||
color: "#5b6066",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "top",
|
||||
width: "50%",
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "7px 0",
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{s.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,9 +11,13 @@ import type {
|
|||
DistrictRow,
|
||||
MarketPulsePoint,
|
||||
ObjectBuilding,
|
||||
ObjectCheckRow,
|
||||
ObjectDetail,
|
||||
ObjectDocumentRow,
|
||||
ObjectFullDetail,
|
||||
ObjectInfraPoi,
|
||||
ObjectPhoto,
|
||||
ObjectQuartirographyRow,
|
||||
ObjectSaleGraphPoint,
|
||||
ObjectSalesAggRow,
|
||||
PipelineRow,
|
||||
|
|
@ -219,6 +223,42 @@ export function useObjectBuildings(objId: number | string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useObjectFullDetail(objId: number | string) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics", "object-full", objId],
|
||||
queryFn: () => apiFetch<ObjectFullDetail>(`${BASE}/object/${objId}/full`),
|
||||
enabled: !!objId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useObjectQuartirography(objId: number | string) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics", "object-quartirography", objId],
|
||||
queryFn: () =>
|
||||
apiFetch<ObjectQuartirographyRow[]>(
|
||||
`${BASE}/object/${objId}/quartirography`,
|
||||
),
|
||||
enabled: !!objId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useObjectChecks(objId: number | string) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics", "object-checks", objId],
|
||||
queryFn: () => apiFetch<ObjectCheckRow[]>(`${BASE}/object/${objId}/checks`),
|
||||
enabled: !!objId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useObjectDocuments(objId: number | string) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics", "object-documents", objId],
|
||||
queryFn: () =>
|
||||
apiFetch<ObjectDocumentRow[]>(`${BASE}/object/${objId}/documents`),
|
||||
enabled: !!objId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
||||
|
||||
export function useRecommendMix() {
|
||||
|
|
|
|||
|
|
@ -191,6 +191,104 @@ export interface ObjectInfraPoi {
|
|||
distance_m: number | null;
|
||||
}
|
||||
|
||||
export interface MetroEntry {
|
||||
name: string;
|
||||
time: number | null;
|
||||
line: string | null;
|
||||
color: string | null;
|
||||
isWalk: boolean | null;
|
||||
}
|
||||
|
||||
export interface ObjectFullDetail {
|
||||
obj_id: number;
|
||||
hobj_id: number | null;
|
||||
comm_name: string | null;
|
||||
addr: string | null;
|
||||
short_addr: string | null;
|
||||
region_cd: number | null;
|
||||
dev_id: string | null;
|
||||
dev_name: string | null;
|
||||
dev_group_name: string | null;
|
||||
floor_min: number | null;
|
||||
floor_max: number | null;
|
||||
flat_count: number | null;
|
||||
square_living: number | null;
|
||||
ready_dt: string | null;
|
||||
site_status: string | null;
|
||||
escrow: boolean | null;
|
||||
obj_class: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
obj_status: number | null;
|
||||
snapshot_date: string | null;
|
||||
energy_eff: string | null;
|
||||
wall_type: string | null;
|
||||
// Building specs
|
||||
first_floor_type: string | null;
|
||||
section_count: number | null;
|
||||
elevators_passenger_count: number | null;
|
||||
elevators_cargo_count: number | null;
|
||||
parking_total_slots: number | null;
|
||||
guest_parking_inside_count: number | null;
|
||||
guest_parking_outside_count: number | null;
|
||||
ceiling_height_m: number | null;
|
||||
// Apartment summary
|
||||
finishing_variants_count: number | null;
|
||||
has_free_planning: boolean | null;
|
||||
avg_flat_area_m2: number | null;
|
||||
// Yard
|
||||
playground_kids_count: number | null;
|
||||
playground_sport_count: number | null;
|
||||
has_bike_paths: boolean | null;
|
||||
trash_areas_count: number | null;
|
||||
// OVZ
|
||||
has_ramp: boolean | null;
|
||||
has_low_platforms: boolean | null;
|
||||
has_wheelchair_lift: boolean | null;
|
||||
// Catalog/UI
|
||||
flat_area_min: number | null;
|
||||
flat_area_max: number | null;
|
||||
price_min_rub: number | null;
|
||||
price_max_rub: number | null;
|
||||
price_per_m2_min: number | null;
|
||||
price_per_m2_max: number | null;
|
||||
parking_provision_pct: number | null;
|
||||
project_published_at: string | null;
|
||||
project_declaration_num: string | null;
|
||||
// Metro & scores
|
||||
metro_nearest_name: string | null;
|
||||
metro_nearest_walk_minutes: number | null;
|
||||
metro_top3: MetroEntry[] | null;
|
||||
domrf_score_location: number | null;
|
||||
domrf_score_transport: number | null;
|
||||
domrf_score_infrastructure: number | null;
|
||||
buildings_count: number;
|
||||
}
|
||||
|
||||
export interface ObjectQuartirographyRow {
|
||||
room_label: string;
|
||||
total_count: number;
|
||||
free_count: number;
|
||||
area_min: number | null;
|
||||
area_max: number | null;
|
||||
price_min: number | null;
|
||||
price_max: number | null;
|
||||
}
|
||||
|
||||
export interface ObjectCheckRow {
|
||||
check_type: string;
|
||||
passed: boolean;
|
||||
checked_at: string | null;
|
||||
}
|
||||
|
||||
export interface ObjectDocumentRow {
|
||||
doc_type: string;
|
||||
doc_num: string | null;
|
||||
posted_at: string | null;
|
||||
file_url: string;
|
||||
size_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface ObjectPhoto {
|
||||
obj_file_id: string;
|
||||
ord_num: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue