feat(site-finder): PermitsNearbyBlock — разрешения на строительство в §6 (#2367 PR-2, frontend-half) (#2403)
This commit is contained in:
parent
828d691c0d
commit
ab250a132b
5 changed files with 523 additions and 3 deletions
|
|
@ -239,8 +239,14 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
||||
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
||||
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast).
|
||||
permits_nearby (#2367) приходит из /analyze (не из forecast-
|
||||
эндпоинта, который Section6Forecast self-fetch'ит) — прокидываем. */}
|
||||
<Section6Forecast
|
||||
cad={cad}
|
||||
selectedHorizon={horizon}
|
||||
permitsNearby={analysis.permits_nearby}
|
||||
/>
|
||||
|
||||
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
|
||||
Seeded из анализа (geom + financial_estimate), считается по
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = <PermitsNearbyBlock permitsNearby={permitsNearby} />;
|
||||
|
||||
// ── Pending: forecast still computing (poll in flight) ─────────────────────
|
||||
if (isPending && !error) {
|
||||
return (
|
||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||
{permits}
|
||||
<HeadlineBar
|
||||
title="6. Прогноз"
|
||||
subtitle="Прогноз спроса, предложения и сценарии (§22)."
|
||||
|
|
@ -137,6 +160,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
|||
if (error || !data || data.status !== "ready" || !data.report) {
|
||||
return (
|
||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||
{permits}
|
||||
<HeadlineBar title="6. Прогноз" />
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -167,6 +191,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
|||
report={data.report}
|
||||
runCreatedAt={data.created_at}
|
||||
selectedHorizon={selectedHorizon}
|
||||
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 — готовый <PermitsNearbyBlock> (собран в 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 (
|
||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||
{/* Разрешения на строительство поблизости (#2367) — ПЕРВЫМ в секции, до
|
||||
прогноза: приходит из /analyze (уже загружен), не ждёт §22-прогноз. */}
|
||||
{permits}
|
||||
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue