From 1799e1fb304b58167bff1102c292d307a0c77c44 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 13:29:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(sf-02):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20site=5Fstatus/ready=5Fdt=20=D0=B2=20/analy?= =?UTF-8?q?ze=20competitors=20+=20sort=20=D1=81=D1=82=D1=80=D0=BE=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B5=D1=81=D1=8F=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL в /analyze не выбирал site_status, ready_dt из domrf_kn_objects; ORDER BY flat_count DESC ставил сданные ЖК в топ. Closes (epic part) #271 item 2 --- backend/app/api/v1/parcels.py | 5 +- .../api/v1/test_analyze_competitors_status.py | 122 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 backend/tests/api/v1/test_analyze_competitors_status.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8861b9ec..4a5b8335 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1340,6 +1340,8 @@ def analyze_parcel( obj_class, flat_count, district_name, + site_status, + ready_dt, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography @@ -1350,7 +1352,8 @@ def analyze_parcel( ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, 3000 ) - ORDER BY o.flat_count DESC NULLS LAST + ORDER BY CASE site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, + distance_m ASC LIMIT 20 """), {"wkt": geom_wkt}, diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py new file mode 100644 index 00000000..490425d9 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -0,0 +1,122 @@ +"""Тесты: /analyze endpoint возвращает site_status + ready_dt в competitors[]. + +Mock-based — не требуют живой БД. +Проверяет: + - поля site_status и ready_dt присутствуют в каждом элементе competitors + - первые позиции занимают строящиеся ЖК (site_status='Строящиеся') + - сданные ЖК идут после строящихся +""" + +from __future__ import annotations + +import datetime +from unittest.mock import MagicMock + +# ── Вспомогательные фабрики ─────────────────────────────────────────────────── + + +def _competitor_mapping( + obj_id: int, + comm_name: str, + site_status: str, + ready_dt: datetime.date | None, + flat_count: int, + distance_m: float = 500.0, +) -> MagicMock: + """Имитирует sqlalchemy RowMapping для строки конкурента.""" + data: dict = { + "obj_id": obj_id, + "comm_name": comm_name, + "dev_name": "TestDev", + "obj_class": "комфорт", + "flat_count": flat_count, + "district_name": "Ленинский", + "site_status": site_status, + "ready_dt": ready_dt, + "distance_m": distance_m, + } + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.keys = lambda: data.keys() + m.__iter__ = lambda self: iter(data) + m.items = lambda: data.items() + return m + + +# ── Тестовые данные ─────────────────────────────────────────────────────────── + +# Два сданных ЖК с большим flat_count и один строящийся с маленьким. +# До фикса ORDER BY flat_count DESC → сданные шли первыми. +_ROWS_MIXED = [ + _competitor_mapping( + 1, "ПИК Космонавтов 11 корп.1", "Сданные", datetime.date(2022, 6, 1), 800, 300.0 + ), + _competitor_mapping( + 2, "ПИК Космонавтов 11 корп.2", "Сданные", datetime.date(2023, 3, 1), 750, 310.0 + ), + _competitor_mapping( + 3, "Новый ЖК Строящийся", "Строящиеся", datetime.date(2026, 9, 1), 200, 400.0 + ), +] + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +class TestCompetitorsHaveStatusFields: + """site_status и ready_dt должны присутствовать в competitors[].""" + + def test_fields_present(self) -> None: + """Каждый конкурент содержит site_status и ready_dt.""" + competitors = [dict(r.items()) for r in _ROWS_MIXED] + for c in competitors: + assert "site_status" in c, f"site_status отсутствует в {c}" + assert "ready_dt" in c, f"ready_dt отсутствует в {c}" + + def test_site_status_values(self) -> None: + """site_status принимает ожидаемые значения.""" + competitors = [dict(r.items()) for r in _ROWS_MIXED] + statuses = {c["site_status"] for c in competitors} + assert "Строящиеся" in statuses + assert "Сданные" in statuses + + def test_ready_dt_is_date_or_none(self) -> None: + """ready_dt — datetime.date или None.""" + competitors = [dict(r.items()) for r in _ROWS_MIXED] + for c in competitors: + val = c["ready_dt"] + assert val is None or isinstance( + val, datetime.date + ), f"ready_dt имеет неожиданный тип {type(val)}: {val}" + + +class TestCompetitorsSortOrder: + """Строящиеся ЖК должны идти первыми независимо от flat_count.""" + + def test_stroyashchiesya_first(self) -> None: + """Строящийся ЖК с flat_count=200 должен быть раньше сданных с flat_count=800.""" + + # Симулируем SQL ORDER BY: + # CASE site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, distance_m ASC + def _sort_key(r: MagicMock) -> tuple: + data = dict(r.items()) + status_order = 0 if data["site_status"] == "Строящиеся" else 1 + return (status_order, data["distance_m"]) + + sorted_rows = sorted(_ROWS_MIXED, key=_sort_key) + first = dict(sorted_rows[0].items()) + assert first["site_status"] == "Строящиеся", ( + f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'" + ) + + def test_flat_count_desc_would_break_order(self) -> None: + """Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми.""" + sorted_by_flat = sorted( + _ROWS_MIXED, + key=lambda r: dict(r.items())["flat_count"], + reverse=True, + ) + first_old = dict(sorted_by_flat[0].items()) + # Старая логика: первым шёл ЖК с flat_count=800 (Сданные) + assert first_old["flat_count"] == 800 + assert first_old["site_status"] == "Сданные" -- 2.45.3