10 changed files with 437 additions and 45 deletions
|
|
@ -72,7 +72,10 @@ export function LandTab({ data }: Props) {
|
|||
<SectionLabel style={{ marginBottom: 10 }}>
|
||||
Зоны с особыми условиями использования (ЗОУИТ)
|
||||
</SectionLabel>
|
||||
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps ?? []} />
|
||||
<NspdZouitOverlapsBlock
|
||||
overlaps={data.nspd_zouit_overlaps ?? []}
|
||||
parcelCad={data.cad_num}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import type { NspdZouitOverlap } from "@/types/nspd";
|
||||
|
||||
interface Props {
|
||||
overlaps: NspdZouitOverlap[];
|
||||
/**
|
||||
* #1957 — кадастровый номер анализируемого участка. Нужен, чтобы подсветить
|
||||
* «СЗЗ вашего участка» (когда описание/рег-номер охранной зоны ссылается на
|
||||
* сам участок). Optional: вызовы без cad просто не делают подсветку.
|
||||
*/
|
||||
parcelCad?: string;
|
||||
}
|
||||
|
||||
// Group key → human-readable label
|
||||
// Group key → human-readable label (fallback, когда type_zone отсутствует).
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
engineering: "Инженерные коммуникации",
|
||||
okn: "Объекты культурного наследия",
|
||||
|
|
@ -15,27 +23,62 @@ const GROUP_LABELS: Record<string, string> = {
|
|||
other: "Прочие ЗОУИТ",
|
||||
};
|
||||
|
||||
// Determine a color-coded severity badge from group_key
|
||||
function getSeverityStyle(groupKey: string): {
|
||||
bg: string;
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
// #1957 — СЗЗ (санитарно-защитная зона): размещение жилья внутри ограничено/
|
||||
// запрещено (СанПиН 2.2.1/2.1.1.1200-03), это материальный инвест-риск.
|
||||
function isSzz(typeZone: string | null | undefined): boolean {
|
||||
return (typeZone ?? "").toLowerCase().includes("санитарно-защитн");
|
||||
}
|
||||
|
||||
// Severity → токенные цвета (раньше тут был сырой hex, вне ui-tokens).
|
||||
function getSeverityStyle(
|
||||
groupKey: string,
|
||||
szz: boolean,
|
||||
): { bg: string; color: string; label: string } {
|
||||
// СЗЗ — всегда danger вне зависимости от group_key (материальный риск).
|
||||
if (szz)
|
||||
return { bg: "var(--danger-soft)", color: "var(--danger)", label: "СЗЗ" };
|
||||
switch (groupKey) {
|
||||
case "engineering":
|
||||
return { bg: "#fef3c7", color: "#92400e", label: "warn" };
|
||||
return { bg: "var(--warn-soft)", color: "var(--warn)", label: "warn" };
|
||||
case "okn":
|
||||
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
|
||||
case "protected":
|
||||
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
|
||||
return {
|
||||
bg: "var(--danger-soft)",
|
||||
color: "var(--danger)",
|
||||
label: "block",
|
||||
};
|
||||
case "natural":
|
||||
return { bg: "#dbeafe", color: "#1e40af", label: "low" };
|
||||
return { bg: "var(--accent-soft)", color: "var(--accent)", label: "low" };
|
||||
default:
|
||||
return { bg: "#f3f4f6", color: "#374151", label: "info" };
|
||||
return {
|
||||
bg: "var(--bg-card-alt)",
|
||||
color: "var(--fg-secondary)",
|
||||
label: "info",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
||||
/**
|
||||
* #1957 — ссылается ли охранная зона на сам участок? Сверяем cad участка с
|
||||
* descr / name_by_doc / reg_numb_border / name. (СЗЗ собственного предприятия
|
||||
* участка — «СЗЗ вашего участка», а не чужая зона по соседству.)
|
||||
*/
|
||||
function referencesOwnParcel(
|
||||
overlap: NspdZouitOverlap,
|
||||
parcelCad: string | undefined,
|
||||
): boolean {
|
||||
if (!parcelCad) return false;
|
||||
const raw = overlap.raw_props ?? {};
|
||||
const haystacks = [
|
||||
overlap.reg_numb_border,
|
||||
overlap.name,
|
||||
typeof raw.descr === "string" ? raw.descr : null,
|
||||
typeof raw.name_by_doc === "string" ? raw.name_by_doc : null,
|
||||
];
|
||||
return haystacks.some((h) => typeof h === "string" && h.includes(parcelCad));
|
||||
}
|
||||
|
||||
export function NspdZouitOverlapsBlock({ overlaps, parcelCad }: Props) {
|
||||
if (overlaps.length === 0) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -43,16 +86,16 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
background: "var(--success-soft)",
|
||||
border: "1px solid var(--success)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "#dcfce7",
|
||||
color: "#15803d",
|
||||
background: "var(--bg-card)",
|
||||
color: "var(--success)",
|
||||
borderRadius: 6,
|
||||
padding: "2px 10px",
|
||||
fontSize: 12,
|
||||
|
|
@ -61,30 +104,76 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
|||
>
|
||||
Нет пересечений
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: "#15803d" }}>
|
||||
<span style={{ fontSize: 13, color: "var(--success)" }}>
|
||||
Охранных зон ЗОУИТ не обнаружено
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// #1957 — СЗЗ-caveat: показываем, если хоть один overlap — санитарно-защитная
|
||||
// зона. Если эта СЗЗ ссылается на сам участок — формулировка строже.
|
||||
const hasSzz = overlaps.some((o) => isSzz(o.type_zone) || isSzz(o.name));
|
||||
const ownSzz = overlaps.some(
|
||||
(o) =>
|
||||
(isSzz(o.type_zone) || isSzz(o.name)) &&
|
||||
referencesOwnParcel(o, parcelCad),
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{hasSzz && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 16px",
|
||||
background: "var(--danger-soft)",
|
||||
border: "1px solid var(--danger)",
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
color: "var(--danger)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
/>
|
||||
<span>
|
||||
{ownSzz
|
||||
? "Участок попадает в санитарно-защитную зону (СЗЗ вашего участка). "
|
||||
: "Участок пересекает санитарно-защитную зону (СЗЗ). "}
|
||||
Размещение жилой застройки в границах СЗЗ ограничено и, как правило,
|
||||
не допускается (СанПиН 2.2.1/2.1.1.1200-03). Это материальный
|
||||
инвест-риск: до проектирования нужно подтвердить возможность
|
||||
строительства жилья на участке.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overlaps.map((overlap, idx) => {
|
||||
const style = getSeverityStyle(overlap.group_key);
|
||||
const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key;
|
||||
const detail =
|
||||
const szz = isSzz(overlap.type_zone) || isSzz(overlap.name);
|
||||
const style = getSeverityStyle(overlap.group_key, szz);
|
||||
const own = szz && referencesOwnParcel(overlap, parcelCad);
|
||||
// #1957 — заголовок строки: человекочитаемая категория зоны (type_zone),
|
||||
// иначе name, иначе fallback на group-label. type_zone приоритетнее
|
||||
// общей метки группы («Охраняемые зоны»).
|
||||
const title =
|
||||
overlap.type_zone ||
|
||||
overlap.name ||
|
||||
(typeof overlap.subcategory === "string"
|
||||
? overlap.subcategory
|
||||
: null);
|
||||
GROUP_LABELS[overlap.group_key] ||
|
||||
overlap.group_key;
|
||||
const reg = overlap.reg_numb_border;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
background: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
display: "flex",
|
||||
|
|
@ -102,17 +191,43 @@ export function NspdZouitOverlapsBlock({ overlaps }: Props) {
|
|||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{style.label.toUpperCase()}
|
||||
{style.label}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
|
||||
{label}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
{own && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "var(--danger)",
|
||||
}}
|
||||
>
|
||||
СЗЗ вашего участка
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{detail && (
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
|
||||
{detail}
|
||||
{reg && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
marginTop: 2,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
Рег. номер границы: {reg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ function formatArea(v: number | null): string {
|
|||
return v.toFixed(0) + " м²";
|
||||
}
|
||||
|
||||
/**
|
||||
* #1955 — подпись бакета с классом ЖК: «Студии 15-30 · Комфорт». Класс убирает
|
||||
* визуальные дубли строк с одинаковым area-label (Комфорт vs Типовой vs «не
|
||||
* указан»). Defensive: backend объявляет obj_class required, но старый кэш
|
||||
* прогона мог его не нести (null/пусто/«unknown») — тогда показываем только
|
||||
* area-label без фантомного « · » хвоста.
|
||||
*/
|
||||
function formatBucketLabel(bucket: SuccessRankingBucket): string {
|
||||
const cls = (bucket.obj_class ?? "").trim();
|
||||
const isMeaningful =
|
||||
cls.length > 0 && cls.toLowerCase() !== "unknown" && cls !== "—";
|
||||
return isMeaningful ? `${bucket.bucket} · ${cls}` : bucket.bucket;
|
||||
}
|
||||
|
||||
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
|
||||
const color = successColor(bucket.success_score);
|
||||
const bg = successBg(bucket.success_score);
|
||||
|
|
@ -168,10 +182,16 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{top5.map((row) => {
|
||||
const isTop = row.bucket === top_bucket.bucket;
|
||||
{top5.map((row, idx) => {
|
||||
// #1955 — бакеты больше не уникальны по area-label (одинаковый
|
||||
// «Студии 15-30» в разных классах), поэтому матчим топ по паре
|
||||
// (bucket, obj_class), а React key включает индекс — иначе строки
|
||||
// с дублирующимся area-label делят key и схлопываются в рендере.
|
||||
const isTop =
|
||||
row.bucket === top_bucket.bucket &&
|
||||
(row.obj_class ?? "") === (top_bucket.obj_class ?? "");
|
||||
return (
|
||||
<tr key={row.bucket}>
|
||||
<tr key={`${row.bucket}-${row.obj_class ?? ""}-${idx}`}>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 12px 6px 0",
|
||||
|
|
@ -181,7 +201,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
|||
}}
|
||||
>
|
||||
{isTop ? "* " : ""}
|
||||
{row.bucket}
|
||||
{formatBucketLabel(row)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -111,6 +111,33 @@ function rateByHorizon(
|
|||
return horizons.map((h) => byH.get(h) ?? null);
|
||||
}
|
||||
|
||||
// #1958 — близость к ±1 трактуем как clamp-floor: backend подрезает deficit_index
|
||||
// к [−1,1], и вырожденный (недифференцированный) прогноз садится на границу.
|
||||
const DEGENERATE_CLAMP_EPS = 0.02;
|
||||
|
||||
/**
|
||||
* #1958 — вырожден ли deficit-ряд? Линия «плоская и неинформативная», когда:
|
||||
* (a) backend схлопнул сценарии (scenarios_collapsed) — 3 линии идентичны; ИЛИ
|
||||
* (b) все ненулевые точки дефицита РАВНЫ между собой И сидят у clamp-границы
|
||||
* (|value| ≈ 1) — дифференциации сегментов нет, линия почти невидима у края.
|
||||
*
|
||||
* Чистая функция (без ECharts) — переиспользуется тестами. Реальный фикс
|
||||
* дифференциации — backend #1959; здесь только честный flat-state callout.
|
||||
*/
|
||||
export function isDeficitDegenerate(
|
||||
values: (number | null)[],
|
||||
scenariosCollapsed: boolean,
|
||||
): boolean {
|
||||
if (scenariosCollapsed) return true;
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
if (present.length === 0) return false;
|
||||
const first = present[0];
|
||||
const allEqual = present.every((v) => Math.abs(v - first) < 1e-9);
|
||||
if (!allEqual) return false;
|
||||
// Все точки равны — вырождено только если они у clamp-floor (|value| ≈ 1).
|
||||
return Math.abs(Math.abs(first) - 1) < DEGENERATE_CLAMP_EPS;
|
||||
}
|
||||
|
||||
export function ForecastChart({ report, selectedHorizon }: Props) {
|
||||
const fm = report.future_market;
|
||||
const byScenario = report.scenarios.by_scenario;
|
||||
|
|
@ -241,7 +268,14 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
min: -1,
|
||||
max: 1,
|
||||
interval: 0.5,
|
||||
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "left" },
|
||||
// #1958 — раньше nameLocation по умолчанию ("end") ставил подпись оси в
|
||||
// верхний угол, где она перекрывалась с легендой. Кладём подпись ВДОЛЬ оси
|
||||
// (middle, повёрнута на 90°) с зазором nameGap, чтобы она не лезла в
|
||||
// легенду и не наезжала на axisLabel'ы.
|
||||
nameLocation: "middle",
|
||||
nameGap: 40,
|
||||
nameRotate: 90,
|
||||
nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
|
||||
axisLabel: {
|
||||
color: FG_SECONDARY,
|
||||
formatter: (v: number) => fmtNum(v, 1),
|
||||
|
|
@ -255,7 +289,12 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
name: "Ставка, % годовых",
|
||||
position: "right",
|
||||
scale: true,
|
||||
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "right" },
|
||||
// #1958 — то же, что для левой оси: подпись вдоль правой оси (повёрнута
|
||||
// на −90°), nameGap чуть больше под формат «14 %», не в легенду.
|
||||
nameLocation: "middle",
|
||||
nameGap: 48,
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
|
||||
axisLabel: {
|
||||
color: FG_SECONDARY,
|
||||
formatter: (v: number) => `${fmtNum(v, 0)} %`,
|
||||
|
|
@ -312,8 +351,50 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
targetHorizon,
|
||||
]);
|
||||
|
||||
// #1958 — детектим вырожденный (плоский) deficit-ряд на тех же данных, что
|
||||
// кормят график: per-scenario forecasts либо fallback forecasts_by_horizon.
|
||||
const isDegenerate = useMemo(() => {
|
||||
const horizons = sortedUniqueHorizons(report);
|
||||
const values: (number | null)[] = hasScenarios
|
||||
? effectiveScenarios.flatMap((key) =>
|
||||
deficitByHorizon(byScenario[key]?.forecasts ?? [], horizons),
|
||||
)
|
||||
: deficitByHorizon(fm.forecasts_by_horizon, horizons);
|
||||
return isDeficitDegenerate(values, collapsed);
|
||||
}, [report, fm, byScenario, hasScenarios, effectiveScenarios, collapsed]);
|
||||
|
||||
// Thin reports (no horizons AND no scenarios) → render nothing.
|
||||
if (!hasHorizons && !hasScenarios) return null;
|
||||
|
||||
return <ChartShell option={option} height={300} notMerge />;
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ChartShell option={option} height={300} notMerge />
|
||||
{isDegenerate && (
|
||||
<div
|
||||
// #1958 — плоская/вырожденная линия почти невидима у края шкалы;
|
||||
// честная серая плашка поверх центра графика объясняет, почему
|
||||
// дифференциации сегментов нет (реальный фикс — backend #1959).
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
maxWidth: 360,
|
||||
padding: "10px 14px",
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-strong)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
color: "var(--fg-secondary)",
|
||||
textAlign: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
Прогноз дефицита плоский — данных недостаточно для дифференциации
|
||||
сегментов.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { PoiList2Gis } from "./PoiList2Gis";
|
|||
import { ExportButtons } from "./ExportButtons";
|
||||
import {
|
||||
adaptEgrn,
|
||||
formatEncumbrance,
|
||||
useParcelAnalyzeQuery,
|
||||
useParcelPoiScoreQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
|
|
@ -193,6 +194,13 @@ export function Section1ParcelInfo({ cad }: Props) {
|
|||
// empty in prod (#1217). Empty `{}` from backend → null → fallback stub.
|
||||
const egrn: ParcelEgrn = adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad);
|
||||
|
||||
// #1954 — обременения живут в отдельном `encumbrance_block` (top-level
|
||||
// `encumbrance`), а НЕ в `egrn_block`, поэтому adaptEgrn по дизайну ставит
|
||||
// «—». Подставляем ЗОУИТ-сводку, чтобы строка «Обременения» ЕГРН-таблицы
|
||||
// показывала реальные данные («ЗОУИТ: N (типы)» / «не выявлено (НСПД)»)
|
||||
// вместо захардкоженного прочерка.
|
||||
egrn.encumbrance = formatEncumbrance(data.encumbrance);
|
||||
|
||||
// KPI values
|
||||
const areaHa =
|
||||
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ export function Section4Estimate({ cad }: Props) {
|
|||
</div>
|
||||
<NspdZouitOverlapsBlock
|
||||
overlaps={analysis.nspd_zouit_overlaps ?? []}
|
||||
parcelCad={cad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Tests for `isDeficitDegenerate` — issue #1958 (flat/degenerate forecast state).
|
||||
*
|
||||
* The §22 deficit line is "flat and uninformative" when either:
|
||||
* (a) the backend collapsed the 3 scenarios (scenarios_collapsed) — the
|
||||
* base/aggressive/conservative deficit lines are identical; OR
|
||||
* (b) every present deficit point is EQUAL and sits at the clamp floor
|
||||
* (|value| ≈ 1) — no segment differentiation, the line hugs the edge and
|
||||
* is near-invisible.
|
||||
*
|
||||
* In either case ForecastChart renders a grey flat-state callout over the chart.
|
||||
* The real differentiation fix is backend #1959 (separate).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isDeficitDegenerate } from "../ForecastChart";
|
||||
|
||||
describe("isDeficitDegenerate — #1958 flat-state detection", () => {
|
||||
it("is degenerate when scenarios_collapsed is true (regardless of values)", () => {
|
||||
expect(isDeficitDegenerate([0.1, 0.2, -0.3], true)).toBe(true);
|
||||
expect(isDeficitDegenerate([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("is degenerate when all points equal AND clamped at +1", () => {
|
||||
expect(isDeficitDegenerate([1, 1, 1], false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is degenerate when all points equal AND clamped at −1", () => {
|
||||
expect(isDeficitDegenerate([-1, -1, -1], false)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats near-1 (clamp floor within eps) as degenerate", () => {
|
||||
expect(isDeficitDegenerate([0.99, 0.99, 0.99], false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is NOT degenerate when all equal but mid-range (flat but informative)", () => {
|
||||
// Flat at e.g. 0.3 is a real balanced signal, not a clamp artefact.
|
||||
expect(isDeficitDegenerate([0.3, 0.3, 0.3], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("is NOT degenerate when points differ (real trajectory)", () => {
|
||||
expect(isDeficitDegenerate([1, 0.5, 0.2], false)).toBe(false);
|
||||
expect(isDeficitDegenerate([-1, -0.8, -0.4], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores nulls and gates on present values only", () => {
|
||||
expect(isDeficitDegenerate([null, 1, null, 1], false)).toBe(true);
|
||||
expect(isDeficitDegenerate([null, 1, 0.5], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("is NOT degenerate when there are no present values", () => {
|
||||
expect(isDeficitDegenerate([null, null], false)).toBe(false);
|
||||
expect(isDeficitDegenerate([], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("a single clamped point counts as degenerate; single mid-range does not", () => {
|
||||
expect(isDeficitDegenerate([1], false)).toBe(true);
|
||||
expect(isDeficitDegenerate([0.4], false)).toBe(false);
|
||||
});
|
||||
});
|
||||
63
frontend/src/lib/__tests__/formatEncumbrance.test.ts
Normal file
63
frontend/src/lib/__tests__/formatEncumbrance.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Tests for `formatEncumbrance` — issue #1954.
|
||||
*
|
||||
* Bug: the ЕГРН-таблица (EgrnPropertyTable) hardcoded the «Обременения» row to
|
||||
* «—», although the backend already ships an `encumbrance` block (top-level,
|
||||
* derived from cad_zouit ∩ parcel geom). `formatEncumbrance` turns that block
|
||||
* into the human row value Section1ParcelInfo writes into `egrn.encumbrance`.
|
||||
*
|
||||
* Wire shape (verified live POST /analyze for 66:41:0205010:287):
|
||||
* { has_zouit: true, zouit_count: 4, zouit_types: ["Санитарно-защитная зона"] }
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatEncumbrance } from "../site-finder-api";
|
||||
|
||||
describe("formatEncumbrance — #1954 ЗОУИТ → «Обременения» row", () => {
|
||||
it("renders «ЗОУИТ: N (types)» when has_zouit and types present", () => {
|
||||
expect(
|
||||
formatEncumbrance({
|
||||
has_zouit: true,
|
||||
zouit_count: 4,
|
||||
zouit_types: ["Санитарно-защитная зона"],
|
||||
}),
|
||||
).toBe("ЗОУИТ: 4 (Санитарно-защитная зона)");
|
||||
});
|
||||
|
||||
it("joins multiple zouit types with comma", () => {
|
||||
expect(
|
||||
formatEncumbrance({
|
||||
has_zouit: true,
|
||||
zouit_count: 3,
|
||||
zouit_types: ["Санитарно-защитная зона", "Охранная зона ЛЭП"],
|
||||
}),
|
||||
).toBe("ЗОУИТ: 3 (Санитарно-защитная зона, Охранная зона ЛЭП)");
|
||||
});
|
||||
|
||||
it("renders «ЗОУИТ: N» (no parens) when has_zouit but types empty", () => {
|
||||
expect(
|
||||
formatEncumbrance({ has_zouit: true, zouit_count: 2, zouit_types: [] }),
|
||||
).toBe("ЗОУИТ: 2");
|
||||
});
|
||||
|
||||
it("filters out blank type strings before joining", () => {
|
||||
expect(
|
||||
formatEncumbrance({
|
||||
has_zouit: true,
|
||||
zouit_count: 1,
|
||||
zouit_types: ["", "Санитарно-защитная зона"],
|
||||
}),
|
||||
).toBe("ЗОУИТ: 1 (Санитарно-защитная зона)");
|
||||
});
|
||||
|
||||
it("renders «не выявлено (НСПД)» when has_zouit is false", () => {
|
||||
expect(
|
||||
formatEncumbrance({ has_zouit: false, zouit_count: 0, zouit_types: [] }),
|
||||
).toBe("не выявлено (НСПД)");
|
||||
});
|
||||
|
||||
it("renders «—» (unknown) when the block is null/undefined (old cache)", () => {
|
||||
expect(formatEncumbrance(null)).toBe("—");
|
||||
expect(formatEncumbrance(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
|
@ -42,11 +42,7 @@ import type {
|
|||
export type ParcelStatus = "free" | "in_progress" | "favorite";
|
||||
|
||||
export type ParcelVri =
|
||||
| "multistory"
|
||||
| "mixed"
|
||||
| "individual"
|
||||
| "office"
|
||||
| "other";
|
||||
"multistory" | "mixed" | "individual" | "office" | "other";
|
||||
|
||||
export interface ParcelBboxItem {
|
||||
cad_num: string;
|
||||
|
|
@ -302,6 +298,37 @@ export interface ParcelEgrn {
|
|||
last_updated: string | null;
|
||||
}
|
||||
|
||||
// ── Encumbrance (#1954) ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Обременения участка — ЗОУИТ-сводка из `encumbrance_block` бэкенда
|
||||
* (`parcels.py`, сериализуется top-level как `encumbrance`). Пересечение геома
|
||||
* участка с `cad_zouit`.
|
||||
*/
|
||||
export interface ParcelEncumbrance {
|
||||
has_zouit: boolean;
|
||||
zouit_count: number;
|
||||
zouit_types: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемая строка обременений для строки «Обременения» ЕГРН-таблицы
|
||||
* (#1954). Раньше ячейка была захардкожена «—», хотя бэкенд уже отдаёт ЗОУИТ-
|
||||
* сводку. has_zouit=true → «ЗОУИТ: N (типы через запятую)»; иначе — честный
|
||||
* «не выявлено (НСПД)». null/отсутствие блока (старый кэш) → «—» (неизвестно).
|
||||
*/
|
||||
export function formatEncumbrance(
|
||||
enc: ParcelEncumbrance | null | undefined,
|
||||
): string {
|
||||
if (enc == null) return "—";
|
||||
if (!enc.has_zouit) return "не выявлено (НСПД)";
|
||||
const types = enc.zouit_types.filter((t) => t && t.length > 0);
|
||||
const count = enc.zouit_count;
|
||||
return types.length > 0
|
||||
? `ЗОУИТ: ${count} (${types.join(", ")})`
|
||||
: `ЗОУИТ: ${count}`;
|
||||
}
|
||||
|
||||
// ── Adapter: backend egrn_block → frontend ParcelEgrn (issue #1217) ──────────
|
||||
|
||||
/**
|
||||
|
|
@ -403,6 +430,15 @@ export interface ParcelAnalyzeResponse {
|
|||
* root cause of #1217. May be `null`/missing if B5-extended hasn't shipped.
|
||||
*/
|
||||
egrn?: unknown;
|
||||
/**
|
||||
* #1954 — обременения участка. Бэкенд (`parcels.py` `encumbrance_block`,
|
||||
* сериализуется как top-level `encumbrance`) пересекает геом участка с
|
||||
* `cad_zouit` и отдаёт ЗОУИТ-сводку. `has_zouit=false` → обременений по НСПД
|
||||
* не выявлено; `has_zouit=true` → `zouit_count` зон, `zouit_types` —
|
||||
* уникальные категории («Санитарно-защитная зона» и т.п.). Optional +
|
||||
* nullable: старый кэш / тонкий 202-стаб поля не несут.
|
||||
*/
|
||||
encumbrance?: ParcelEncumbrance | null;
|
||||
/** Engineering / utilities nearby — present when NSPD data available */
|
||||
utilities?: UtilitiesData | null;
|
||||
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export interface NspdZouitOverlap {
|
|||
// source = "cad_zouit". В nspd-dump path обоих может не быть → optional.
|
||||
type_zone?: string | null;
|
||||
category_name?: string | null;
|
||||
// #1957 — рег. номер границы охранной зоны (cad_zouit fallback path), напр.
|
||||
// "66:41-6.4380". Optional: nspd-dump path может не отдавать поле.
|
||||
reg_numb_border?: string | null;
|
||||
// #1957 — доля площади участка под зоной (0..1). Optional + graceful.
|
||||
coverage_pct?: number | null;
|
||||
source?: string;
|
||||
// #255 — geometry охранной зоны для map-слоя. ST_AsGeoJSON(geom::geometry) →
|
||||
// строка GeoJSON (Polygon/MultiPolygon). Optional + graceful: backend пока может
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue