feat(site-finder): PermitsNearbyBlock — разрешения на строительство в §6 (#2367 PR-2, frontend-half) #2403

Merged
bot-backend merged 1 commit from feat/permits-nearby-ui into main 2026-07-04 10:24:49 +00:00
5 changed files with 523 additions and 3 deletions

View file

@ -239,8 +239,14 @@ export function AnalysisPageContent({ cad }: Props) {
</div> </div>
)} )}
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */} {/* 6. Прогноз и рекомендация IMPLEMENTED in 958-B3 (§22 forecast).
<Section6Forecast cad={cad} selectedHorizon={horizon} /> permits_nearby (#2367) приходит из /analyze (не из forecast-
эндпоинта, который Section6Forecast self-fetch'ит) прокидываем. */}
<Section6Forecast
cad={cad}
selectedHorizon={horizon}
permitsNearby={analysis.permits_nearby}
/>
{/* 7. Концепция генеративный дизайн застройки (#1965 Stage 1). {/* 7. Концепция генеративный дизайн застройки (#1965 Stage 1).
Seeded из анализа (geom + financial_estimate), считается по Seeded из анализа (geom + financial_estimate), считается по

View file

@ -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 видимы, остальные под <details>, как в
* 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<string, unknown> {
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<PermitDocGroup, string> = {
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 видимых сразу; остальные — под <details> (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 (
<Section
title="Разрешения на строительство поблизости"
subtitle={`Радиус ${data.radius_m.toLocaleString("ru")} м · официальный реестр ГИСОГД Свердловской области`}
>
{data.total_count === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}>
В радиусе {data.radius_m.toLocaleString("ru")} м новых разрешений не
найдено.
</p>
) : (
<PermitsContent data={data} />
)}
</Section>
);
}
// ── Содержимое при total_count > 0 ───────────────────────────────────────────
function PermitsContent({ data }: { data: PermitsNearby }) {
const head = data.items.slice(0, HEAD_COUNT);
const rest = data.items.slice(HEAD_COUNT);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* KPI-строка: RS / RV / ближайшее (паттерн KpiCard-грид). */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Разрешений на строительство"
value={data.rs_count.toLocaleString("ru")}
color="blue"
/>
<KpiCard
label="Вводов в эксплуатацию"
value={data.rv_count.toLocaleString("ru")}
color="green"
/>
<KpiCard
label="Ближайшее, м"
value={
data.nearest_distance_m != null
? Math.round(data.nearest_distance_m).toLocaleString("ru")
: "—"
}
color="neutral"
/>
</div>
{/* Список разрешений (первые HEAD_COUNT видимы). */}
{data.items.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<PermitsTable items={head} />
{rest.length > 0 && (
<details>
<summary
style={{
fontSize: 12,
color: "var(--accent)",
cursor: "pointer",
}}
>
Раскрыть всё · ещё {rest.length.toLocaleString("ru")}
</summary>
<div style={{ marginTop: 8 }}>
<PermitsTable items={rest} />
</div>
</details>
)}
{/* Честная приписка, если бэкенд капнул список (cap 30). */}
{data.items_truncated && (
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
Показаны ближайшие {data.items.length.toLocaleString("ru")} из{" "}
{data.total_count.toLocaleString("ru")}.
</span>
)}
</div>
) : null}
</div>
);
}
// ── Таблица разрешений ───────────────────────────────────────────────────────
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 (
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", minWidth: 640 }}
>
<thead>
<tr>
<th style={TH_STYLE}>Тип</th>
<th style={TH_STYLE}>Документ</th>
<th style={{ ...TH_STYLE, whiteSpace: "nowrap" }}>Дата</th>
<th style={NUM_TH}>Расстояние</th>
<th style={{ ...TH_STYLE, minWidth: 200 }}>Орган</th>
</tr>
</thead>
<tbody>
{items.map((it, i) => (
<PermitRow
key={`${it.doc_num ?? it.doc_name ?? "permit"}-${i}`}
item={it}
/>
))}
</tbody>
</table>
</div>
);
}
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 (
<tr>
<td style={TD_STYLE}>
<Badge
variant={isRs ? "info" : "success"}
size="sm"
icon={
isRs ? (
<Building2 size={12} aria-hidden />
) : (
<FileCheck size={12} aria-hidden />
)
}
>
{label}
</Badge>
</td>
<td style={{ ...TD_STYLE, maxWidth: 320 }}>{docText}</td>
<td style={{ ...TD_STYLE, whiteSpace: "nowrap" }}>
{formatRuDate(item.date_doc)}
</td>
<td style={NUM_TD}>{formatMeters(item.distance_m)}</td>
<td style={{ ...TD_STYLE, maxWidth: 280 }}>
{org ? (
<span
title={org}
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--fg-secondary)",
}}
>
{org}
</span>
) : (
<span style={{ color: "var(--fg-tertiary)" }}></span>
)}
</td>
</tr>
);
}

View file

@ -36,6 +36,7 @@ import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock"; import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
import { ForecastScoringBlock } from "./ForecastScoringBlock"; import { ForecastScoringBlock } from "./ForecastScoringBlock";
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock"; import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
import { PermitsNearbyBlock } from "./PermitsNearbyBlock";
import { ForecastMetaLine } from "./ForecastMetaLine"; import { ForecastMetaLine } from "./ForecastMetaLine";
import { ForecastExportButtons } from "./ForecastExportButtons"; import { ForecastExportButtons } from "./ForecastExportButtons";
import { StageDetails } from "./StageDetails"; import { StageDetails } from "./StageDetails";
@ -56,6 +57,16 @@ interface Props {
* before the recomputed forecast finishes (see AnalysisPageContent comment). * before the recomputed forecast finishes (see AnalysisPageContent comment).
*/ */
selectedHorizon?: number; 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 = const ADVISORY_DISCLAIMER =
@ -73,16 +84,28 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
// ── Section6Forecast ───────────────────────────────────────────────────────── // ── Section6Forecast ─────────────────────────────────────────────────────────
export function Section6Forecast({ cad, selectedHorizon }: Props) { export function Section6Forecast({
cad,
selectedHorizon,
permitsNearby,
}: Props) {
const { data, isLoading, error } = useParcelForecastQuery(cad); const { data, isLoading, error } = useParcelForecastQuery(cad);
const isPending = const isPending =
isLoading || (data != null && data.status !== "ready") || data == null; isLoading || (data != null && data.status !== "ready") || data == null;
// permits_nearby (#2367) приходит из /analyze (быстрый, уже загружен к этому
// рендеру), а НЕ из forecast-эндпоинта, который эта секция self-fetch'ит и
// который на холодном участке считается 30-180с. Рендерим блок разрешений
// ПЕРВЫМ и БЕЗУСЛОВНО — он не должен ждать §22-прогноз (юзер видит данные,
// которые уже есть). Дальше — обычная pending/error/ready-логика прогноза.
const permits = <PermitsNearbyBlock permitsNearby={permitsNearby} />;
// ── Pending: forecast still computing (poll in flight) ───────────────────── // ── Pending: forecast still computing (poll in flight) ─────────────────────
if (isPending && !error) { if (isPending && !error) {
return ( return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}> <section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
{permits}
<HeadlineBar <HeadlineBar
title="6. Прогноз" title="6. Прогноз"
subtitle="Прогноз спроса, предложения и сценарии (§22)." subtitle="Прогноз спроса, предложения и сценарии (§22)."
@ -137,6 +160,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
if (error || !data || data.status !== "ready" || !data.report) { if (error || !data || data.status !== "ready" || !data.report) {
return ( return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}> <section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
{permits}
<HeadlineBar title="6. Прогноз" /> <HeadlineBar title="6. Прогноз" />
<div <div
style={{ style={{
@ -167,6 +191,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
report={data.report} report={data.report}
runCreatedAt={data.created_at} runCreatedAt={data.created_at}
selectedHorizon={selectedHorizon} selectedHorizon={selectedHorizon}
permits={permits}
/> />
); );
} }
@ -178,12 +203,19 @@ function ForecastReady({
report, report,
runCreatedAt, runCreatedAt,
selectedHorizon, selectedHorizon,
permits,
}: { }: {
cad: string; cad: string;
report: ForecastReport; report: ForecastReport;
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */ /** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
runCreatedAt?: string | null; runCreatedAt?: string | null;
selectedHorizon?: number; selectedHorizon?: number;
/**
* #2367 готовый <PermitsNearbyBlock> (собран в Section6Forecast из
* analyze-payload). Рендерится ПЕРВЫМ в секции, до forecast-контента, чтобы
* не зависеть от статуса §22-прогноза (данные /analyze уже есть).
*/
permits: React.ReactNode;
}) { }) {
const exec = report.exec_summary; const exec = report.exec_summary;
const fm = report.future_market; const fm = report.future_market;
@ -274,6 +306,9 @@ function ForecastReady({
return ( return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}> <section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
{/* Разрешения на строительство поблизости (#2367) ПЕРВЫМ в секции, до
прогноза: приходит из /analyze (уже загружен), не ждёт §22-прогноз. */}
{permits}
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} /> <HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
<div <div

View file

@ -0,0 +1,129 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PermitsNearbyBlock } from "../PermitsNearbyBlock";
// PermitsNearbyBlock — чистый компонент (нет useQuery), рендерим напрямую.
// Форму permits_nearby подаём как generic-объект (unknown на входе), как её
// отдаёт /analyze; проверяем honest-zero, RS/RV-подписи, truncated-приписку.
function buildPermit(overrides: Record<string, unknown> = {}) {
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(
<PermitsNearbyBlock
permitsNearby={{
radius_m: 500,
total_count: 0,
rs_count: 0,
rv_count: 0,
nearest_distance_m: null,
items: [],
items_truncated: false,
source: "gisogd66",
}}
/>,
);
// Заголовок секции виден, и внутри — честная нейтральная строка.
expect(
screen.getByText("Разрешения на строительство поблизости"),
).toBeTruthy();
expect(
screen.getByText(/В радиусе 500 м новых разрешений не найдено/),
).toBeTruthy();
});
it("не рендерит ничего, когда permits_nearby отсутствует / null (старый кэш)", () => {
const { container: c1 } = render(
<PermitsNearbyBlock permitsNearby={null} />,
);
expect(c1.firstChild).toBeNull();
const { container: c2 } = render(
<PermitsNearbyBlock permitsNearby={undefined} />,
);
expect(c2.firstChild).toBeNull();
// Объект без обязательного total_count → тоже не рендерим.
const { container: c3 } = render(
<PermitsNearbyBlock permitsNearby={{ radius_m: 500 }} />,
);
expect(c3.firstChild).toBeNull();
});
it("показывает человеко-читаемые подписи RS → «Разрешение на строительство», RV → «Ввод в эксплуатацию»", () => {
render(
<PermitsNearbyBlock
permitsNearby={{
radius_m: 500,
total_count: 2,
rs_count: 1,
rv_count: 1,
nearest_distance_m: 0,
items: [
buildPermit({ doc_group: "RS" }),
buildPermit({
doc_group: "RV",
doc_name: "Разрешение на ввод объекта в эксплуатацию",
}),
],
items_truncated: false,
source: "gisogd66",
}}
/>,
);
expect(screen.getByText("Разрешение на строительство")).toBeTruthy();
expect(screen.getByText("Ввод в эксплуатацию")).toBeTruthy();
});
it("НЕ показывает truncated-приписку, когда items_truncated === false", () => {
render(
<PermitsNearbyBlock
permitsNearby={{
radius_m: 500,
total_count: 1,
rs_count: 1,
rv_count: 0,
nearest_distance_m: 120,
items: [buildPermit()],
items_truncated: false,
source: "gisogd66",
}}
/>,
);
expect(screen.queryByText(/Показаны ближайшие/)).toBeNull();
});
it("показывает truncated-приписку «показаны ближайшие N из M», когда items_truncated === true", () => {
const items = Array.from({ length: 30 }, (_, i) =>
buildPermit({ doc_num: `66-${i}` }),
);
render(
<PermitsNearbyBlock
permitsNearby={{
radius_m: 500,
total_count: 42,
rs_count: 20,
rv_count: 22,
nearest_distance_m: 0,
items,
items_truncated: true,
source: "gisogd66",
}}
/>,
);
// «Показаны ближайшие 30 из 42.»
expect(screen.getByText(/Показаны ближайшие 30 из 42/)).toBeTruthy();
});
});

View file

@ -550,6 +550,11 @@ export interface ParcelAnalysis {
// это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py); // это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
// на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend). // на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
program_alternatives?: ProgramAlternatives | null; program_alternatives?: ProgramAlternatives | null;
// #2367 — разрешения на строительство (RS) и вводы в эксплуатацию (RV) в
// радиусе 500 м по реестру ГИСОГД. Backend держит блок как dict[str, Any] |
// None, поэтому здесь — generic-объект; строгую форму narrow'ит
// PermitsNearbyBlock в рантайме. Может отсутствовать/быть null (старый кэш).
permits_nearby?: Record<string, unknown> | null;
} }
/** /**