diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index dc59b20c..13bac11e 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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. diff --git a/frontend/src/components/site-finder/NeighborsBlock.tsx b/frontend/src/components/site-finder/NeighborsBlock.tsx index b6cba225..f83ec87a 100644 --- a/frontend/src/components/site-finder/NeighborsBlock.tsx +++ b/frontend/src/components/site-finder/NeighborsBlock.tsx @@ -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)}