From fdb54834e5b1fe76c4ecd719902a70238c9e5c6f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:33:14 +0300 Subject: [PATCH] =?UTF-8?q?fix(nspd):=20rename=20migration=2089=E2=86=9298?= =?UTF-8?q?,=20fix=20red=20lines=20ST=5FArea=E2=86=92ST=5FLength=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/app/schemas/parcel.py | 7 ++-- .../site_finder/quarter_dump_lookup.py | 32 +++++++++++-------- .../services/test_quarter_dump_lookup.py | 22 +++++++++---- ...8_nspd_quarter_dumps_opportunity_flag.sql} | 2 +- .../site-finder/NspdRedLinesBlock.tsx | 16 +++++----- frontend/src/types/nspd.ts | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) rename data/sql/{89_nspd_quarter_dumps_opportunity_flag.sql => 98_nspd_quarter_dumps_opportunity_flag.sql} (98%) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 6f92375e..8d210704 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -105,17 +105,18 @@ class RedLine(BaseModel): Поля: geom_wkt: WKT геометрии линии в EPSG:4326 - intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) + intersection_length_m: длина пересечения линии с участком, м + (null если только nearby, не intersect) distance_m: расстояние от ближайшей точки линии до участка, м (null если линия пересекает участок) UI-семантика: - intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) + intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) distance_m != null → красная линия рядом (warning) """ 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 diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 56222c18..b6b17e06 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -685,11 +685,15 @@ def _get_red_lines( """TIER 4: красные линии застройки (layer 879243). Возвращает 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 - - nearby only: intersection_area_sqm = None, distance_m = расстояние + - intersecting: intersection_length_m >= 0, distance_m = None + - 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 — пропускаем heavy jsonb_array_elements scan. @@ -719,7 +723,7 @@ def _get_red_lines( ), ST_GeomFromText(:wkt, 4326) ) AS does_intersect, - ST_Area( + ST_Length( ST_Intersection( ST_Transform( ST_SetSRID( @@ -727,10 +731,10 @@ def _get_red_lines( 3857 ), 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) - ) AS intersection_area_sqm, + ), + ST_GeomFromText(:wkt, 4326) + )::geography + ) AS intersection_length_m, ST_Distance( ST_Transform( ST_SetSRID( @@ -769,14 +773,14 @@ def _get_red_lines( for r in rows: geom_wkt_val: str | None = r[0] 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] - intersection_area_sqm: float | None = None - if does_intersect and raw_area is not None: + intersection_length_m: float | None = None + if does_intersect and raw_length is not None: try: - val = float(raw_area) - intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + val = float(raw_length) + intersection_length_m = round(val, 1) if not math.isnan(val) else None except (TypeError, ValueError): pass @@ -790,7 +794,7 @@ def _get_red_lines( result.append( { "geom_wkt": geom_wkt_val, - "intersection_area_sqm": intersection_area_sqm, + "intersection_length_m": intersection_length_m, "distance_m": distance_m, } ) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 044d1c30..3b23a011 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -297,13 +297,13 @@ def test_get_opportunity_parcels_db_exception_returns_empty() -> 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, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(0 0, 1 1)", True, - 500.0, + 45.3, # длина пересечения в метрах 0.0, # intersecting → distance_m becomes None in result ), ] @@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None: assert len(result) == 1 r = result[0] 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 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, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(10 10, 20 20)", False, @@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None: assert len(result) == 1 r = result[0] - assert r["intersection_area_sqm"] is None + assert r["intersection_length_m"] is None 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: """Нет red lines features — пустой список.""" db = _make_mock_db_with_rows([]) diff --git a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql similarity index 98% rename from data/sql/89_nspd_quarter_dumps_opportunity_flag.sql rename to data/sql/98_nspd_quarter_dumps_opportunity_flag.sql index cc56e5e8..5c972a54 100644 --- a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql +++ b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql @@ -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. -- Enables fast map filter "find quarters with auction parcels" without -- unpacking features_json JSONB on every query. diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx index e6628c6e..651bab9b 100644 --- a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx +++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx @@ -6,10 +6,10 @@ interface Props { redLines: RedLine[] | null | undefined; } -function formatArea(sqm: number | null): string | null { - if (sqm === null) return null; - if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; - return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +function formatLength(m: number | null): string | null { + if (m === null) return null; + if (m >= 1000) return `${(m / 1000).toFixed(2)} км`; + return `${Math.round(m)} м`; } function formatDistance(m: number | null): string | null { @@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) { return null; } - const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); - const nearbyOnly = 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_length_m === null); const hasIntersect = intersecting.length > 0; return ( @@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
{intersecting.map((l, idx) => { - const areaStr = formatArea(l.intersection_area_sqm); + const lenStr = formatLength(l.intersection_length_m); return (
Линия {idx + 1} - {areaStr ? `: пересечение ${areaStr}` : ""} + {lenStr ? `: длина пересечения ${lenStr}` : ""}
); })} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 7f8cdae6..7b49b3ad 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -59,7 +59,7 @@ export interface OpportunityParcel { export interface RedLine { 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 }