fix(ptica): пустой слой риск-зон перестаёт означать «рисков нет» (#2934)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 17m34s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 17m34s
Бэкенд ВСЕГДА присылает nspd_risk_zones — пустым списком, если ничего нет.
Условие показа в LandTab (`!== undefined`) поэтому истинно всегда, а ветка
zones.length === 0 рисовала ЗЕЛЁНУЮ плашку «Риски не обнаружены» с текстом
«Риск-зоны НСПД на участке не выявлены».
Пустой список неоднозначен: «слой опрошен, на участке чисто» и «слой не дал
ничего вообще» выглядят одинаково. Замер на проде 19.08: risks_count = 0 у ВСЕХ
669 дампов (для сравнения zouit_count > 0 у 581) — одиннадцать слоёв природного
риска ни разу не вернули ни одного объекта. То есть плашка показывалась всегда и
всегда без основания.
Провёл наружу risks_count из строки дампа (он уже вычитывался, в ответ не
попадал). Три состояния вместо двух:
risks_count > 0 -> «Риски не обнаружены» — законно, слой отработал
risks_count == 0 -> «Не проверено», нейтральным серым
risks_count is None -> «Не проверено» (дампа нет; None, а не 0, потому что
ноль означал бы «спросили и не нашли»)
layers_fetched наружу НЕ отдаю: append в nspd_client.py:956 стоит ДО запроса,
поэтому как признак «слой опрошен» он лжёт. Записано в коде, чтобы следующий
читатель не подхватил его как готовый признак.
Заодно счётчик слоя в тумблере карты: при пустом слое «н/д» вместо «0». Ноль в
одном ряду с измеренными «Конкуренты 14» и «Красные линии 2» читался как
измеренный ноль.
Тесты двусторонние: на origin/main обе проверки падают с «Unable to find an
element with the text: Не проверено»; два контроля (слой отработал → зелёная
плашка законна; зоны найдены → карточки) зелёные с обеих сторон.
Большой дифф CpLayerControlPanel.tsx (267 строк при правке в две) — работа
pre-commit prettier: файл НЕ проходил prettier уже на origin/main (проверено
отдельно), хук нормализовал его целиком, когда я его тронул. Тот же класс, что
расхождение ruff в #2864: хук гоняется только по staged-файлам, поэтому файлы,
которых давно не касались, остаются нестандартизованными.
tsc --noEmit rc=0 · vitest 35 файлов / 275 тестов rc=0 (перепрогнано ПОСЛЕ хуков)
pytest tests/test_quarter_dump_lookup.py: 23 passed
pytest tests/api/v1 (контракт ответа): 354 passed, 1 skipped, rc=0
Refs #2934
This commit is contained in:
parent
a2fbe4b400
commit
a64119ad8f
8 changed files with 318 additions and 139 deletions
|
|
@ -160,6 +160,9 @@ def make_empty_result(
|
||||||
"harvest_triggered": harvest_triggered,
|
"harvest_triggered": harvest_triggered,
|
||||||
"total_features": total_features,
|
"total_features": total_features,
|
||||||
"harvest_eta_seconds": harvest_eta_seconds,
|
"harvest_eta_seconds": harvest_eta_seconds,
|
||||||
|
# Дампа нет — про риск-слои не известно ничего. None, а не 0:
|
||||||
|
# ноль означал бы «спросили и не нашли».
|
||||||
|
"risks_count": None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,6 +300,20 @@ def get_quarter_dump_data(
|
||||||
"harvest_triggered": False,
|
"harvest_triggered": False,
|
||||||
"total_features": total_features,
|
"total_features": total_features,
|
||||||
"harvest_eta_seconds": None,
|
"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:
|
if parcel_wkt is None:
|
||||||
|
|
|
||||||
|
|
@ -399,8 +399,7 @@ def _make_zouit_row(
|
||||||
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
|
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
|
||||||
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
|
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
|
||||||
geojson_str = (
|
geojson_str = (
|
||||||
'{"type":"Polygon","coordinates":'
|
'{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
|
||||||
"[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
|
|
||||||
)
|
)
|
||||||
rows = [
|
rows = [
|
||||||
_make_zouit_row(
|
_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_zoning"]["zone_code"] == "66:41-7.2"
|
||||||
assert result["nspd_zouit_overlaps"] == []
|
assert result["nspd_zouit_overlaps"] == []
|
||||||
assert result["nspd_engineering_nearby"] == []
|
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
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ export interface MarketLayerToggle {
|
||||||
key: MarketLayerKey;
|
key: MarketLayerKey;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
count: number;
|
/**
|
||||||
|
* Число объектов слоя. `null` — слой ничего не отдал и «0» рядом с измеренными
|
||||||
|
* счётчиками соседей читалось бы как измеренный ноль (#2934); показываем «н/д».
|
||||||
|
*/
|
||||||
|
count: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -96,158 +100,161 @@ export function CpLayerControlPanel({
|
||||||
{/* ── Точки подключения (CP) ─────────────────────────────── */}
|
{/* ── Точки подключения (CP) ─────────────────────────────── */}
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
{/* No dump */}
|
{/* No dump */}
|
||||||
{!data.dump_available && (
|
{!data.dump_available && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fef3c7",
|
background: "#fef3c7",
|
||||||
color: "#92400e",
|
color: "#92400e",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Дамп квартала не загружен — 0 точек подключения
|
Дамп квартала не загружен — 0 точек подключения
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{data.dump_available && totalCount === 0 && (
|
{data.dump_available && totalCount === 0 && (
|
||||||
<div style={{ color: "#6b7280", fontSize: 11, marginBottom: 6 }}>
|
<div
|
||||||
0 точек подключения в этом квартале
|
style={{ color: "#6b7280", fontSize: 11, marginBottom: 6 }}
|
||||||
</div>
|
>
|
||||||
)}
|
0 точек подключения в этом квартале
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toggle-all */}
|
{/* Toggle-all */}
|
||||||
{totalCount > 0 && (
|
{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 (
|
|
||||||
<label
|
<label
|
||||||
key={cat}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 5,
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
opacity: active ? 1 : 0.45,
|
|
||||||
transition: "opacity 0.15s",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={active}
|
checked={allVisible}
|
||||||
onChange={() => onToggleCategory(cat)}
|
onChange={onToggleAll}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
/>
|
/>
|
||||||
<span
|
Показать все ({totalCount})
|
||||||
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>
|
</label>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Per-category */}
|
||||||
{data.dump_available && totalCount > 0 && (
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: "flex",
|
||||||
marginTop: 8,
|
flexWrap: "wrap",
|
||||||
paddingTop: 8,
|
gap: "4px 14px",
|
||||||
borderTop: "1px solid #f3f4f6",
|
}}
|
||||||
display: "flex",
|
>
|
||||||
flexWrap: "wrap",
|
{CP_ALL_CATEGORIES.map((cat) => {
|
||||||
gap: 6,
|
const structs = grouped.get(cat) ?? [];
|
||||||
alignItems: "center",
|
const style = CP_CATEGORY_STYLES[cat];
|
||||||
}}
|
if (structs.length === 0) return null;
|
||||||
>
|
const active = visibleCategories.has(cat);
|
||||||
{data.summary.nearest_structure_distance_m !== null && (
|
return (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: "#f3f4f6",
|
marginTop: 8,
|
||||||
color: "#374151",
|
paddingTop: 8,
|
||||||
borderRadius: 6,
|
borderTop: "1px solid #f3f4f6",
|
||||||
padding: "2px 8px",
|
display: "flex",
|
||||||
fontSize: 11,
|
flexWrap: "wrap",
|
||||||
|
gap: 6,
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ближайший:{" "}
|
{data.summary.nearest_structure_distance_m !== null && (
|
||||||
{Math.round(data.summary.nearest_structure_distance_m)} м
|
<span
|
||||||
</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}
|
{ml.label}
|
||||||
<span style={{ color: "#9ca3af" }}>{ml.count}</span>
|
<span style={{ color: "#9ca3af" }}>
|
||||||
|
{ml.count ?? "н/д"}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,10 @@ export function LandTab({ data }: Props) {
|
||||||
<SectionLabel style={{ marginBottom: 10 }}>
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
Природные риски (НСПД)
|
Природные риски (НСПД)
|
||||||
</SectionLabel>
|
</SectionLabel>
|
||||||
<NspdRiskZonesBlock riskZones={data.nspd_risk_zones} />
|
<NspdRiskZonesBlock
|
||||||
|
riskZones={data.nspd_risk_zones}
|
||||||
|
quarterRisksCount={data.nspd_dump?.risks_count}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ import type { RiskZone } from "@/types/nspd";
|
||||||
interface Props {
|
interface Props {
|
||||||
riskZones: RiskZone[] | null | undefined;
|
riskZones: RiskZone[] | null | undefined;
|
||||||
parcelAreaSqm?: number | null;
|
parcelAreaSqm?: number | null;
|
||||||
|
/**
|
||||||
|
* #2934: сколько объектов риск-слоёв в дампе квартала. Пустой `riskZones` без
|
||||||
|
* этого признака неоднозначен, и блок рисовал по нему зелёное «Риски не
|
||||||
|
* обнаружены» — то есть утверждал результат проверки, которой не было.
|
||||||
|
*
|
||||||
|
* `undefined`/`null` — дампа нет; `0` — слой не дал объектов на весь квартал;
|
||||||
|
* `>0` — слой отработал, и отсутствие пересечений с участком уже настоящий факт.
|
||||||
|
*/
|
||||||
|
quarterRisksCount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Severity mapping: layer key suffix → severity tier
|
// 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")} м²`;
|
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) {
|
export function NspdRiskZonesBlock({
|
||||||
|
riskZones,
|
||||||
|
parcelAreaSqm,
|
||||||
|
quarterRisksCount,
|
||||||
|
}: Props) {
|
||||||
const zones = riskZones ?? [];
|
const zones = riskZones ?? [];
|
||||||
|
|
||||||
if (zones.length === 0) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -949,7 +949,12 @@ export function SiteMap({
|
||||||
key: "risk",
|
key: "risk",
|
||||||
label: "Зоны риска",
|
label: "Зоны риска",
|
||||||
color: MARKET_COLORS.risk,
|
color: MARKET_COLORS.risk,
|
||||||
count: riskZoneList.length,
|
// #2934: «0» в одном ряду с измеренными «Конкуренты 14» и
|
||||||
|
// «Красные линии 2» читается как измеренный ноль. Слои
|
||||||
|
// природного риска НСПД не вернули ни одного объекта на 669
|
||||||
|
// дампов (замер 19.08), поэтому при пустом слое показываем
|
||||||
|
// «н/д», а не число.
|
||||||
|
count: riskZoneList.length > 0 ? riskZoneList.length : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "opportunity",
|
key: "opportunity",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -65,6 +65,16 @@ export interface NspdDumpMeta {
|
||||||
// auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest
|
// auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest
|
||||||
// не запущен (lock уже взят другим запросом).
|
// не запущен (lock уже взят другим запросом).
|
||||||
harvest_eta_seconds?: number | null;
|
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
|
// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue