fix(ptica): пустой слой риск-зон перестаёт означать «рисков нет» (#2934) #2940

Merged
bot-backend merged 1 commit from fix/2934-risk-coverage-signal into main 2026-08-19 16:34:27 +00:00
8 changed files with 318 additions and 139 deletions

View file

@ -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:

View file

@ -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

View file

@ -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 && (
<div
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
marginBottom: 8,
}}
>
Дамп квартала не загружен 0 точек подключения
</div>
)}
{/* No dump */}
{!data.dump_available && (
<div
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
marginBottom: 8,
}}
>
Дамп квартала не загружен 0 точек подключения
</div>
)}
{/* Empty state */}
{data.dump_available && totalCount === 0 && (
<div style={{ color: "#6b7280", fontSize: 11, marginBottom: 6 }}>
0 точек подключения в этом квартале
</div>
)}
{/* Empty state */}
{data.dump_available && totalCount === 0 && (
<div
style={{ color: "#6b7280", fontSize: 11, marginBottom: 6 }}
>
0 точек подключения в этом квартале
</div>
)}
{/* Toggle-all */}
{totalCount > 0 && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 6,
cursor: "pointer",
fontWeight: 600,
color: "#374151",
}}
>
<input
type="checkbox"
checked={allVisible}
onChange={onToggleAll}
style={{ cursor: "pointer" }}
/>
Показать все ({totalCount})
</label>
)}
{/* Per-category */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px 14px",
}}
>
{CP_ALL_CATEGORIES.map((cat) => {
const structs = grouped.get(cat) ?? [];
const style = CP_CATEGORY_STYLES[cat];
if (structs.length === 0) return null;
const active = visibleCategories.has(cat);
return (
{/* Toggle-all */}
{totalCount > 0 && (
<label
key={cat}
style={{
display: "flex",
alignItems: "center",
gap: 5,
gap: 6,
marginBottom: 6,
cursor: "pointer",
fontWeight: 600,
color: "#374151",
opacity: active ? 1 : 0.45,
transition: "opacity 0.15s",
}}
>
<input
type="checkbox"
checked={active}
onChange={() => onToggleCategory(cat)}
checked={allVisible}
onChange={onToggleAll}
style={{ cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
<span style={{ color: "#9ca3af" }}>{structs.length}</span>
Показать все ({totalCount})
</label>
);
})}
</div>
)}
{/* Summary */}
{data.dump_available && totalCount > 0 && (
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid #f3f4f6",
display: "flex",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
}}
>
{data.summary.nearest_structure_distance_m !== null && (
<span
{/* Per-category */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px 14px",
}}
>
{CP_ALL_CATEGORIES.map((cat) => {
const structs = grouped.get(cat) ?? [];
const style = CP_CATEGORY_STYLES[cat];
if (structs.length === 0) return null;
const active = visibleCategories.has(cat);
return (
<label
key={cat}
style={{
display: "flex",
alignItems: "center",
gap: 5,
cursor: "pointer",
color: "#374151",
opacity: active ? 1 : 0.45,
transition: "opacity 0.15s",
}}
>
<input
type="checkbox"
checked={active}
onChange={() => onToggleCategory(cat)}
style={{ cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
<span style={{ color: "#9ca3af" }}>{structs.length}</span>
</label>
);
})}
</div>
{/* Summary */}
{data.dump_available && totalCount > 0 && (
<div
style={{
background: "#f3f4f6",
color: "#374151",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid #f3f4f6",
display: "flex",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
}}
>
Ближайший:{" "}
{Math.round(data.summary.nearest_structure_distance_m)} м
</span>
{data.summary.nearest_structure_distance_m !== null && (
<span
style={{
background: "#f3f4f6",
color: "#374151",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
}}
>
Ближайший:{" "}
{Math.round(data.summary.nearest_structure_distance_m)} м
</span>
)}
{data.summary.in_protection_zone && (
<span
style={{
background: "#fee2e2",
color: "#991b1b",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
fontWeight: 600,
}}
>
В охранной зоне
</span>
)}
{data.summary.protection_zones_intersecting > 0 &&
!data.summary.in_protection_zone && (
<span
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
}}
>
Охранных зон:{" "}
{data.summary.protection_zones_intersecting}
</span>
)}
</div>
)}
{data.summary.in_protection_zone && (
<span
style={{
background: "#fee2e2",
color: "#991b1b",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
fontWeight: 600,
}}
>
В охранной зоне
</span>
)}
{data.summary.protection_zones_intersecting > 0 &&
!data.summary.in_protection_zone && (
<span
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
}}
>
Охранных зон: {data.summary.protection_zones_intersecting}
</span>
)}
</div>
)}
</>
)}
@ -308,7 +315,9 @@ export function CpLayerControlPanel({
}}
/>
{ml.label}
<span style={{ color: "#9ca3af" }}>{ml.count}</span>
<span style={{ color: "#9ca3af" }}>
{ml.count ?? "н/д"}
</span>
</label>
);
})}

View file

@ -107,7 +107,10 @@ export function LandTab({ data }: Props) {
<SectionLabel style={{ marginBottom: 10 }}>
Природные риски (НСПД)
</SectionLabel>
<NspdRiskZonesBlock riskZones={data.nspd_risk_zones} />
<NspdRiskZonesBlock
riskZones={data.nspd_risk_zones}
quarterRisksCount={data.nspd_dump?.risks_count}
/>
</div>
)}

View file

@ -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 (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: 8,
padding: "10px 14px",
}}
>
<span
style={{
background: "#e2e8f0",
color: "#475569",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
fontWeight: 600,
}}
>
Не проверено
</span>
<span style={{ fontSize: 13, color: "#475569" }}>
Слои природного риска НСПД не вернули объектов по этому кварталу
отсутствие риска не подтверждено
</span>
</div>
);
}
return (
<div
style={{

View file

@ -949,7 +949,12 @@ export function SiteMap({
key: "risk",
label: "Зоны риска",
color: MARKET_COLORS.risk,
count: riskZoneList.length,
// #2934: «0» в одном ряду с измеренными «Конкуренты 14» и
// «Красные линии 2» читается как измеренный ноль. Слои
// природного риска НСПД не вернули ни одного объекта на 669
// дампов (замер 19.08), поэтому при пустом слое показываем
// «н/д», а не число.
count: riskZoneList.length > 0 ? riskZoneList.length : null,
},
{
key: "opportunity",

View file

@ -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(<NspdRiskZonesBlock riskZones={[]} quarterRisksCount={0} />);
expect(screen.getByText("Не проверено")).toBeInTheDocument();
expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument();
});
it("дампа нет → тоже «не проверено» (признак null)", () => {
render(<NspdRiskZonesBlock riskZones={[]} quarterRisksCount={null} />);
expect(screen.getByText("Не проверено")).toBeInTheDocument();
});
it("слой отработал по кварталу, но участок не пересекает → «рисков не обнаружено» законно", () => {
render(<NspdRiskZonesBlock riskZones={[]} quarterRisksCount={7} />);
expect(screen.getByText("Риски не обнаружены")).toBeInTheDocument();
});
it("контроль: найденные зоны рисуются как прежде", () => {
render(<NspdRiskZonesBlock riskZones={[ZONE]} quarterRisksCount={7} />);
expect(screen.queryByText("Не проверено")).not.toBeInTheDocument();
expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument();
});
});

View file

@ -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