feat(site-finder): P2 cad_buildings соседи + overlap check (#46) #91

Merged
lekss361 merged 2 commits from feat/site-finder-neighbors into main 2026-05-11 22:30:54 +00:00
2 changed files with 45 additions and 17 deletions
Showing only changes of commit 3706a79898 - Show all commits

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>