diff --git a/backend/app/api/v1/analytics.py b/backend/app/api/v1/analytics.py
index 61028b56..5568364f 100644
--- a/backend/app/api/v1/analytics.py
+++ b/backend/app/api/v1/analytics.py
@@ -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)],
diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py
index 0b3855df..2ea75ba5 100644
--- a/backend/app/services/analytics_queries.py
+++ b/backend/app/services/analytics_queries.py
@@ -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 = (
diff --git a/frontend/src/app/analytics/objects/[id]/page.tsx b/frontend/src/app/analytics/objects/[id]/page.tsx
index 3e9c8869..e57fdee5 100644
--- a/frontend/src/app/analytics/objects/[id]/page.tsx
+++ b/frontend/src/app/analytics/objects/[id]/page.tsx
@@ -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() {
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
- {d?.escrow ? " · ✅ эскроу" : ""}
+ {d?.escrow ? " · эскроу" : ""}
+ {/* ── KPI row — распроданность ────────────────────────────────────────── */}
-
+
+ {d?.price_min_rub != null ? (
+
+ ) : null}
+ {d?.price_max_rub != null ? (
+
+ ) : null}
+ {/* ── DOM.РФ scores ───────────────────────────────────────────────────── */}
+ {(d?.domrf_score_location != null ||
+ d?.domrf_score_transport != null ||
+ d?.domrf_score_infrastructure != null) && (
+
+ {d?.domrf_score_location != null && (
+
+ )}
+ {d?.domrf_score_transport != null && (
+
+ )}
+ {d?.domrf_score_infrastructure != null && (
+
+ )}
+
+ )}
+
+ {/* ── 1. Динамика продаж ──────────────────────────────────────────────── */}
+ {/* ── 2. Квартирография ──────────────────────────────────────────────── */}
+
+ {quartirography.isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {/* ── 3. Ближайшее метро ──────────────────────────────────────────────── */}
+ {(d?.metro_nearest_name != null ||
+ (d?.metro_top3 != null && d.metro_top3.length > 0)) && (
+
+ )}
+
+ {/* ── 4. Инфраструктура (POI) ─────────────────────────────────────────── */}
+ {/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
- {/* Корпуса ЖК — показываем только если есть данные */}
+ {/* ── 6. Проверено на наш.дом.рф ─────────────────────────────────────── */}
+ {!checks.isLoading && (checks.data?.length ?? 0) > 0 && (
+
+ )}
+
+ {/* ── 7. Документы ────────────────────────────────────────────────────── */}
+ {!documents.isLoading && (documents.data?.length ?? 0) > 0 && (
+
+ )}
+
+ {/* ── 8. Все характеристики ───────────────────────────────────────────── */}
+ {d && (
+
+ )}
+
+ {/* ── 9. Корпуса ЖК ───────────────────────────────────────────────────── */}
{(d?.buildings_count ?? 0) > 0 &&
buildings.data &&
buildings.data.length > 0 && (
@@ -290,7 +411,6 @@ export default function ObjectDrillInPage() {
)}
- {/* Placeholder when buildings_count > 0 but data not yet loaded */}
{(d?.buildings_count ?? 0) > 0 &&
buildings.data &&
buildings.data.length === 0 && (
diff --git a/frontend/src/components/analytics/ObjectChecksBadges.tsx b/frontend/src/components/analytics/ObjectChecksBadges.tsx
new file mode 100644
index 00000000..f565ca8c
--- /dev/null
+++ b/frontend/src/components/analytics/ObjectChecksBadges.tsx
@@ -0,0 +1,45 @@
+import type { ObjectCheckRow } from "@/types/analytics";
+
+const CHECK_LABELS: Record = {
+ no_problems: "Нет проблемных объектов",
+ docs: "Документы опубликованы",
+ timing: "Сроки соблюдены",
+ photos: "Актуальные фото",
+ bankruptcy: "Не банкрот",
+ declaration: "Декларация обновлена",
+};
+
+export function ObjectChecksBadges({ checks }: { checks: ObjectCheckRow[] }) {
+ if (checks.length === 0) {
+ return (
+
+ Данные проверок ещё не загружены.
+
+ );
+ }
+
+ return (
+
+ {checks.map((c) => (
+
+ {c.passed ? "✓" : "✗"}
+ {CHECK_LABELS[c.check_type] ?? c.check_type}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/analytics/ObjectDocumentsList.tsx b/frontend/src/components/analytics/ObjectDocumentsList.tsx
new file mode 100644
index 00000000..b7817558
--- /dev/null
+++ b/frontend/src/components/analytics/ObjectDocumentsList.tsx
@@ -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 = {
+ декларация: 1,
+ разрешение: 2,
+ проектная: 3,
+ отчётность: 4,
+ прочее: 5,
+};
+
+export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) {
+ if (docs.length === 0) {
+ return (
+ Документы отсутствуют.
+ );
+ }
+
+ // Group by doc_type
+ const byType = new Map();
+ 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 (
+
+ {sortedTypes.map((type) => {
+ const group = byType.get(type)!;
+ return (
+
+
+ {type}
+
+
+ {group.map((doc, i) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/analytics/ObjectMetroList.tsx b/frontend/src/components/analytics/ObjectMetroList.tsx
new file mode 100644
index 00000000..63ba85cb
--- /dev/null
+++ b/frontend/src/components/analytics/ObjectMetroList.tsx
@@ -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 (
+
+ Данные о метро отсутствуют.
+
+ );
+ }
+
+ return (
+
+ {entries.map((m, i) => (
+
+
+
+
{m.name}
+ {m.line ? (
+
{m.line}
+ ) : null}
+
+ {m.time != null ? (
+
+ {m.isWalk !== false ? "🚶" : "🚗"} {m.time} мин
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/analytics/ObjectQuartirographyTable.tsx b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx
new file mode 100644
index 00000000..0d57cee3
--- /dev/null
+++ b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx
@@ -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 (
+
+ Данные квартирографии отсутствуют.
+
+ );
+ }
+
+ const headers = ["Тип", "Всего", "В продаже", "Площадь", "Цена (от–до)"];
+
+ return (
+
+
+
+
+ {headers.map((h) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {rows.map((r) => (
+
+ |
+ {r.room_label}
+ |
+
+ {r.total_count.toLocaleString("ru-RU")}
+ |
+
+ {r.free_count > 0 ? (
+
+ {r.free_count.toLocaleString("ru-RU")}
+
+ ) : (
+ 0
+ )}
+ |
+
+ {r.area_min !== null || r.area_max !== null
+ ? `${fmtArea(r.area_min)} – ${fmtArea(r.area_max)}`
+ : "—"}
+ |
+
+ {r.price_min !== null || r.price_max !== null
+ ? `${fmtPrice(r.price_min)} – ${fmtPrice(r.price_max)}`
+ : "—"}
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/analytics/ObjectSpecsTable.tsx b/frontend/src/components/analytics/ObjectSpecsTable.tsx
new file mode 100644
index 00000000..3b0ebb34
--- /dev/null
+++ b/frontend/src/components/analytics/ObjectSpecsTable.tsx
@@ -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 (
+
+ Характеристики ещё не загружены.
+
+ );
+ }
+
+ return (
+
+
+
+ {filtered.map((s) => (
+
+ |
+ {s.label}
+ |
+
+ {s.value}
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/lib/analytics-api.ts b/frontend/src/lib/analytics-api.ts
index f1508634..73b7287c 100644
--- a/frontend/src/lib/analytics-api.ts
+++ b/frontend/src/lib/analytics-api.ts
@@ -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(`${BASE}/object/${objId}/full`),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectQuartirography(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-quartirography", objId],
+ queryFn: () =>
+ apiFetch(
+ `${BASE}/object/${objId}/quartirography`,
+ ),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectChecks(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-checks", objId],
+ queryFn: () => apiFetch(`${BASE}/object/${objId}/checks`),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectDocuments(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-documents", objId],
+ queryFn: () =>
+ apiFetch(`${BASE}/object/${objId}/documents`),
+ enabled: !!objId,
+ });
+}
+
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
export function useRecommendMix() {
diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts
index 01a2c7e7..d8b9c80f 100644
--- a/frontend/src/types/analytics.ts
+++ b/frontend/src/types/analytics.ts
@@ -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;