fix(site-finder): true-counts вместо молча-усечённых len(list) в connection-points/utility (#2445-A1/A2) #2450
6 changed files with 337 additions and 59 deletions
|
|
@ -65,6 +65,11 @@ class ConnectionPointsSummary(BaseModel):
|
||||||
in_protection_zone: bool
|
in_protection_zone: bool
|
||||||
protection_zones_intersecting: int
|
protection_zones_intersecting: int
|
||||||
total_structures_in_radius: int
|
total_structures_in_radius: int
|
||||||
|
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
|
||||||
|
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
|
||||||
|
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||||
|
protection_zones_truncated: bool = False
|
||||||
|
structures_truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ConnectionPointsResponse(BaseModel):
|
class ConnectionPointsResponse(BaseModel):
|
||||||
|
|
@ -100,6 +105,10 @@ class UtilityInfrastructureSummary(BaseModel):
|
||||||
nearest_distance_m: float | None
|
nearest_distance_m: float | None
|
||||||
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||||
nearest_by_kind: dict[str, float | None]
|
nearest_by_kind: dict[str, float | None]
|
||||||
|
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
|
||||||
|
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
|
||||||
|
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||||
|
features_truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UtilityInfrastructureResponse(BaseModel):
|
class UtilityInfrastructureResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1138,6 +1138,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
||||||
"in_protection_zone": False,
|
"in_protection_zone": False,
|
||||||
"protection_zones_intersecting": 0,
|
"protection_zones_intersecting": 0,
|
||||||
"total_structures_in_radius": 0,
|
"total_structures_in_radius": 0,
|
||||||
|
"protection_zones_truncated": False,
|
||||||
|
"structures_truncated": False,
|
||||||
},
|
},
|
||||||
"dump_available": False,
|
"dump_available": False,
|
||||||
"dump_fetched_at": fetched_at_iso,
|
"dump_fetched_at": fetched_at_iso,
|
||||||
|
|
@ -1166,20 +1168,25 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
||||||
_trigger_harvest(quarter)
|
_trigger_harvest(quarter)
|
||||||
return _empty_unavailable(dump_fetched_at)
|
return _empty_unavailable(dump_fetched_at)
|
||||||
|
|
||||||
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
|
structures, structures_total = _get_engineering_structures_by_boundary(
|
||||||
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
db, quarter, parcel_wkt, radius_m
|
||||||
|
)
|
||||||
|
zouit_overlaps, zouit_total = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
||||||
|
|
||||||
nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None
|
nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None
|
||||||
protection_count = len(zouit_overlaps)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"engineering_structures": structures,
|
"engineering_structures": structures,
|
||||||
"zouit_engineering_overlaps": zouit_overlaps,
|
"zouit_engineering_overlaps": zouit_overlaps,
|
||||||
"summary": {
|
"summary": {
|
||||||
"nearest_structure_distance_m": nearest_dist,
|
"nearest_structure_distance_m": nearest_dist,
|
||||||
"in_protection_zone": protection_count > 0,
|
"in_protection_zone": zouit_total > 0,
|
||||||
"protection_zones_intersecting": protection_count,
|
# Epic #2445 A1: истинные счётчики по ПОЛНОЙ выборке (COUNT(*) без LIMIT),
|
||||||
"total_structures_in_radius": len(structures),
|
# НЕ len(list) — раньше тихо капались на 100/50 (LIMIT в SQL row-fetch).
|
||||||
|
"protection_zones_intersecting": zouit_total,
|
||||||
|
"total_structures_in_radius": structures_total,
|
||||||
|
"protection_zones_truncated": zouit_total > len(zouit_overlaps),
|
||||||
|
"structures_truncated": structures_total > len(structures),
|
||||||
},
|
},
|
||||||
"dump_available": True,
|
"dump_available": True,
|
||||||
"dump_fetched_at": dump_fetched_at,
|
"dump_fetched_at": dump_fetched_at,
|
||||||
|
|
@ -1227,18 +1234,59 @@ def _get_parcel_wkt(db: Session, cad_num: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_ENGINEERING_STRUCTURES_ITEMS_LIMIT = 50
|
||||||
|
|
||||||
|
# Условие радиус-выборки engineering_structures — используется И в row-fetch (с ORDER BY +
|
||||||
|
# LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на 50, но
|
||||||
|
# summary.total_structures_in_radius обязан считаться по ВСЕЙ выборке, не по len(list)).
|
||||||
|
_ENGINEERING_STRUCTURES_WHERE = """
|
||||||
|
AND feat.value->>'layer' = 'engineering_structures'
|
||||||
|
AND (feat.value->'geometry') IS NOT NULL
|
||||||
|
AND feat.value->>'geometry' != 'null'
|
||||||
|
AND ST_DWithin(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography,
|
||||||
|
:radius_m
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _get_engineering_structures_by_boundary(
|
def _get_engineering_structures_by_boundary(
|
||||||
db: Session,
|
db: Session,
|
||||||
quarter: str,
|
quarter: str,
|
||||||
parcel_wkt: str,
|
parcel_wkt: str,
|
||||||
radius_m: int,
|
radius_m: int,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
"""Engineering structures из dump в radius_m от boundary участка.
|
"""Engineering structures из dump в radius_m от boundary участка.
|
||||||
|
|
||||||
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
|
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
|
||||||
Geometry трансформируется из EPSG:3857 (хранение dump) → 4326.
|
Geometry трансформируется из EPSG:3857 (хранение dump) → 4326.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(items, total_count) — items ограничен _ENGINEERING_STRUCTURES_ITEMS_LIMIT
|
||||||
|
(ближайшие), total_count — ЧЕСТНЫЙ COUNT(*) по всей радиус-выборке (epic #2445
|
||||||
|
A1: раньше summary.total_structures_in_radius = len(items) тихо капался на 50).
|
||||||
"""
|
"""
|
||||||
|
params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}
|
||||||
try:
|
try:
|
||||||
|
total_count = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*)"
|
||||||
|
" FROM nspd_quarter_dumps d,"
|
||||||
|
" jsonb_array_elements(d.features_json) AS feat(value)"
|
||||||
|
" WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -1256,26 +1304,14 @@ def _get_engineering_structures_by_boundary(
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM nspd_quarter_dumps d,
|
FROM nspd_quarter_dumps d,
|
||||||
jsonb_array_elements(d.features_json) AS feat(value)
|
jsonb_array_elements(d.features_json) AS feat(value)
|
||||||
WHERE d.quarter_cad = :q
|
WHERE d.quarter_cad = :q"""
|
||||||
AND feat.value->>'layer' = 'engineering_structures'
|
+ _ENGINEERING_STRUCTURES_WHERE
|
||||||
AND (feat.value->'geometry') IS NOT NULL
|
+ """
|
||||||
AND feat.value->>'geometry' != 'null'
|
|
||||||
AND ST_DWithin(
|
|
||||||
ST_Transform(
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
|
||||||
3857
|
|
||||||
),
|
|
||||||
4326
|
|
||||||
)::geography,
|
|
||||||
ST_GeomFromText(:wkt, 4326)::geography,
|
|
||||||
:radius_m
|
|
||||||
)
|
|
||||||
ORDER BY distance_m ASC
|
ORDER BY distance_m ASC
|
||||||
LIMIT 50
|
LIMIT :items_limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
|
{**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except (OperationalError, ProgrammingError, DataError) as e:
|
except (OperationalError, ProgrammingError, DataError) as e:
|
||||||
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
||||||
|
|
@ -1284,7 +1320,7 @@ def _get_engineering_structures_by_boundary(
|
||||||
quarter,
|
quarter,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
return []
|
return [], 0
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
@ -1349,7 +1385,7 @@ def _get_engineering_structures_by_boundary(
|
||||||
"source": "nspd_36328",
|
"source": "nspd_36328",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result, int(total_count)
|
||||||
|
|
||||||
|
|
||||||
def _clean_struct_str(value: Any) -> str | None:
|
def _clean_struct_str(value: Any) -> str | None:
|
||||||
|
|
@ -1390,24 +1426,12 @@ def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
|
||||||
return " · ".join(parts) if parts else None
|
return " · ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
def _get_zouit_engineering_overlaps(
|
_ZOUIT_ENGINEERING_ITEMS_LIMIT = 100
|
||||||
db: Session,
|
|
||||||
quarter: str,
|
|
||||||
parcel_wkt: str,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
|
|
||||||
|
|
||||||
Использует слой 'zouit_engineering' из dump.
|
# Условие пересечения zouit_engineering с участком — используется И в row-fetch (с
|
||||||
"""
|
# ORDER BY + LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на
|
||||||
try:
|
# 100, но summary.protection_zones_intersecting обязан считаться по ВСЕЙ выборке).
|
||||||
rows = db.execute(
|
_ZOUIT_ENGINEERING_WHERE = """
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT feat.value->'properties' AS props,
|
|
||||||
feat.value->>'geometry' AS geom_json
|
|
||||||
FROM nspd_quarter_dumps d,
|
|
||||||
jsonb_array_elements(d.features_json) AS feat(value)
|
|
||||||
WHERE d.quarter_cad = :q
|
|
||||||
AND feat.value->>'layer' = 'zouit_engineering'
|
AND feat.value->>'layer' = 'zouit_engineering'
|
||||||
AND (feat.value->'geometry') IS NOT NULL
|
AND (feat.value->'geometry') IS NOT NULL
|
||||||
AND feat.value->>'geometry' != 'null'
|
AND feat.value->>'geometry' != 'null'
|
||||||
|
|
@ -1421,10 +1445,55 @@ def _get_zouit_engineering_overlaps(
|
||||||
),
|
),
|
||||||
ST_GeomFromText(:wkt, 4326)
|
ST_GeomFromText(:wkt, 4326)
|
||||||
)
|
)
|
||||||
LIMIT 100
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_zouit_engineering_overlaps(
|
||||||
|
db: Session,
|
||||||
|
quarter: str,
|
||||||
|
parcel_wkt: str,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
|
||||||
|
|
||||||
|
Использует слой 'zouit_engineering' из dump.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(items, total_count) — items ограничен _ZOUIT_ENGINEERING_ITEMS_LIMIT,
|
||||||
|
детерминированно отсортирован (epic #2445 A1: раньше не было ORDER BY перед
|
||||||
|
LIMIT 100 → недетерминированные 100 строк на каждый запрос). total_count —
|
||||||
|
ЧЕСТНЫЙ COUNT(*) по всей intersects-выборке (раньше summary.
|
||||||
|
protection_zones_intersecting = len(items) тихо капался на 100).
|
||||||
|
"""
|
||||||
|
params = {"q": quarter, "wkt": parcel_wkt}
|
||||||
|
try:
|
||||||
|
total_count = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*)"
|
||||||
|
" FROM nspd_quarter_dumps d,"
|
||||||
|
" jsonb_array_elements(d.features_json) AS feat(value)"
|
||||||
|
" WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT feat.value->'properties' AS props,
|
||||||
|
feat.value->>'geometry' AS geom_json
|
||||||
|
FROM nspd_quarter_dumps d,
|
||||||
|
jsonb_array_elements(d.features_json) AS feat(value)
|
||||||
|
WHERE d.quarter_cad = :q"""
|
||||||
|
+ _ZOUIT_ENGINEERING_WHERE
|
||||||
|
+ """
|
||||||
|
ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST,
|
||||||
|
feat.value->>'geometry'
|
||||||
|
LIMIT :items_limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"q": quarter, "wkt": parcel_wkt},
|
{**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except (OperationalError, ProgrammingError, DataError) as e:
|
except (OperationalError, ProgrammingError, DataError) as e:
|
||||||
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
||||||
|
|
@ -1433,7 +1502,7 @@ def _get_zouit_engineering_overlaps(
|
||||||
quarter,
|
quarter,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
return []
|
return [], 0
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
@ -1466,7 +1535,7 @@ def _get_zouit_engineering_overlaps(
|
||||||
"source": "nspd_37578",
|
"source": "nspd_37578",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result, int(total_count)
|
||||||
|
|
||||||
|
|
||||||
# ── Harvest trigger ───────────────────────────────────────────────────────────
|
# ── Harvest trigger ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -657,9 +657,30 @@ def get_nearby_utility_infrastructure(
|
||||||
if parcel_wkt is None:
|
if parcel_wkt is None:
|
||||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||||
|
|
||||||
|
# Условие радиус-выборки — общее для честного COUNT(*) (без LIMIT) и row-fetch
|
||||||
|
# (с ORDER BY + LIMIT). Epic #2445 A2: summary.total_features раньше = len(features),
|
||||||
|
# тихо капался на `limit` (default 200) даже когда в радиусе фич больше.
|
||||||
|
where_clause = """
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
u.geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = {"wkt": parcel_wkt, "radius_m": radius_m}
|
||||||
|
|
||||||
|
total_features = (
|
||||||
|
db.execute(
|
||||||
|
text("SELECT COUNT(*) FROM osm_utility_infrastructure_ekb u" + where_clause),
|
||||||
|
params,
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text(
|
||||||
|
"""
|
||||||
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
|
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
|
||||||
u.tags AS tags,
|
u.tags AS tags,
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
|
|
@ -667,16 +688,14 @@ def get_nearby_utility_infrastructure(
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
) AS distance_m,
|
) AS distance_m,
|
||||||
ST_AsGeoJSON(u.geom) AS geojson
|
ST_AsGeoJSON(u.geom) AS geojson
|
||||||
FROM osm_utility_infrastructure_ekb u
|
FROM osm_utility_infrastructure_ekb u"""
|
||||||
WHERE ST_DWithin(
|
+ where_clause
|
||||||
u.geom::geography,
|
+ """
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
|
||||||
CAST(:radius_m AS float)
|
|
||||||
)
|
|
||||||
ORDER BY distance_m ASC
|
ORDER BY distance_m ASC
|
||||||
LIMIT :lim
|
LIMIT :lim
|
||||||
"""),
|
"""
|
||||||
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
|
),
|
||||||
|
{**params, "lim": limit},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -706,11 +725,15 @@ def get_nearby_utility_infrastructure(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
total_features_int = int(total_features)
|
||||||
return {
|
return {
|
||||||
"features": features,
|
"features": features,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_features": len(features),
|
# Epic #2445 A2: честный COUNT(*) по всей радиус-выборке — НЕ len(features)
|
||||||
|
# (которое капалось на `limit`, default 200).
|
||||||
|
"total_features": total_features_int,
|
||||||
"nearest_distance_m": nearest_overall,
|
"nearest_distance_m": nearest_overall,
|
||||||
"nearest_by_kind": nearest_by_kind,
|
"nearest_by_kind": nearest_by_kind,
|
||||||
|
"features_truncated": total_features_int > len(features),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
_get_opportunity_parcels,
|
_get_opportunity_parcels,
|
||||||
_get_red_lines,
|
_get_red_lines,
|
||||||
_get_risk_zones,
|
_get_risk_zones,
|
||||||
|
_get_zouit_engineering_overlaps,
|
||||||
_get_zouit_overlaps,
|
_get_zouit_overlaps,
|
||||||
_zouit_subcategory_type,
|
_zouit_subcategory_type,
|
||||||
derive_quarter_cad,
|
derive_quarter_cad,
|
||||||
|
|
@ -103,10 +104,17 @@ def test_extract_features_by_layer_non_string_layer() -> None:
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||||
"""Мок Session где execute().fetchall() возвращает rows."""
|
"""Мок Session где execute().fetchall() возвращает rows.
|
||||||
|
|
||||||
|
execute().scalar() тоже замокан на len(rows) — epic #2445 A1 добавил отдельный
|
||||||
|
honest-COUNT(*) запрос в _get_engineering_structures_by_boundary /
|
||||||
|
_get_zouit_engineering_overlaps ДО row-fetch; хелперы без true-count-запроса
|
||||||
|
(_get_risk_zones и т.п.) scalar() не вызывают, так что это безопасный default.
|
||||||
|
"""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
mock_result.fetchall.return_value = rows
|
mock_result.fetchall.return_value = rows
|
||||||
|
mock_result.scalar.return_value = len(rows)
|
||||||
db.execute.return_value = mock_result
|
db.execute.return_value = mock_result
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
@ -791,9 +799,10 @@ def _eng_structures(opts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""Прогнать _get_engineering_structures_by_boundary на одной строке с данными opts."""
|
"""Прогнать _get_engineering_structures_by_boundary на одной строке с данными opts."""
|
||||||
geom = '{"type":"Point","coordinates":[0,0]}'
|
geom = '{"type":"Point","coordinates":[0,0]}'
|
||||||
db = _make_mock_db_with_rows([({"options": opts}, geom, 12.3)])
|
db = _make_mock_db_with_rows([({"options": opts}, geom, 12.3)])
|
||||||
return _get_engineering_structures_by_boundary(
|
items, _total = _get_engineering_structures_by_boundary(
|
||||||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500
|
||||||
)
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def test_engineering_purpose_set_when_differs_from_type() -> None:
|
def test_engineering_purpose_set_when_differs_from_type() -> None:
|
||||||
|
|
@ -831,3 +840,94 @@ def test_engineering_purpose_from_object_type_value_distinct() -> None:
|
||||||
def test_engineering_purpose_none_when_no_options() -> None:
|
def test_engineering_purpose_none_when_no_options() -> None:
|
||||||
result = _eng_structures({})
|
result = _eng_structures({})
|
||||||
assert result[0]["purpose"] is None
|
assert result[0]["purpose"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Epic #2445 A1: honest total_count vs capped items list ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_db_count_then_rows(total_count: int, rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||||
|
"""Мок Session: 1-й execute() → COUNT(*).scalar(), 2-й execute() → .fetchall() capped rows."""
|
||||||
|
db = MagicMock()
|
||||||
|
count_result = MagicMock()
|
||||||
|
count_result.scalar.return_value = total_count
|
||||||
|
rows_result = MagicMock()
|
||||||
|
rows_result.fetchall.return_value = rows
|
||||||
|
db.execute.side_effect = [count_result, rows_result]
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_engineering_structures_total_count_exceeds_capped_list() -> None:
|
||||||
|
"""total_count (COUNT(*) без LIMIT) > len(items) (LIMIT 50 row-fetch) → structures_truncated."""
|
||||||
|
geom = '{"type":"Point","coordinates":[0,0]}'
|
||||||
|
# Мок отдаёт только 2 строки (симулирует LIMIT 50 сработавший в БД), но истинный
|
||||||
|
# count в радиусе — 137 (COUNT(*) без LIMIT видит больше, чем row-fetch отдал).
|
||||||
|
rows = [({"options": {}}, geom, 12.3), ({"options": {}}, geom, 45.6)]
|
||||||
|
db = _make_mock_db_count_then_rows(total_count=137, rows=rows)
|
||||||
|
|
||||||
|
items, total = _get_engineering_structures_by_boundary(
|
||||||
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(items) == 2
|
||||||
|
assert total == 137
|
||||||
|
assert total > len(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engineering_structures_not_truncated_when_under_cap() -> None:
|
||||||
|
"""total_count == len(items) (нет капа) → честно нет усечения."""
|
||||||
|
geom = '{"type":"Point","coordinates":[0,0]}'
|
||||||
|
rows = [({"options": {}}, geom, 12.3)]
|
||||||
|
db = _make_mock_db_count_then_rows(total_count=1, rows=rows)
|
||||||
|
|
||||||
|
items, total = _get_engineering_structures_by_boundary(
|
||||||
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zouit_engineering_overlaps_total_count_exceeds_capped_list() -> None:
|
||||||
|
"""total_count (COUNT(*) без LIMIT) > len(items) (LIMIT 100) → protection_zones_truncated."""
|
||||||
|
geom = '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}'
|
||||||
|
rows = [
|
||||||
|
({"reg_numb_border": "RN-001", "subcategory": "17"}, geom),
|
||||||
|
({"reg_numb_border": "RN-002", "subcategory": "17"}, geom),
|
||||||
|
]
|
||||||
|
db = _make_mock_db_count_then_rows(total_count=243, rows=rows)
|
||||||
|
|
||||||
|
items, total = _get_zouit_engineering_overlaps(
|
||||||
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(items) == 2
|
||||||
|
assert total == 243
|
||||||
|
assert total > len(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zouit_engineering_overlaps_order_by_is_present_in_sql() -> None:
|
||||||
|
"""Fix для non-determinism: ORDER BY обязателен перед LIMIT 100 (was missing)."""
|
||||||
|
geom = '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}'
|
||||||
|
db = _make_mock_db_count_then_rows(total_count=1, rows=[({"reg_numb_border": "RN-1"}, geom)])
|
||||||
|
|
||||||
|
_get_zouit_engineering_overlaps(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
|
||||||
|
# 2-й вызов execute() — row-fetch (1-й — COUNT). Проверяем что SQL содержит ORDER BY.
|
||||||
|
row_fetch_sql = str(db.execute.call_args_list[1][0][0])
|
||||||
|
assert "ORDER BY" in row_fetch_sql
|
||||||
|
assert "LIMIT" in row_fetch_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_zouit_engineering_overlaps_query_failure_returns_zero_total() -> None:
|
||||||
|
"""DB exception на COUNT/row-fetch → ([], 0), не крашится (existing graceful contract)."""
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = OperationalError("stmt", {}, Exception("boom"))
|
||||||
|
|
||||||
|
items, total = _get_zouit_engineering_overlaps(
|
||||||
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert items == []
|
||||||
|
assert total == 0
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ Mock-based / pure — без реального Overpass и БД. Покрыва
|
||||||
- _passes_tag_filters: substance-фильтр (man_made=pipeline) + tower:type (man_made=tower)
|
- _passes_tag_filters: substance-фильтр (man_made=pipeline) + tower:type (man_made=tower)
|
||||||
- _upsert_elements: парсинг way→LineString / node→Point, классификация kind,
|
- _upsert_elements: парсинг way→LineString / node→Point, классификация kind,
|
||||||
per-element SAVEPOINT (битый элемент не валит батч), per-kind counts
|
per-element SAVEPOINT (битый элемент не валит батч), per-kind counts
|
||||||
|
- get_nearby_utility_infrastructure: honest total_features vs capped features list
|
||||||
|
(epic #2445 A2 — true COUNT(*) без LIMIT, НЕ len(features))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -12,6 +14,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -593,3 +596,62 @@ async def test_fetch_one_failing_query_does_not_abort_others(monkeypatch: Any) -
|
||||||
# каждое зеркало получило ровно _MAX_RETRIES_PER_ENDPOINT попыток от power
|
# каждое зеркало получило ровно _MAX_RETRIES_PER_ENDPOINT попыток от power
|
||||||
for mirror in ul.OVERPASS_ENDPOINTS[1:]:
|
for mirror in ul.OVERPASS_ENDPOINTS[1:]:
|
||||||
assert client.calls.count(mirror) == ul._MAX_RETRIES_PER_ENDPOINT
|
assert client.calls.count(mirror) == ul._MAX_RETRIES_PER_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_nearby_utility_infrastructure: honest total_features (epic #2445 A2) ────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_feature_row(osm_id: int, distance_m: float) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"osm_id": osm_id,
|
||||||
|
"osm_type": "node",
|
||||||
|
"infrastructure_kind": "power",
|
||||||
|
"name": None,
|
||||||
|
"source_tag": "power=tower",
|
||||||
|
"tags": {},
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"geojson": json.dumps({"type": "Point", "coordinates": [60.6, 56.8]}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_db(total_count: int, capped_rows: list[dict[str, Any]]) -> MagicMock:
|
||||||
|
"""Мок Session: 1-й execute() → COUNT(*).scalar(), 2-й execute() → capped .mappings().all()."""
|
||||||
|
db = MagicMock()
|
||||||
|
count_result = MagicMock()
|
||||||
|
count_result.scalar.return_value = total_count
|
||||||
|
rows_result = MagicMock()
|
||||||
|
rows_result.mappings.return_value.all.return_value = capped_rows
|
||||||
|
db.execute.side_effect = [count_result, rows_result]
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nearby_utility_infrastructure_reports_true_total_when_capped() -> None:
|
||||||
|
"""total_features = честный COUNT(*) (350), НЕ len(features) (капнутый на limit=200)."""
|
||||||
|
capped_rows = [_make_feature_row(i, float(i)) for i in range(200)]
|
||||||
|
db = _make_mock_db(total_count=350, capped_rows=capped_rows)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.site_finder.quarter_dump_lookup._get_parcel_wkt",
|
||||||
|
return_value="POLYGON((0 0,1 0,1 1,0 0))",
|
||||||
|
):
|
||||||
|
result = ul.get_nearby_utility_infrastructure(db, "66:41:0204016:10", radius_m=500)
|
||||||
|
|
||||||
|
assert len(result["features"]) == 200
|
||||||
|
assert result["summary"]["total_features"] == 350
|
||||||
|
assert result["summary"]["features_truncated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nearby_utility_infrastructure_not_truncated_when_under_limit() -> None:
|
||||||
|
"""total_features == len(features) (нет капа) → features_truncated=False."""
|
||||||
|
capped_rows = [_make_feature_row(i, float(i)) for i in range(5)]
|
||||||
|
db = _make_mock_db(total_count=5, capped_rows=capped_rows)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.site_finder.quarter_dump_lookup._get_parcel_wkt",
|
||||||
|
return_value="POLYGON((0 0,1 0,1 1,0 0))",
|
||||||
|
):
|
||||||
|
result = ul.get_nearby_utility_infrastructure(db, "66:41:0204016:10", radius_m=500)
|
||||||
|
|
||||||
|
assert len(result["features"]) == 5
|
||||||
|
assert result["summary"]["total_features"] == 5
|
||||||
|
assert result["summary"]["features_truncated"] is False
|
||||||
|
|
|
||||||
|
|
@ -3794,6 +3794,16 @@ export interface components {
|
||||||
protection_zones_intersecting: number;
|
protection_zones_intersecting: number;
|
||||||
/** Total Structures In Radius */
|
/** Total Structures In Radius */
|
||||||
total_structures_in_radius: number;
|
total_structures_in_radius: number;
|
||||||
|
/**
|
||||||
|
* Protection Zones Truncated
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
protection_zones_truncated: boolean;
|
||||||
|
/**
|
||||||
|
* Structures Truncated
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
structures_truncated: boolean;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* CreateCadastreJobRequest
|
* CreateCadastreJobRequest
|
||||||
|
|
@ -5653,6 +5663,11 @@ export interface components {
|
||||||
nearest_by_kind: {
|
nearest_by_kind: {
|
||||||
[key: string]: number | null;
|
[key: string]: number | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Features Truncated
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
features_truncated: boolean;
|
||||||
};
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue