All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
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) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m23s
_compute_confidence получал зону ТОЛЬКО из legacy-словаря `zoning`, который
питается таблицей pzz_zones_ekb. На проде она пуста — 0 строк (замер 19.08),
поэтому data_available там всегда False.
Настоящая зона живёт в nspd_zoning: территориальные зоны дампа НСПД, а для
участков в зазорах между зонами она синтезируется резолвером геопортала (см.
PR-A #financial-zoning-decouple). Из-за этого подскор «zoning» был 0.2 у КАЖДОГО
участка, а оговорка утверждала неправду.
Прогон analyze в прод-контейнере, участок 66:41:0402029:25:
nspd_zoning.zone_code = 'Ж-5' <- зона известна
zoning.data_available = False
confidence = 0.61 medium
подскор zoning = 0.2
оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое
использование (G1 pending)»
Один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна. Подскоров
семь, цена в композите — (1.0 − 0.2)/7 = 0.114: 0.61 вместо 0.72.
Тесты переписаны после самопроверки: первая версия падала на origin/main с
TypeError, то есть красное означало «возможности нет», а не «значение неверно», и
контроли падали там же — контролями они не были. Теперь контроли не передают новый
аргумент и зелёные по обе стороны, а тест на уровне ручки красный ПО ПОВЕДЕНИЮ:
«ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна». Он же
сторожит проводку — параметр можно добавить в функцию и забыть передать.
Переформатирование — от pre-commit ruff 0.7.4 (#2864), не моё.
147 lines
7.1 KiB
Python
147 lines
7.1 KiB
Python
"""#2464: достоверность должна видеть зону ПЗЗ из ОБОИХ источников.
|
||
|
||
Зона приходит двумя путями:
|
||
|
||
* `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она
|
||
**пуста** (0 строк, замер 19.08.2026), поэтому `data_available` там всегда False;
|
||
* `nspd_zoning` — территориальные зоны дампа НСПД, а для участков в зазорах между
|
||
зонами зона синтезируется резолвером геопортала.
|
||
|
||
Пока в `_compute_confidence` передавали только первый, подскор «zoning» был 0.2 у
|
||
КАЖДОГО участка, а оговорка утверждала неправду. Прогон analyze на проде, участок
|
||
66:41:0402029:25 (вызов функции в живом контейнере):
|
||
|
||
nspd_zoning.zone_code = 'Ж-5' <- зона известна
|
||
zoning.data_available = False
|
||
confidence = 0.61 medium
|
||
подскор zoning = 0.2
|
||
оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое использование»
|
||
|
||
То есть один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна.
|
||
Подскоров семь, цена ошибки в композите — (1.0 − 0.2) / 7 = 0.114.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from app.api.v1.parcels import _compute_confidence
|
||
|
||
_CAVEAT = "ПЗЗ zone_code не известен"
|
||
|
||
|
||
def _confidence(**over: Any) -> dict[str, Any]:
|
||
"""Вызов с общими аргументами.
|
||
|
||
`nspd_zoning` НЕ передаётся, если его явно не попросили: тесты-контроли обязаны
|
||
исполняться и на origin/main, где такого параметра ещё нет. Иначе они падали бы
|
||
там с TypeError и контролями не были бы — красное «нет такой возможности»
|
||
неотличимо от красного «значение неверно».
|
||
"""
|
||
base: dict[str, Any] = {
|
||
"source": "cad_quarter",
|
||
"poi_rows": [],
|
||
"district_row": None,
|
||
"competitor_rows": [],
|
||
"noise_map_rows_nearby": 0,
|
||
"air_q": None,
|
||
"weather": None,
|
||
"market_trend": None,
|
||
"zoning": {"data_available": False},
|
||
}
|
||
base.update(over)
|
||
return _compute_confidence(**base)
|
||
|
||
|
||
def test_nspd_zone_counts_as_known() -> None:
|
||
"""Зона есть только в nspd_zoning → подскор полный, ложной оговорки нет."""
|
||
res = _confidence(nspd_zoning={"zone_code": "Ж-5"})
|
||
|
||
assert res["breakdown"]["zoning"] == 1.0
|
||
assert not any(
|
||
_CAVEAT in c for c in res["caveats"]
|
||
), "оговорка «зона неизвестна» при известной зоне Ж-5 — ровно то, что видел прод"
|
||
|
||
|
||
def test_regulation_zone_index_also_counts() -> None:
|
||
"""Синтезированная геопорталом зона несёт regulation_zone_index, а не zone_code."""
|
||
res = _confidence(nspd_zoning={"regulation_zone_index": "Ж-2"})
|
||
assert res["breakdown"]["zoning"] == 1.0
|
||
|
||
|
||
def test_no_zone_anywhere_keeps_the_caveat() -> None:
|
||
"""Контроль: когда зоны нет НИ В ОДНОМ источнике, оговорка остаётся.
|
||
|
||
Зелёный по обе стороны правки — иначе правка просто глушила бы предупреждение.
|
||
"""
|
||
res = _confidence() # без nspd_zoning — исполняется и на origin/main
|
||
assert res["breakdown"]["zoning"] == 0.2
|
||
assert any(_CAVEAT in c for c in res["caveats"])
|
||
|
||
|
||
def test_legacy_source_still_honoured() -> None:
|
||
"""Контроль: если старый слой когда-нибудь наполнится, он по-прежнему считается."""
|
||
res = _confidence(zoning={"data_available": True}) # без nspd_zoning
|
||
assert res["breakdown"]["zoning"] == 1.0
|
||
|
||
|
||
def test_composite_gain_matches_the_measured_cost() -> None:
|
||
"""Цена дефекта в композите — ровно 0.114, как посчитано по проду."""
|
||
without = _confidence()
|
||
with_zone = _confidence(nspd_zoning={"zone_code": "Ж-5"})
|
||
|
||
assert len(without["breakdown"]) == 7, "число подскоров изменилось — пересчитать цену"
|
||
assert round(with_zone["value"] - without["value"], 2) == 0.11
|
||
|
||
|
||
# ── поведенческая проверка на уровне ручки + проводка ───────────────────────────
|
||
#
|
||
# Проверки выше падают на origin/main с TypeError — это «возможности нет», а не
|
||
# «значение неверно». Тест ниже красный ПО ПОВЕДЕНИЮ: на origin/main ручка вернёт
|
||
# ложную оговорку при известной зоне. Он же сторожит проводку: параметр можно
|
||
# добавить в функцию и забыть передать на вызове, и все проверки выше останутся
|
||
# зелёными.
|
||
|
||
|
||
def test_analyze_does_not_claim_unknown_zone_when_nspd_resolved_it() -> None:
|
||
from unittest.mock import patch
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.core.db import get_db
|
||
from app.main import app
|
||
from tests.api.v1.test_analyze_market_price import (
|
||
_make_db_for_analyze,
|
||
_override_db,
|
||
_start_patches,
|
||
_stop_patches,
|
||
)
|
||
|
||
db = _make_db_for_analyze()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
_start_patches()
|
||
try:
|
||
with patch(
|
||
"app.api.v1.parcels.get_quarter_dump_data",
|
||
return_value={
|
||
"nspd_zoning": {"zone_code": "Ж-5", "zone_name": "Многоэтажная жилая"},
|
||
"nspd_zouit_overlaps": [],
|
||
"nspd_engineering_nearby": [],
|
||
"nspd_dump": {"available": True, "stale": False, "harvest_triggered": False},
|
||
},
|
||
):
|
||
resp = TestClient(app).post("/api/v1/parcels/66:41:0204016:10/analyze")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
_stop_patches()
|
||
|
||
assert (body.get("nspd_zoning") or {}).get(
|
||
"zone_code"
|
||
) == "Ж-5", "предусловие теста не выполнено: зона не доехала до ответа"
|
||
caveats = " ".join(body.get("confidence_caveats") or [])
|
||
assert (
|
||
_CAVEAT not in caveats
|
||
), "ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна"
|
||
assert (body.get("confidence_breakdown") or {}).get("zoning") == 1.0
|