From b76bb4f7eae728d60ceba2643cfa8e78b597b317 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 15:21:16 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20PermitsNearbyBlock=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D1=82=D0=B2=D0=BE=20=D0=B2=20?= =?UTF-8?q?=C2=A76=20(#2367=20PR-2,=20frontend-half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Делает видимым backend-поле permits_nearby (PR #2401, ГИСОГД-66) на странице анализа участка: §6 «Риски и дефицит», сразу после блока будущего предложения. Рендерится независимо от статуса §22-прогноза (permits приходит быстро вместе с analyze, форсайт может считаться 30-180с) — блок первым в дереве §6 во всех трёх ветках pending/error/ready. Честный ноль (0 найдено — видимый нейтральный текст, не скрытие блока) отделён от отсутствия поля (старый кэш — блок молча не рендерится, не выдумывает «0»). items_truncated -> честная приписка «показаны ближайшие N из M». RS/RV — человеко-читаемые Badge-подписи, progressive disclosure (8 видимых +
) по паттерну ResourceReservesCard. --- .../analysis/[cad]/AnalysisPageContent.tsx | 10 +- .../analysis/PermitsNearbyBlock.tsx | 345 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 37 +- .../__tests__/PermitsNearbyBlock.test.tsx | 129 +++++++ frontend/src/types/site-finder.ts | 5 + 5 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/PermitsNearbyBlock.tsx create mode 100644 frontend/src/components/site-finder/analysis/__tests__/PermitsNearbyBlock.test.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 29830683..03be7b0d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -239,8 +239,14 @@ export function AnalysisPageContent({ cad }: Props) { )} - {/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */} - + {/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast). + permits_nearby (#2367) приходит из /analyze (не из forecast- + эндпоинта, который Section6Forecast self-fetch'ит) — прокидываем. */} + {/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1). Seeded из анализа (geom + financial_estimate), считается по diff --git a/frontend/src/components/site-finder/analysis/PermitsNearbyBlock.tsx b/frontend/src/components/site-finder/analysis/PermitsNearbyBlock.tsx new file mode 100644 index 00000000..393123fe --- /dev/null +++ b/frontend/src/components/site-finder/analysis/PermitsNearbyBlock.tsx @@ -0,0 +1,345 @@ +"use client"; + +/** + * PermitsNearbyBlock — «Разрешения на строительство поблизости» (#2367 PR-2). + * + * Точный радиус-запрос (500 м) по официальному реестру ГИСОГД Свердловской + * области: разрешения на строительство (RS) и вводы в эксплуатацию (RV) рядом с + * участком. Ценность девелоперу — что реально строится/сдаётся в шаговой + * доступности (ранний сигнал будущей конкуренции/спроса). + * + * Источник данных — блок `permits_nearby` из /analyze. Backend держит его как + * `dict[str, Any] | None` (Pydantic extra=allow), поэтому в api-types.ts он + * приходит как generic-объект; форму типизируем здесь локально и валидируем + * обязательные поля в рантайме (`asPermitsNearby`), без `any`. + * + * Рендер: + * • total_count === 0 → нейтральная строка «В радиусе 500 м … не найдено» + * (блок НЕ скрывается — юзер видит «проверили, рядом нет»). + * • total_count > 0 → KPI-строка (RS / RV / ближайшее) + список items с + * progressive disclosure (первые 8 видимы, остальные под
, как в + * ResourceReservesCard / ForecastFutureSupplyBlock). + * • items_truncated → честная приписка «показаны ближайшие N из M». + */ + +import { Building2, FileCheck } from "lucide-react"; + +import { Section } from "@/components/analytics/Section"; +import { KpiCard } from "@/components/site-finder/KpiCard"; +import { Badge } from "@/components/ui/Badge"; + +// ── Локальная форма данных (типизируем сами — см. шапку) ───────────────────── + +type PermitDocGroup = "RS" | "RV"; + +interface PermitItem { + doc_group: PermitDocGroup; + doc_name: string | null; + doc_num: string | null; + date_doc: string | null; + approved_organization: string | null; + distance_m: number | null; +} + +interface PermitsNearby { + radius_m: number; + total_count: number; + rs_count: number; + rv_count: number; + nearest_distance_m: number | null; + items: PermitItem[]; + items_truncated: boolean; + source: string | null; +} + +interface Props { + /** + * Сырой `permits_nearby` из /analyze (generic-объект / null). Narrow'им в + * рантайме через asPermitsNearby — на входе `unknown`, чтобы не тащить сюда + * ложную строгость из api-types.ts. + */ + permitsNearby: unknown; +} + +// ── Runtime narrowing (без any) ────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function toNum(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function toStr(v: unknown): string | null { + return typeof v === "string" && v.length > 0 ? v : null; +} + +function toPermitItem(v: unknown): PermitItem | null { + if (!isRecord(v)) return null; + const group = + v.doc_group === "RV" ? "RV" : v.doc_group === "RS" ? "RS" : null; + if (group === null) return null; + return { + doc_group: group, + doc_name: toStr(v.doc_name), + doc_num: toStr(v.doc_num), + date_doc: toStr(v.date_doc), + approved_organization: toStr(v.approved_organization), + distance_m: toNum(v.distance_m), + }; +} + +/** + * Narrow сырого `permits_nearby` в строгую форму. Возвращает null, если объекта + * нет / это не объект / нет обязательного `total_count` (старый кэш без поля). + */ +function asPermitsNearby(raw: unknown): PermitsNearby | null { + if (!isRecord(raw)) return null; + const total = toNum(raw.total_count); + if (total === null) return null; + + const items = Array.isArray(raw.items) + ? raw.items.map(toPermitItem).filter((it): it is PermitItem => it !== null) + : []; + + return { + radius_m: toNum(raw.radius_m) ?? 500, + total_count: total, + rs_count: toNum(raw.rs_count) ?? 0, + rv_count: toNum(raw.rv_count) ?? 0, + nearest_distance_m: toNum(raw.nearest_distance_m), + items, + items_truncated: raw.items_truncated === true, + source: toStr(raw.source), + }; +} + +// ── Форматирование ─────────────────────────────────────────────────────────── + +const GROUP_LABEL: Record = { + RS: "Разрешение на строительство", + RV: "Ввод в эксплуатацию", +}; + +function formatRuDate(raw: string | null): string { + if (!raw) return "—"; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString("ru-RU"); +} + +function formatMeters(v: number | null): string { + return v != null ? `${Math.round(v).toLocaleString("ru")} м` : "—"; +} + +// Первые N видимых сразу; остальные — под
(progressive disclosure, +// как в ResourceReservesCard / ForecastFutureSupplyBlock). +const HEAD_COUNT = 8; + +// ── PermitsNearbyBlock ─────────────────────────────────────────────────────── + +export function PermitsNearbyBlock({ permitsNearby }: Props) { + const data = asPermitsNearby(permitsNearby); + + // Поле отсутствует / null / старый кэш без формы — не рендерим блок совсем + // (о нём просто нечего сказать, в отличие от честного нуля). + if (data === null) return null; + + return ( +
+ {data.total_count === 0 ? ( +

+ В радиусе {data.radius_m.toLocaleString("ru")} м новых разрешений не + найдено. +

+ ) : ( + + )} +
+ ); +} + +// ── Содержимое при total_count > 0 ─────────────────────────────────────────── + +function PermitsContent({ data }: { data: PermitsNearby }) { + const head = data.items.slice(0, HEAD_COUNT); + const rest = data.items.slice(HEAD_COUNT); + + return ( +
+ {/* KPI-строка: RS / RV / ближайшее (паттерн KpiCard-грид). */} +
+ + + +
+ + {/* Список разрешений (первые HEAD_COUNT видимы). */} + {data.items.length > 0 ? ( +
+ + + {rest.length > 0 && ( +
+ + Раскрыть всё · ещё {rest.length.toLocaleString("ru")} + +
+ +
+
+ )} + + {/* Честная приписка, если бэкенд капнул список (cap 30). */} + {data.items_truncated && ( + + Показаны ближайшие {data.items.length.toLocaleString("ru")} из{" "} + {data.total_count.toLocaleString("ru")}. + + )} +
+ ) : null} +
+ ); +} + +// ── Таблица разрешений ─────────────────────────────────────────────────────── + +const TH_STYLE: React.CSSProperties = { + textAlign: "left", + padding: "8px 12px", + fontSize: 11, + fontWeight: 500, + letterSpacing: "0.04em", + textTransform: "uppercase", + color: "var(--fg-secondary)", + whiteSpace: "nowrap", + borderBottom: "1px solid var(--border-card)", +}; + +const TD_STYLE: React.CSSProperties = { + padding: "10px 12px", + fontSize: 13, + color: "var(--fg-primary)", + verticalAlign: "top", + borderBottom: "1px solid var(--border-soft)", +}; + +const NUM_TH: React.CSSProperties = { ...TH_STYLE, textAlign: "right" }; +const NUM_TD: React.CSSProperties = { + ...TD_STYLE, + textAlign: "right", + whiteSpace: "nowrap", + fontVariantNumeric: "tabular-nums", +}; + +function PermitsTable({ items }: { items: PermitItem[] }) { + return ( +
+ + + + + + + + + + + + {items.map((it, i) => ( + + ))} + +
ТипДокументДатаРасстояниеОрган
+
+ ); +} + +function PermitRow({ item }: { item: PermitItem }) { + const isRs = item.doc_group === "RS"; + const label = GROUP_LABEL[item.doc_group]; + // doc_name основной текст (в реестре он несёт полный «№ … от …»); при его + // отсутствии деградируем к doc_num. + const docText = item.doc_name ?? item.doc_num ?? "—"; + const org = item.approved_organization; + + return ( + + + + ) : ( + + ) + } + > + {label} + + + {docText} + + {formatRuDate(item.date_doc)} + + {formatMeters(item.distance_m)} + + {org ? ( + + {org} + + ) : ( + + )} + + + ); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index f008c495..bc10a89c 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -36,6 +36,7 @@ import { ForecastProductTzBlock } from "./ForecastProductTzBlock"; import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock"; import { ForecastScoringBlock } from "./ForecastScoringBlock"; import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock"; +import { PermitsNearbyBlock } from "./PermitsNearbyBlock"; import { ForecastMetaLine } from "./ForecastMetaLine"; import { ForecastExportButtons } from "./ForecastExportButtons"; import { StageDetails } from "./StageDetails"; @@ -56,6 +57,16 @@ interface Props { * before the recomputed forecast finishes (see AnalysisPageContent comment). */ selectedHorizon?: number; + /** + * Сырой `permits_nearby` из /analyze (#2367) — разрешения РС/ввод в + * эксплуатацию в радиусе 500 м. Приходит из analyze-payload (НЕ из forecast- + * эндпоинта, который self-fetch'ит эта секция), поэтому прокидывается пропом из + * AnalysisPageContent. `unknown` — backend держит его generic-объектом; форму + * narrow'ит PermitsNearbyBlock. Рендерится ПЕРВЫМ в секции §6, ДО forecast- + * контента и НЕЗАВИСИМО от статуса §22-прогноза (данные /analyze уже загружены, + * не должны ждать медленный несвязанный прогноз 30-180с). + */ + permitsNearby?: unknown; } const ADVISORY_DISCLAIMER = @@ -73,16 +84,28 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" { // ── Section6Forecast ───────────────────────────────────────────────────────── -export function Section6Forecast({ cad, selectedHorizon }: Props) { +export function Section6Forecast({ + cad, + selectedHorizon, + permitsNearby, +}: Props) { const { data, isLoading, error } = useParcelForecastQuery(cad); const isPending = isLoading || (data != null && data.status !== "ready") || data == null; + // permits_nearby (#2367) приходит из /analyze (быстрый, уже загружен к этому + // рендеру), а НЕ из forecast-эндпоинта, который эта секция self-fetch'ит и + // который на холодном участке считается 30-180с. Рендерим блок разрешений + // ПЕРВЫМ и БЕЗУСЛОВНО — он не должен ждать §22-прогноз (юзер видит данные, + // которые уже есть). Дальше — обычная pending/error/ready-логика прогноза. + const permits = ; + // ── Pending: forecast still computing (poll in flight) ───────────────────── if (isPending && !error) { return (
+ {permits} + {permits}
); } @@ -178,12 +203,19 @@ function ForecastReady({ report, runCreatedAt, selectedHorizon, + permits, }: { cad: string; report: ForecastReport; /** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */ runCreatedAt?: string | null; selectedHorizon?: number; + /** + * #2367 — готовый (собран в Section6Forecast из + * analyze-payload). Рендерится ПЕРВЫМ в секции, до forecast-контента, чтобы + * не зависеть от статуса §22-прогноза (данные /analyze уже есть). + */ + permits: React.ReactNode; }) { const exec = report.exec_summary; const fm = report.future_market; @@ -274,6 +306,9 @@ function ForecastReady({ return (
+ {/* Разрешения на строительство поблизости (#2367) — ПЕРВЫМ в секции, до + прогноза: приходит из /analyze (уже загружен), не ждёт §22-прогноз. */} + {permits}
= {}) { + return { + doc_group: "RS", + doc_name: "Разрешение на строительство № 66-45-08-2026 от 02.06.2026", + doc_num: "66-45-08-2026", + date_doc: "2026-06-02", + approved_organization: "Комитет по архитектуре", + distance_m: 120.0, + ...overrides, + }; +} + +describe("PermitsNearbyBlock", () => { + it("рендерит honest-zero строку при total_count === 0 (блок не скрыт)", () => { + render( + , + ); + // Заголовок секции виден, и внутри — честная нейтральная строка. + expect( + screen.getByText("Разрешения на строительство поблизости"), + ).toBeTruthy(); + expect( + screen.getByText(/В радиусе 500 м новых разрешений не найдено/), + ).toBeTruthy(); + }); + + it("не рендерит ничего, когда permits_nearby отсутствует / null (старый кэш)", () => { + const { container: c1 } = render( + , + ); + expect(c1.firstChild).toBeNull(); + + const { container: c2 } = render( + , + ); + expect(c2.firstChild).toBeNull(); + + // Объект без обязательного total_count → тоже не рендерим. + const { container: c3 } = render( + , + ); + expect(c3.firstChild).toBeNull(); + }); + + it("показывает человеко-читаемые подписи RS → «Разрешение на строительство», RV → «Ввод в эксплуатацию»", () => { + render( + , + ); + expect(screen.getByText("Разрешение на строительство")).toBeTruthy(); + expect(screen.getByText("Ввод в эксплуатацию")).toBeTruthy(); + }); + + it("НЕ показывает truncated-приписку, когда items_truncated === false", () => { + render( + , + ); + expect(screen.queryByText(/Показаны ближайшие/)).toBeNull(); + }); + + it("показывает truncated-приписку «показаны ближайшие N из M», когда items_truncated === true", () => { + const items = Array.from({ length: 30 }, (_, i) => + buildPermit({ doc_num: `66-${i}` }), + ); + render( + , + ); + // «Показаны ближайшие 30 из 42.» + expect(screen.getByText(/Показаны ближайшие 30 из 42/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 3ed312de..758195c8 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -550,6 +550,11 @@ export interface ParcelAnalysis { // это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py); // на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend). program_alternatives?: ProgramAlternatives | null; + // #2367 — разрешения на строительство (RS) и вводы в эксплуатацию (RV) в + // радиусе 500 м по реестру ГИСОГД. Backend держит блок как dict[str, Any] | + // None, поэтому здесь — generic-объект; строгую форму narrow'ит + // PermitsNearbyBlock в рантайме. Может отсутствовать/быть null (старый кэш). + permits_nearby?: Record | null; } /**