fix(nspd): rename migration 89→98, fix red lines ST_Area→ST_Length (#220)

- data/sql/98_*: rename from 89 to avoid collision with existing 89_drop_dead_brin
- _get_red_lines: ST_Length(ST_Intersection(planar)::geography) instead of
  ST_Area(ST_Intersection(::geography, ::geography)) — fixes PostGIS 3.4
  tolerance error and returns correct non-zero length for LINESTRING intersections
- Rename intersection_area_sqm → intersection_length_m across schema, TS types,
  frontend component, and tests; add test_get_red_lines_db_exception_returns_empty

Addresses review-bot feedback on PR #220.
This commit is contained in:
lekss361 2026-05-16 19:33:14 +03:00
parent ed3c128528
commit fdb54834e5
6 changed files with 47 additions and 34 deletions

View file

@ -105,17 +105,18 @@ class RedLine(BaseModel):
Поля: Поля:
geom_wkt: WKT геометрии линии в EPSG:4326 geom_wkt: WKT геометрии линии в EPSG:4326
intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) intersection_length_m: длина пересечения линии с участком, м
(null если только nearby, не intersect)
distance_m: расстояние от ближайшей точки линии до участка, м distance_m: расстояние от ближайшей точки линии до участка, м
(null если линия пересекает участок) (null если линия пересекает участок)
UI-семантика: UI-семантика:
intersection_area_sqm != null красная линия ПЕРЕСЕКАЕТ участок (ALERT) intersection_length_m != null красная линия ПЕРЕСЕКАЕТ участок (ALERT)
distance_m != null красная линия рядом (warning) distance_m != null красная линия рядом (warning)
""" """
geom_wkt: str | None geom_wkt: str | None
intersection_area_sqm: float | None # null если только nearby, не intersect intersection_length_m: float | None # null если только nearby, не intersect
distance_m: float | None # null если intersect distance_m: float | None # null если intersect

View file

@ -685,11 +685,15 @@ def _get_red_lines(
"""TIER 4: красные линии застройки (layer 879243). """TIER 4: красные линии застройки (layer 879243).
Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями: Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями:
geom_wkt, intersection_area_sqm, distance_m. geom_wkt, intersection_length_m, distance_m.
Логика: Логика:
- intersecting: intersection_area_sqm > 0, distance_m = None - intersecting: intersection_length_m >= 0, distance_m = None
- nearby only: intersection_area_sqm = None, distance_m = расстояние - nearby only: intersection_length_m = None, distance_m = расстояние
ST_Intersection выполняется в planar EPSG:4326, затем результат кастуется
в ::geography для ST_Length это избегает PostGIS 3.4 tolerance bug
(ST_Intersection geography × geography на LINESTRING бросает transform error).
layer_counts денормализованные счётчики. Если red_lines_count == 0 layer_counts денормализованные счётчики. Если red_lines_count == 0
пропускаем heavy jsonb_array_elements scan. пропускаем heavy jsonb_array_elements scan.
@ -719,7 +723,7 @@ def _get_red_lines(
), ),
ST_GeomFromText(:wkt, 4326) ST_GeomFromText(:wkt, 4326)
) AS does_intersect, ) AS does_intersect,
ST_Area( ST_Length(
ST_Intersection( ST_Intersection(
ST_Transform( ST_Transform(
ST_SetSRID( ST_SetSRID(
@ -727,10 +731,10 @@ def _get_red_lines(
3857 3857
), ),
4326 4326
)::geography, ),
ST_GeomFromText(:wkt, 4326)::geography ST_GeomFromText(:wkt, 4326)
) )::geography
) AS intersection_area_sqm, ) AS intersection_length_m,
ST_Distance( ST_Distance(
ST_Transform( ST_Transform(
ST_SetSRID( ST_SetSRID(
@ -769,14 +773,14 @@ def _get_red_lines(
for r in rows: for r in rows:
geom_wkt_val: str | None = r[0] geom_wkt_val: str | None = r[0]
does_intersect: bool = bool(r[1]) if r[1] is not None else False does_intersect: bool = bool(r[1]) if r[1] is not None else False
raw_area: Any = r[2] raw_length: Any = r[2]
raw_dist: Any = r[3] raw_dist: Any = r[3]
intersection_area_sqm: float | None = None intersection_length_m: float | None = None
if does_intersect and raw_area is not None: if does_intersect and raw_length is not None:
try: try:
val = float(raw_area) val = float(raw_length)
intersection_area_sqm = round(val, 1) if not math.isnan(val) else None intersection_length_m = round(val, 1) if not math.isnan(val) else None
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
@ -790,7 +794,7 @@ def _get_red_lines(
result.append( result.append(
{ {
"geom_wkt": geom_wkt_val, "geom_wkt": geom_wkt_val,
"intersection_area_sqm": intersection_area_sqm, "intersection_length_m": intersection_length_m,
"distance_m": distance_m, "distance_m": distance_m,
} }
) )

View file

@ -297,13 +297,13 @@ def test_get_opportunity_parcels_db_exception_returns_empty() -> None:
def test_get_red_lines_intersects() -> None: def test_get_red_lines_intersects() -> None:
"""Red line intersecting parcel → intersection_area_sqm filled, distance_m=None.""" """Red line intersecting parcel → intersection_length_m filled, distance_m=None."""
rows: list[tuple[Any, ...]] = [ rows: list[tuple[Any, ...]] = [
# (geom_wkt, does_intersect, intersection_area_sqm, distance_m) # (geom_wkt, does_intersect, intersection_length_m, distance_m)
( (
"LINESTRING(0 0, 1 1)", "LINESTRING(0 0, 1 1)",
True, True,
500.0, 45.3, # длина пересечения в метрах
0.0, # intersecting → distance_m becomes None in result 0.0, # intersecting → distance_m becomes None in result
), ),
] ]
@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None:
assert len(result) == 1 assert len(result) == 1
r = result[0] r = result[0]
assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)"
assert r["intersection_area_sqm"] == 500.0 assert r["intersection_length_m"] == 45.3
assert r["distance_m"] is None # null when intersecting assert r["distance_m"] is None # null when intersecting
def test_get_red_lines_nearby_only() -> None: def test_get_red_lines_nearby_only() -> None:
"""Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None.""" """Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None."""
rows: list[tuple[Any, ...]] = [ rows: list[tuple[Any, ...]] = [
# (geom_wkt, does_intersect, intersection_area_sqm, distance_m) # (geom_wkt, does_intersect, intersection_length_m, distance_m)
( (
"LINESTRING(10 10, 20 20)", "LINESTRING(10 10, 20 20)",
False, False,
@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None:
assert len(result) == 1 assert len(result) == 1
r = result[0] r = result[0]
assert r["intersection_area_sqm"] is None assert r["intersection_length_m"] is None
assert r["distance_m"] == 85.5 assert r["distance_m"] == 85.5
def test_get_red_lines_db_exception_returns_empty() -> None:
"""DB exception → пустой список (не propagate)."""
db = MagicMock()
db.execute.side_effect = Exception("DB connection lost")
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert result == []
def test_get_red_lines_empty() -> None: def test_get_red_lines_empty() -> None:
"""Нет red lines features — пустой список.""" """Нет red lines features — пустой список."""
db = _make_mock_db_with_rows([]) db = _make_mock_db_with_rows([])

View file

@ -1,4 +1,4 @@
-- 89_nspd_quarter_dumps_opportunity_flag.sql -- 98_nspd_quarter_dumps_opportunity_flag.sql
-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps. -- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps.
-- Enables fast map filter "find quarters with auction parcels" without -- Enables fast map filter "find quarters with auction parcels" without
-- unpacking features_json JSONB on every query. -- unpacking features_json JSONB on every query.

View file

@ -6,10 +6,10 @@ interface Props {
redLines: RedLine[] | null | undefined; redLines: RedLine[] | null | undefined;
} }
function formatArea(sqm: number | null): string | null { function formatLength(m: number | null): string | null {
if (sqm === null) return null; if (m === null) return null;
if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; return `${Math.round(m)} м`;
} }
function formatDistance(m: number | null): string | null { function formatDistance(m: number | null): string | null {
@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) {
return null; return null;
} }
const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); const intersecting = lines.filter((l) => l.intersection_length_m !== null);
const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null); const nearbyOnly = lines.filter((l) => l.intersection_length_m === null);
const hasIntersect = intersecting.length > 0; const hasIntersect = intersecting.length > 0;
return ( return (
@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
</div> </div>
<div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}> <div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}>
{intersecting.map((l, idx) => { {intersecting.map((l, idx) => {
const areaStr = formatArea(l.intersection_area_sqm); const lenStr = formatLength(l.intersection_length_m);
return ( return (
<div key={idx}> <div key={idx}>
Линия {idx + 1} Линия {idx + 1}
{areaStr ? `: пересечение ${areaStr}` : ""} {lenStr ? `: длина пересечения ${lenStr}` : ""}
</div> </div>
); );
})} })}

View file

@ -59,7 +59,7 @@ export interface OpportunityParcel {
export interface RedLine { export interface RedLine {
geom_wkt: string | null; geom_wkt: string | null;
intersection_area_sqm: number | null; // null if only nearby, not intersecting intersection_length_m: number | null; // null if only nearby, not intersecting
distance_m: number | null; // null if intersecting distance_m: number | null; // null if intersecting
} }