fix(site-finder): address PR #91 auto-review minor feedback

Backend (parcels.py):
1. (medium) Aggregation loop _neighbors_summary теперь обёрнут в try/except
   (ValueError, TypeError) с fallback к data_available:False + log warning.
   Защищает от non-numeric cost_value/area придёт в строке (e.g. "N/A") —
   ранее весь endpoint падал 500.
2. Magic numbers вынесены: _COST_PER_M2_MIN=1000, _COST_PER_M2_MAX=500_000.
3. _parse_floors docstring + inline note про malformed parts ("5а-7" filter,
   multi-range "1-2-3" max acceptable degradation).

Frontend (NeighborsBlock.tsx):
5. Русский plural fix: pluralBuildings(n) helper — 1 здание, 2-4 здания,
   5+/11-14 зданий. Раньше "3 зданий" — теперь "3 здания".

Не сделано (defer):
4. ST_Area для overlap query — практически 0-5 buildings в ЕКБ, GIST scan OK.
6. Discriminated union для NeighborsSummary — refactor а не bug.
7. Vault entry для P2 — добавится batch'ем после merge всех текущих PR.

Per auto-review on 60d53bb.
This commit is contained in:
lekss361 2026-05-12 01:22:34 +03:00
parent 60d53bbf7d
commit 3706a79898
2 changed files with 45 additions and 17 deletions

View file

@ -289,10 +289,19 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
}
# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда
# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны
# эмпирически для ЕКБ.
_COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода
_COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier
def _parse_floors(raw: str | None) -> int | None:
"""cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2').
"""cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2', '5-7').
Возвращаем верхнюю границу (более консервативный сосед-высотка).
NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для
multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation).
"""
if not raw:
return None
@ -353,22 +362,30 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"}
# Aggregate floors + cost
floors_parsed: list[int] = []
costs_per_m2: list[float] = []
for r in neighbor_rows:
f = _parse_floors(r.get("floors"))
if f is not None and f > 0:
floors_parsed.append(f)
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0:
cost_per_m2 = float(r["cost_value"]) / float(r["area"])
# sanity filter — кадастровая стоимость должна быть >0 + <500K ₽/м²
if 1000 < cost_per_m2 < 500_000:
costs_per_m2.append(cost_per_m2)
# Aggregate floors + cost. Дефенсивный try/except: если cost_value/area
# придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без
# этого guard весь endpoint вернёт 500.
try:
floors_parsed: list[int] = []
costs_per_m2: list[float] = []
for r in neighbor_rows:
f = _parse_floors(r.get("floors"))
if f is not None and f > 0:
floors_parsed.append(f)
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0:
cost_per_m2 = float(r["cost_value"]) / float(r["area"])
if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX:
costs_per_m2.append(cost_per_m2)
avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None
max_floors = max(floors_parsed) if floors_parsed else None
median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None
avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None
max_floors = max(floors_parsed) if floors_parsed else None
median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None
except (ValueError, TypeError) as e:
logger.warning("neighbors aggregation failed: %s", e)
return {
"data_available": False,
"note": f"neighbors aggregation failed: {e}",
}
# Overlap check — что-то построено непосредственно на нашем участке.
# Если хоть один building пересекается с площадью >50 м² — hard warn.

View file

@ -21,6 +21,17 @@ function fmtYearBuilt(y: number | null | undefined): string {
return String(y);
}
// Русский plural: 1 → "здание", 2-4 → "здания", 5+ → "зданий".
// Также корректно для 11-14 (zданий), 21 (здание), 22 (здания) etc.
function pluralBuildings(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return "зданий";
if (mod10 === 1) return "здание";
if (mod10 >= 2 && mod10 <= 4) return "здания";
return "зданий";
}
export function NeighborsBlock({ data }: Props) {
const [expanded, setExpanded] = useState(false);
@ -94,7 +105,7 @@ export function NeighborsBlock({ data }: Props) {
fontVariantNumeric: "tabular-nums",
}}
>
{count} {count === 1 ? "здание" : "зданий"}
{count} {pluralBuildings(count)}
</span>
</div>