diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py
index bb853221..bc25a927 100644
--- a/backend/app/services/site_finder/quarter_dump_lookup.py
+++ b/backend/app/services/site_finder/quarter_dump_lookup.py
@@ -160,6 +160,9 @@ def make_empty_result(
"harvest_triggered": harvest_triggered,
"total_features": total_features,
"harvest_eta_seconds": harvest_eta_seconds,
+ # Дампа нет — про риск-слои не известно ничего. None, а не 0:
+ # ноль означал бы «спросили и не нашли».
+ "risks_count": None,
},
}
@@ -297,6 +300,20 @@ def get_quarter_dump_data(
"harvest_triggered": False,
"total_features": total_features,
"harvest_eta_seconds": None,
+ # #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. Нужен, чтобы
+ # отличить «слой опрошен, на участке чисто» от «слой не дал ничего вообще».
+ # Пустой `nspd_risk_zones` сам по себе этих случаев не различает, и фронт
+ # рисовал по нему зелёное «Риски не обнаружены».
+ #
+ # Замер 19.08: risks_count = 0 у ВСЕХ 669 дампов (для сравнения zouit_count > 0
+ # у 581), то есть сегодня признак всегда говорит «не подтверждено». Это и есть
+ # правда: одиннадцать слоёв природного риска ни разу не вернули ни одного
+ # объекта.
+ #
+ # Намеренно НЕ отдаём layers_fetched: `layers_fetched.append(...)` в
+ # nspd_client.py:956 стоит ДО запроса, поэтому как признак «слой опрошен» он
+ # лжёт. Пока append не перенесён после успешного ответа, наружу его нельзя.
+ "risks_count": risks_count,
}
if parcel_wkt is None:
diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py
index 7563d8a9..15d1fdc1 100644
--- a/backend/tests/test_quarter_dump_lookup.py
+++ b/backend/tests/test_quarter_dump_lookup.py
@@ -399,8 +399,7 @@ def _make_zouit_row(
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
geojson_str = (
- '{"type":"Polygon","coordinates":'
- "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
+ '{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
)
rows = [
_make_zouit_row(
@@ -549,3 +548,35 @@ def test_early_exit_partial_counts() -> None:
assert result["nspd_zoning"]["zone_code"] == "66:41-7.2"
assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == []
+
+
+# ── #2934: признак покрытия риск-слоёв доезжает до ответа ──────────────────────
+#
+# Пустой `nspd_risk_zones` неоднозначен: «слой опрошен, на участке чисто» и «слой
+# не дал ничего вообще» выглядят одинаково. Фронт рисовал по нему зелёную плашку
+# «Риски не обнаружены», то есть утверждал результат проверки, которой не было.
+#
+# Замер на проде 19.08.2026: risks_count = 0 у ВСЕХ 669 дампов (для сравнения
+# zouit_count > 0 у 581). Одиннадцать слоёв природного риска ни разу не вернули
+# ни одного объекта.
+
+
+def test_empty_result_reports_unknown_risk_coverage() -> None:
+ """Дампа нет → про риск-слои неизвестно НИЧЕГО.
+
+ Именно None, а не 0: ноль означал бы «спросили и не нашли».
+ """
+ result = make_empty_result()
+
+ assert (
+ "risks_count" in result["nspd_dump"]
+ ), "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»"
+ assert result["nspd_dump"]["risks_count"] is None
+
+
+def test_empty_result_stays_independent_between_calls() -> None:
+ """Контроль: новое поле не сломало mutation-safety (см. шапку файла)."""
+ a = make_empty_result()
+ a["nspd_dump"]["risks_count"] = 42
+
+ assert make_empty_result()["nspd_dump"]["risks_count"] is None
diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
index e461ab9b..a39afb32 100644
--- a/frontend/src/components/site-finder/CpLayerControlPanel.tsx
+++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
@@ -20,7 +20,11 @@ export interface MarketLayerToggle {
key: MarketLayerKey;
label: string;
color: string;
- count: number;
+ /**
+ * Число объектов слоя. `null` — слой ничего не отдал и «0» рядом с измеренными
+ * счётчиками соседей читалось бы как измеренный ноль (#2934); показываем «н/д».
+ */
+ count: number | null;
}
interface Props {
@@ -96,158 +100,161 @@ export function CpLayerControlPanel({
{/* ── Точки подключения (CP) ─────────────────────────────── */}
{data && (
<>
- {/* No dump */}
- {!data.dump_available && (
-
- Дамп квартала не загружен — 0 точек подключения
-
)}
- {data.summary.in_protection_zone && (
-
- В охранной зоне
-
- )}
- {data.summary.protection_zones_intersecting > 0 &&
- !data.summary.in_protection_zone && (
-
- Охранных зон: {data.summary.protection_zones_intersecting}
-
- )}
-
- )}
>
)}
@@ -308,7 +315,9 @@ export function CpLayerControlPanel({
}}
/>
{ml.label}
- {ml.count}
+
+ {ml.count ?? "н/д"}
+
);
})}
diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx
index 27cae5de..fd20992f 100644
--- a/frontend/src/components/site-finder/LandTab.tsx
+++ b/frontend/src/components/site-finder/LandTab.tsx
@@ -107,7 +107,10 @@ export function LandTab({ data }: Props) {
Природные риски (НСПД)
-
+
)}
diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
index 2c45c1bf..0dfdc058 100644
--- a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
+++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
@@ -5,6 +5,15 @@ import type { RiskZone } from "@/types/nspd";
interface Props {
riskZones: RiskZone[] | null | undefined;
parcelAreaSqm?: number | null;
+ /**
+ * #2934: сколько объектов риск-слоёв в дампе квартала. Пустой `riskZones` без
+ * этого признака неоднозначен, и блок рисовал по нему зелёное «Риски не
+ * обнаружены» — то есть утверждал результат проверки, которой не было.
+ *
+ * `undefined`/`null` — дампа нет; `0` — слой не дал объектов на весь квартал;
+ * `>0` — слой отработал, и отсутствие пересечений с участком уже настоящий факт.
+ */
+ quarterRisksCount?: number | null;
}
// Severity mapping: layer key suffix → severity tier
@@ -45,10 +54,52 @@ function formatArea(sqm: number | null): string | null {
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
}
-export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) {
+export function NspdRiskZonesBlock({
+ riskZones,
+ parcelAreaSqm,
+ quarterRisksCount,
+}: Props) {
const zones = riskZones ?? [];
if (zones.length === 0) {
+ // Зелёный success-стиль оставляем ТОЛЬКО когда слой реально что-то отдал по
+ // кварталу: тогда «на участке ничего не пересекается» — измеренный факт.
+ // Замер 19.08: risks_count = 0 у всех 669 дампов, слои risk_* не вернули ни
+ // одного объекта, — то есть сегодня показывается нейтральная ветка.
+ const layerAnswered = (quarterRisksCount ?? 0) > 0;
+ if (!layerAnswered) {
+ return (
+
+
+ Не проверено
+
+
+ Слои природного риска НСПД не вернули объектов по этому кварталу —
+ отсутствие риска не подтверждено
+
+
+ );
+ }
+
return (
0 ? riskZoneList.length : null,
},
{
key: "opportunity",
diff --git a/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx
new file mode 100644
index 00000000..9f4bfd14
--- /dev/null
+++ b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx
@@ -0,0 +1,53 @@
+/**
+ * #2934: блок природных рисков не должен утверждать «рисков нет», когда слой
+ * ничего не отдал.
+ *
+ * Бэкенд ВСЕГДА присылает `nspd_risk_zones` (пустым списком), поэтому условие
+ * показа в LandTab (`!== undefined`) истинно всегда, а ветка `zones.length === 0`
+ * рисовала зелёную плашку «Риски не обнаружены» с текстом «Риск-зоны НСПД на
+ * участке не выявлены».
+ *
+ * Замер на проде 19.08.2026: risks_count = 0 у всех 669 дампов, слои risk_* НСПД
+ * не вернули ни одного объекта — то есть эта плашка показывалась всегда и всегда
+ * без основания.
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { NspdRiskZonesBlock } from "../NspdRiskZonesBlock";
+import type { RiskZone } from "@/types/nspd";
+
+const ZONE: RiskZone = {
+ layer: "risk_flooding",
+ subtype: "затопление",
+ geom_wkt: null,
+ intersection_area_sqm: 1234,
+} as unknown as RiskZone;
+
+describe("NspdRiskZonesBlock (#2934)", () => {
+ it("слой не дал объектов по кварталу → «не проверено», а не «рисков нет»", () => {
+ render();
+
+ expect(screen.getByText("Не проверено")).toBeInTheDocument();
+ expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument();
+ });
+
+ it("дампа нет → тоже «не проверено» (признак null)", () => {
+ render();
+
+ expect(screen.getByText("Не проверено")).toBeInTheDocument();
+ });
+
+ it("слой отработал по кварталу, но участок не пересекает → «рисков не обнаружено» законно", () => {
+ render();
+
+ expect(screen.getByText("Риски не обнаружены")).toBeInTheDocument();
+ });
+
+ it("контроль: найденные зоны рисуются как прежде", () => {
+ render();
+
+ expect(screen.queryByText("Не проверено")).not.toBeInTheDocument();
+ expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts
index dd772f6f..a1190263 100644
--- a/frontend/src/types/nspd.ts
+++ b/frontend/src/types/nspd.ts
@@ -65,6 +65,16 @@ export interface NspdDumpMeta {
// auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest
// не запущен (lock уже взят другим запросом).
harvest_eta_seconds?: number | null;
+ /**
+ * #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА.
+ *
+ * Отличает «слой опрошен, на участке чисто» (>0) от «слой не дал ничего вообще»
+ * (0). Пустой `nspd_risk_zones` сам по себе этих случаев не различает, а UI
+ * рисовал по нему зелёное «Риски не обнаружены».
+ *
+ * `null` — дампа нет, про риск-слои не известно ничего.
+ */
+ risks_count?: number | null;
}
// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field