diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 70661088..74bfb5c9 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -857,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text(""" ORDER BY distance_m ASC LIMIT 30 ), + neighbors_total AS ( + -- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30 + -- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше + -- считался как len(neighbors), тихо капаясь на 30 даже когда соседей + -- в радиусе больше. + SELECT COUNT(*) AS n + FROM cad_buildings b + WHERE ST_DWithin( + b.geom::geography, + ST_GeomFromText(CAST(:wkt AS text), 4326)::geography, + 100 + ) + AND b.cad_num != CAST(:our_cad AS text) + ), overlap_rows AS ( SELECT cad_num, building_name, @@ -884,7 +898,8 @@ _NEIGHBORS_SUMMARY_SQL = text(""" (SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST) FROM overlap_rows o), '[]'::json - ) AS overlap_rows + ) AS overlap_rows, + (SELECT n FROM neighbors_total) AS neighbors_total_count """) @@ -901,6 +916,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не меняются. Формат возвращаемого dict идентичен прежнему. + #2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке + (CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30). + `neighbors_truncated` — True если в радиусе больше соседей, чем показано в + списке `neighbors`. + SQL — module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`). """ @@ -915,6 +935,9 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str ) neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else [] overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else [] + # #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) — + # НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше. + neighbors_total_count = int(row["neighbors_total_count"]) if row else 0 except Exception as e: logger.warning("neighbors query failed: %s", e) return {"data_available": False, "note": f"neighbors query failed: {e}"} @@ -959,36 +982,46 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str ] has_existing = len(overlap_buildings) > 0 + # neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно + # режется до 20 для payload. `neighbors_truncated` сравнивает честный total с + # тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным + # neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях, + # где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows). + neighbors_list = [ + { + "cad_num": r["cad_num"], + "building_name": r.get("building_name"), + "floors": r.get("floors"), + "floors_parsed": _parse_floors(r.get("floors")), + "year_built": r.get("year_built"), + "area_m2": round(float(r["area"])) if r.get("area") else None, + "cost_per_m2": ( + round(float(r["cost_value"]) / float(r["area"])) + if r.get("cost_value") and r.get("area") and float(r["area"]) > 0 + else None + ), + "distance_m": round(float(r["distance_m"])), + "readable_address": r.get("readable_address"), + # #2111 — функциональное назначение + статус здания (cad_buildings, + # populated bulk_harvest.py). purpose: TEXT категория, status: + # commissioned/under-construction и т.п. + "purpose": r.get("purpose"), + "status": r.get("status"), + } + for r in neighbor_rows[:20] + ] + return { "data_available": True, "radius_m": 100, - "count_buildings_100m": len(neighbor_rows), + # #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ + # len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`). + "count_buildings_100m": neighbors_total_count, + "neighbors_truncated": neighbors_total_count > len(neighbors_list), "avg_floors_100m": avg_floors, "max_floors_100m": max_floors, "median_cost_per_m2_100m": median_cost, - "neighbors": [ - { - "cad_num": r["cad_num"], - "building_name": r.get("building_name"), - "floors": r.get("floors"), - "floors_parsed": _parse_floors(r.get("floors")), - "year_built": r.get("year_built"), - "area_m2": round(float(r["area"])) if r.get("area") else None, - "cost_per_m2": ( - round(float(r["cost_value"]) / float(r["area"])) - if r.get("cost_value") and r.get("area") and float(r["area"]) > 0 - else None - ), - "distance_m": round(float(r["distance_m"])), - "readable_address": r.get("readable_address"), - # #2111 — функциональное назначение + статус здания (cad_buildings, - # populated bulk_harvest.py). purpose: TEXT категория, status: - # commissioned/under-construction и т.п. - "purpose": r.get("purpose"), - "status": r.get("status"), - } - for r in neighbor_rows[:20] - ], + "neighbors": neighbors_list, "has_existing_buildings": has_existing, "overlap_buildings": overlap_buildings, "note": ( @@ -1157,6 +1190,70 @@ def _compute_confidence( } +def _build_market_pulse( + competitor_rows: list[dict[str, Any]], + competitors_total: int, + velocity_data: dict[str, Any] | None, +) -> dict[str, Any]: + """OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами. + + Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются + в `competitor_rows` (детальный список для карты/карточек), но исключаются + из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers). + + #2464 cluster B: `competitors_total` — честный total (передаётся вызывающим + кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ + len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и + заодно раздувало `coverage_pct` — competitors_priced/20 вместо + competitors_priced/true_total). PURE — вызывающий код передаёт честный total + отдельно, эта функция его не пересчитывает. + """ + competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None] + competitors_priced = len(competitors_with_price) + if competitors_with_price: + prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price] + market_avg_price = round(sum(prices) / len(prices)) + # top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму + with_sales = [ + c + for c in competitors_with_price + if c["units_sold"] is not None and int(c["units_sold"]) > 0 + ] + top_sellers = sorted( + with_sales, + key=lambda c: int(c["units_sold"]), + reverse=True, + )[:5] + top_sellers_list = [ + { + "obj_id": c["obj_id"], + "comm_name": c["comm_name"], + "dev_name": c["dev_name"], + "units_sold": int(c["units_sold"]), + "avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]), + } + for c in top_sellers + ] + else: + market_avg_price = None + top_sellers_list = [] + + coverage_pct = ( + round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0 + ) + # avg_velocity_m2 — берём из velocity_data если есть; это уже только по + # ЖК с objective_corpus_room_month данными (non-null by construction). + avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None + return { + "avg_velocity_m2": avg_velocity, + "market_avg_price_per_m2": market_avg_price, + "competitors_total": competitors_total, + "competitors_with_price": competitors_priced, + "coverage_pct": coverage_pct, + "top_sellers": top_sellers_list, + } + + # #93 — on-demand cadastre fetch tuning constants. # _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback. # @@ -2256,6 +2353,34 @@ def analyze_parcel( .all() ) + # 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT. + # competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и + # детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows), + # тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует + # ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов + # (нужны только для цен детального списка, не для total) — на порядок дешевле + # полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа). + _competitors_total_true = ( + db.execute( + text(""" + WITH latest_obj AS ( + SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ) + SELECT COUNT(*) FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 3000 + ) + """), + {"wkt": geom_wkt}, + ).scalar() + or 0 + ) + # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class # + по кварталам сдачи. Константы — см. PIPELINE_* выше. @@ -3680,56 +3805,12 @@ def analyze_parcel( except Exception as _ve: logger.warning("velocity compute failed for %s: %s", cad_num, _ve) - # OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами. - # Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются - # в competitor list для карты, но исключаются из расчётов рыночных метрик. - _competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None] - _competitors_total = len(competitor_rows) - _competitors_priced = len(_competitors_with_price) - if _competitors_with_price: - _prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price] - _market_avg_price = round(sum(_prices) / len(_prices)) - # top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму - _with_sales = [ - c - for c in _competitors_with_price - if c["units_sold"] is not None and int(c["units_sold"]) > 0 - ] - _top_sellers = sorted( - _with_sales, - key=lambda c: int(c["units_sold"]), - reverse=True, - )[:5] - _top_sellers_list = [ - { - "obj_id": c["obj_id"], - "comm_name": c["comm_name"], - "dev_name": c["dev_name"], - "units_sold": int(c["units_sold"]), - "avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]), - } - for c in _top_sellers - ] - else: - _market_avg_price = None - _top_sellers_list = [] - - _coverage_pct = ( - round(_competitors_priced * 100.0 / _competitors_total, 1) - if _competitors_total > 0 - else 0.0 + # #2464 cluster B: competitors_total — честный total из отдельного COUNT(*) + # (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса + # конкурентов. См. _build_market_pulse docstring для полной логики агрегата. + market_pulse: dict[str, Any] = _build_market_pulse( + competitor_rows, int(_competitors_total_true), velocity_data ) - # avg_velocity_m2 — берём из velocity_data если есть; это уже только по - # ЖК с objective_corpus_room_month данными (non-null by construction). - _avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None - market_pulse: dict[str, Any] = { - "avg_velocity_m2": _avg_velocity, - "market_avg_price_per_m2": _market_avg_price, - "competitors_total": _competitors_total, - "competitors_with_price": _competitors_priced, - "coverage_pct": _coverage_pct, - "top_sellers": _top_sellers_list, - } # #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника # на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index 248b4038..d85d24ca 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -89,6 +89,8 @@ _NO_DATA = "нет данных" # Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML). _HEAT_ROW_CAP = 15 _WATER_ROW_CAP = 25 +# Кап видимых строк таблицы разрешений §6 (зеркало _PERMITS_ROW_CAP в HTML). +_PERMITS_ROW_CAP = 10 # Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html). _TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" @@ -1008,6 +1010,12 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: `permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза (полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в full_report_html. + + #2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby + считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30. Здесь + список дополнительно режется до `_PERMITS_ROW_CAP` (10) для компактности — раньше + это резалось МОЛЧА. Дисклоузим разницу `total_count - показано` строкой + «и ещё N …», как тепло/вода-таблицы выше (`_build_connection_capacity`). """ nearby = _as_dict(result.get("permits_nearby")) total = nearby.get("total_count") @@ -1030,7 +1038,7 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: _add_kv_table(doc, kv) rows: list[list[Any]] = [] - for item in _as_list(nearby.get("items"))[:10]: + for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]: data = _as_dict(item) rows.append( [ @@ -1040,6 +1048,11 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: data.get("distance_m"), ] ) + hidden = total - len(rows) + if hidden > 0: + rows.append( + [f"… и ещё {hidden} записей (полный список в веб-версии §6)", _DASH, _DASH, _DASH] + ) headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] _add_data_table(doc, headers, rows) diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index 8e079005..c71725af 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -96,6 +96,8 @@ _NON_EKB_GENERIC: tuple[str, ...] = ( # Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»). _HEAT_ROW_CAP = 15 _WATER_ROW_CAP = 25 +# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»). +_PERMITS_ROW_CAP = 10 # Усечение длинных бюрократических имён систем. _SYSTEM_NAME_MAX = 120 @@ -1495,6 +1497,13 @@ def _build_permits_nearby(result: dict[str, Any]) -> str: `result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см. `permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза (полное предложение, не аббревиатура). Все динамические строки через `html.escape`. + + #2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby + считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30 + (`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP` + (10) для компактности PDF — раньше это резалось МОЛЧА. Дисклоузим разницу + `total_count - показано` строкой «и ещё N …», как тепло/вода-таблицы выше + (`_build_connection_capacity`). """ nearby = _as_dict(result.get("permits_nearby")) total = nearby.get("total_count") @@ -1514,7 +1523,7 @@ def _build_permits_nearby(result: dict[str, Any]) -> str: ] rows: list[list[Any]] = [] - for item in _as_list(nearby.get("items"))[:10]: + for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]: data = _as_dict(item) rows.append( [ @@ -1524,6 +1533,16 @@ def _build_permits_nearby(result: dict[str, Any]) -> str: data.get("distance_m"), ] ) + hidden = total - len(rows) + if hidden > 0: + rows.append( + [ + f"… и ещё {hidden} записей (полный список в веб-версии §6)", + _DASH, + _DASH, + _DASH, + ] + ) headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] return _kv_table(kv) + _data_table(headers, rows) diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index a63a999a..a13585fb 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -108,6 +108,14 @@ class VelocityResult: # per-project absorption там ill-defined и НЕ должен драйвить график) и на # degenerate/zero путях. project_absorption_sqm_per_month: float | None = None + # #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся + # в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может + # быть больше), а не честный total. Тот же silent-cap класс, что чинили в + # utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь — + # флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне + # редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не + # стоит — честная disclosure флагом дешевле. + competitors_truncated: bool = False def as_dict(self) -> dict[str, Any]: return { @@ -131,6 +139,7 @@ class VelocityResult: if self.project_absorption_sqm_per_month is not None else None ), + "competitors_truncated": self.competitors_truncated, } @@ -324,6 +333,9 @@ def compute_velocity( ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH ekb_median_proxy_fraction = 0.0 n_comps = len(comp_rows) + # #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка + # уперлась в лимит, реальных конкурентов в радиусе может быть больше. + n_comps_truncated = n_comps >= 200 sample_no_data = sorted( [ { @@ -356,6 +368,7 @@ def compute_velocity( sample_competitors=sample_no_data, objective_coverage_pct=coverage_pct, ekb_median_proxy_fraction=ekb_median_proxy_fraction, + competitors_truncated=n_comps_truncated, ) if rr_result is not None: return rr_result @@ -379,6 +392,7 @@ def compute_velocity( proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=None, # degenerate path — ill-defined + competitors_truncated=n_comps_truncated, ) # ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── @@ -486,6 +500,7 @@ def compute_velocity( sample_competitors=sample_no_data, objective_coverage_pct=coverage_pct, ekb_median_proxy_fraction=ekb_median_proxy_fraction, + competitors_truncated=n_comps_truncated, ) if rr_result is not None: return rr_result @@ -521,6 +536,7 @@ def compute_velocity( proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=None, # degenerate path — ill-defined + competitors_truncated=n_comps_truncated, ) # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). @@ -599,6 +615,7 @@ def compute_velocity( proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=project_absorption, + competitors_truncated=n_comps_truncated, ) @@ -611,6 +628,7 @@ def _compute_rosreestr_fallback( sample_competitors: list[dict[str, Any]], objective_coverage_pct: float = 0.0, ekb_median_proxy_fraction: float = 0.0, + competitors_truncated: bool = False, ) -> VelocityResult | None: """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. @@ -707,6 +725,7 @@ def _compute_rosreestr_fallback( # RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции — # per-project absorption ill-defined → None, НЕ драйвит DCF-график. project_absorption_sqm_per_month=None, + competitors_truncated=competitors_truncated, ) diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py index ab5c1869..a5b1ceb8 100644 --- a/backend/tests/api/v1/test_analyze_parcel_meta.py +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -7,26 +7,26 @@ Стратегия mock: аналогична test_analyze_market_price.py — DB mock через dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. -Порядок db.execute calls в analyze_parcel (с #29 G2): +Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B): 0. UNION ALL geom + source → .mappings().first() 1. WKT query → .mappings().first() 2. District → .mappings().first() 3. POI rows → .mappings().all() 4. Competitor rows → .mappings().all() - 5. Pipeline rows → .mappings().all() - 6. Centroid lat/lon → .mappings().first() - 7. Noise rows → .mappings().all() - 8. Hydrology → .mappings().all() - 9. Utilities → .mappings().all() - 10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2 - 11. Market trend → .mappings().first() - 12. Zoning (begin_nested) → .mappings().first() - 13. Success recommendation (begin_nested) → .mappings().all() - 14. Market price (begin_nested) → .mappings().first() - 15. Recent permits (begin_nested) → .mappings().all() - 16. _geotech_risk (industrial count) → .scalar() - 17. _neighbors_summary (neighbor_rows) → .mappings().all() - 18. _neighbors_summary (overlap_row) → .mappings().first() + 5. competitors_total honest COUNT(*) → .scalar() ← NEW #2464 cluster B + 6. Pipeline rows → .mappings().all() + 7. Centroid lat/lon → .mappings().first() + 8. Noise rows → .mappings().all() + 9. Hydrology → .mappings().all() + 10. Utilities → .mappings().all() + 11. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2 + 12. Market trend → .mappings().first() + 13. Zoning (begin_nested) → .mappings().first() + 14. Success recommendation (begin_nested) → .mappings().all() + 15. Market price (begin_nested) → .mappings().first() + 16. Recent permits (begin_nested) → .mappings().all() + 17. _geotech_risk (industrial count) → .scalar() + 18. _neighbors_summary (single statement, neighbors+overlap+total) → .mappings().first() """ from __future__ import annotations @@ -82,20 +82,23 @@ def _make_db_for_analyze( ("first", district_row), # 2: district ("all", []), # 3: POI rows ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows - ("first", pm_mock), # 10: parcel_meta ← #29 G2 - ("first", None), # 11: market trend - ("first", None), # 12: zoning (begin_nested) - ("all", []), # 13: success recommendation (begin_nested) - ("first", None), # 14: market price (begin_nested) - ("all", []), # 15: recent permits (begin_nested) - ("scalar", 0), # 16: geotech_risk - ("all", []), # 17: neighbors - ("first", None), # 18: overlap + ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B + ("all", []), # 6: pipeline rows + ("first", centroid_row), # 7: centroid + ("all", []), # 8: noise rows + ("all", []), # 9: hydrology rows + ("all", []), # 10: utilities rows + ("first", pm_mock), # 11: parcel_meta ← #29 G2 + ("first", None), # 12: market trend + ("first", None), # 13: zoning (begin_nested) + ("all", []), # 14: success recommendation (begin_nested) + ("first", None), # 15: market price (begin_nested) + ("all", []), # 16: recent permits (begin_nested) + ("scalar", 0), # 17: geotech_risk + # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через + # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first() + # возвращает ОДНУ строку с этими тремя ключами. + ("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}), ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_analyze_zoning_regulation.py b/backend/tests/api/v1/test_analyze_zoning_regulation.py index 09b0d116..88e2c01c 100644 --- a/backend/tests/api/v1/test_analyze_zoning_regulation.py +++ b/backend/tests/api/v1/test_analyze_zoning_regulation.py @@ -125,21 +125,24 @@ def _make_db_for_analyze() -> MagicMock: ("first", district_row), # 2: district ("all", []), # 3: POI rows ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows + ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B + ("all", []), # 6: pipeline rows + ("first", centroid_row), # 7: centroid + ("all", []), # 8: noise rows + ("all", []), # 9: hydrology rows + ("all", []), # 10: utilities rows # (далее nspd_zoning merge не ходит в db.execute — резолвер замокан) - ("first", None), # 10: parcel_meta - ("first", None), # 11: market trend - ("first", None), # 12: zoning (begin_nested) - ("all", []), # 13: success recommendation - ("first", None), # 14: market price - ("all", []), # 15: recent permits - ("scalar", 0), # 16: geotech_risk - ("all", []), # 17: neighbors - ("first", None), # 18: overlap + ("first", None), # 11: parcel_meta + ("first", None), # 12: market trend + ("first", None), # 13: zoning (begin_nested) + ("all", []), # 14: success recommendation + ("first", None), # 15: market price + ("all", []), # 16: recent permits + ("scalar", 0), # 17: geotech_risk + # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через + # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first() + # возвращает ОДНУ строку с этими тремя ключами. + ("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}), ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index 2e3fd6cc..16b1fc11 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -277,20 +277,23 @@ def _make_db_for_analyze() -> MagicMock: ("first", district_row), # 2: district ("all", []), # 3: POI rows ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows - ("first", None), # 10: parcel_meta - ("first", None), # 11: market trend - ("first", None), # 12: zoning (begin_nested) - ("all", []), # 13: success recommendation (begin_nested) - ("first", None), # 14: market price (begin_nested) - ("all", []), # 15: recent permits (begin_nested) - ("scalar", 0), # 16: geotech_risk - ("all", []), # 17: neighbors - ("first", None), # 18: overlap + ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B + ("all", []), # 6: pipeline rows + ("first", centroid_row), # 7: centroid + ("all", []), # 8: noise rows + ("all", []), # 9: hydrology rows + ("all", []), # 10: utilities rows + ("first", None), # 11: parcel_meta + ("first", None), # 12: market trend + ("first", None), # 13: zoning (begin_nested) + ("all", []), # 14: success recommendation (begin_nested) + ("first", None), # 15: market price (begin_nested) + ("all", []), # 16: recent permits (begin_nested) + ("scalar", 0), # 17: geotech_risk + # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через + # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first() + # возвращает ОДНУ строку с этими тремя ключами. + ("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}), ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py new file mode 100644 index 00000000..f435dc5a --- /dev/null +++ b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py @@ -0,0 +1,226 @@ +"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel. + +Mock-based — не требуют живой БД. + +Покрывает: + 1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct) — + раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь + принимает честный total отдельным параметром (см. analyze_parcel 5a — + COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него. + 2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated) — + раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE + `neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT). + +Reference fix pattern: `features_truncated` в +`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from app.api.v1 import parcels as parcels_module + +# ── Вспомогательные фабрики ─────────────────────────────────────────────────── + + +def _competitor_row( + obj_id: int, + avg_price_per_m2_rub: float | None, + units_sold: int | None = None, +) -> dict: + return { + "obj_id": obj_id, + "comm_name": f"ЖК-{obj_id}", + "dev_name": "TestDev", + "avg_price_per_m2_rub": avg_price_per_m2_rub, + "units_sold": units_sold, + } + + +# ── _build_market_pulse: competitors_total / coverage_pct honesty ──────────── + + +class TestBuildMarketPulseHonesty: + """market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B).""" + + def test_below_cap_total_matches_len(self) -> None: + """Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero + behavior change: старое поведение (len(competitor_rows)) совпадает с новым.""" + rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)] # 5 конкурентов + pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None) + + assert pulse["competitors_total"] == 5 + assert pulse["competitors_with_price"] == 5 + assert pulse["coverage_pct"] == 100.0 + + def test_at_cap_total_is_honest_not_len_of_rows(self) -> None: + """#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный + список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН + отражать 45, а НЕ len(rows)=20 (старый баг — silent cap репортился как total).""" + rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] # ровно LIMIT 20 + true_total = 45 + pulse = parcels_module._build_market_pulse( + rows, competitors_total=true_total, velocity_data=None + ) + + assert pulse["competitors_total"] == true_total + assert pulse["competitors_total"] != len( + rows + ), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)" + + def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None: + """coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый + список), что давало ложные 100% покрытия даже когда реальное покрытие ниже.""" + # Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%. + rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] + true_total = 40 # реально в радиусе 40 ЖК, из них с ценой только эти 20. + pulse = parcels_module._build_market_pulse( + rows, competitors_total=true_total, velocity_data=None + ) + + assert pulse["competitors_with_price"] == 20 + # 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%). + assert pulse["coverage_pct"] == 50.0 + + def test_zero_competitors_honest_zero_coverage(self) -> None: + """Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль).""" + pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None) + + assert pulse["competitors_total"] == 0 + assert pulse["competitors_with_price"] == 0 + assert pulse["coverage_pct"] == 0.0 + assert pulse["market_avg_price_per_m2"] is None + assert pulse["top_sellers"] == [] + + def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None: + """ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows + (детальный список для карты), но не влияют на market_avg_price_per_m2.""" + rows = [ + _competitor_row(1, 100_000.0), + _competitor_row(2, None), + _competitor_row(3, 200_000.0), + ] + pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None) + + assert pulse["competitors_with_price"] == 2 + assert pulse["market_avg_price_per_m2"] == 150_000 + + def test_velocity_data_feeds_avg_velocity_m2(self) -> None: + """avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть.""" + rows = [_competitor_row(1, 100_000.0)] + pulse = parcels_module._build_market_pulse( + rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5} + ) + assert pulse["avg_velocity_m2"] == 750.5 + + def test_top_sellers_top5_sorted_by_units_sold(self) -> None: + """top_sellers — топ-5 по units_sold среди приценённых конкурентов.""" + rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)] + pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None) + + assert len(pulse["top_sellers"]) == 5 + sold = [s["units_sold"] for s in pulse["top_sellers"]] + assert sold == sorted(sold, reverse=True) + assert sold[0] == 70 # obj_id=7 продал больше всех + + +# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ─── + + +def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict: + return { + "cad_num": cad_num, + "building_name": None, + "floors": None, + "year_built": None, + "cost_value": None, + "area": None, + "readable_address": None, + "purpose": None, + "status": None, + "distance_m": distance_m, + } + + +def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None): + row = { + "neighbors": neighbors_json, + "overlap_rows": overlap_json or [], + "neighbors_total_count": neighbors_total_count, + } + result = MagicMock() + result.mappings.return_value.first.return_value = row + db = MagicMock() + db.execute.return_value = result + return db + + +class TestNeighborsSummaryHonesty: + """count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B).""" + + def test_below_cap_count_matches_len_no_truncation(self) -> None: + """Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False + (zero behavior change для типичного случая).""" + neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)] + db = _make_neighbors_db(neighbors, neighbors_total_count=5) + + summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") + + assert summary["data_available"] is True + assert summary["count_buildings_100m"] == 5 + assert summary["neighbors_truncated"] is False + + def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None: + """#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но + реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`). + count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30.""" + neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] # ровно LIMIT 30 + true_total = 47 + db = _make_neighbors_db(neighbors, neighbors_total_count=true_total) + + summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") + + assert summary["count_buildings_100m"] == true_total + assert summary["count_buildings_100m"] != len( + neighbors + ), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)" + assert summary["neighbors_truncated"] is True + + def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None: + """Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20 + в существующем коде — отдельно от SQL LIMIT 30) остаётся прежним по составу/ + порядку — фикс трогает только count/флаг, НЕ список.""" + neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] + db = _make_neighbors_db(neighbors, neighbors_total_count=47) + + summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") + + assert len(summary["neighbors"]) == 20 + assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0" + + def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None: + """neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО + показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows + (до 30 из SQL) — иначе флаг может ложно молчать (False) на 21..30-м соседе, + где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30.""" + # SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20. + neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)] + db = _make_neighbors_db(neighbors, neighbors_total_count=25) + + summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") + + assert len(summary["neighbors"]) == 20 + assert summary["count_buildings_100m"] == 25 + # 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30 + # не был исчерпан. + assert summary["neighbors_truncated"] is True + + def test_query_failure_degrades_gracefully(self) -> None: + """DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior).""" + db = MagicMock() + db.execute.side_effect = RuntimeError("boom") + + summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") + + assert summary == {"data_available": False, "note": "neighbors query failed: boom"} diff --git a/backend/tests/services/exporters/test_full_report_docx.py b/backend/tests/services/exporters/test_full_report_docx.py index 39a43428..b7d63eec 100644 --- a/backend/tests/services/exporters/test_full_report_docx.py +++ b/backend/tests/services/exporters/test_full_report_docx.py @@ -479,7 +479,11 @@ def test_permits_nearby_non_empty_summary_and_list() -> None: def test_permits_nearby_items_capped_at_ten() -> None: - """items[:10] — таблица документов не превышает шапку + 10 строк (зеркало HTML).""" + """items[:10] — показываются первые 10 строк документов (зеркало HTML). + + #2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому + таблица = шапка + 10 документов + 1 disclosure = 12 строк. + """ items = [ { "doc_group": "RS", @@ -501,15 +505,76 @@ def test_permits_nearby_items_capped_at_ten() -> None: } doc = _new_doc() _build_permits_nearby(doc, result) - # Вторая таблица (документы): шапка + не более 10 строк. + # Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка. docs_table = doc.tables[1] - assert len(docs_table.rows) == 11 + assert len(docs_table.rows) == 12 text = _all_text(doc) assert "РНС-0" in text assert "РНС-9" in text assert "РНС-10" not in text +def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None: + """#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных + 10 → honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый + срез — как тепло/вода-таблицы §3 (`_build_connection_capacity`).""" + items = [ + { + "doc_group": "RS", + "doc_name": f"РНС-{i}", + "date_doc": "2025-01-01", + "approved_organization": "Орган", + "distance_m": float(i), + } + for i in range(12) + ] + result = { + "permits_nearby": { + # total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30, + # здесь режем до 10; disclosure считается от честного total_count). + "total_count": 15, + "rs_count": 15, + "rv_count": 0, + "nearest_distance_m": 0.0, + "items": items, + } + } + doc = _new_doc() + _build_permits_nearby(doc, result) + text = _all_text(doc) + assert "… и ещё 5 записей (полный список в веб-версии §6)" in text + + +def test_permits_nearby_no_disclosure_when_all_shown() -> None: + """total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для + маленьких выборок): таблица = шапка + N документов, без disclosure-строки.""" + items = [ + { + "doc_group": "RS", + "doc_name": f"РНС-{i}", + "date_doc": "2025-01-01", + "approved_organization": "Орган", + "distance_m": float(i), + } + for i in range(7) + ] + result = { + "permits_nearby": { + "total_count": 7, + "rs_count": 7, + "rv_count": 0, + "nearest_distance_m": 0.0, + "items": items, + } + } + doc = _new_doc() + _build_permits_nearby(doc, result) + docs_table = doc.tables[1] + assert len(docs_table.rows) == 8 # шапка + 7, без disclosure + text = _all_text(doc) + assert "и ещё" not in text + + def test_section_6_wires_permits_nearby_from_analyze() -> None: """_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден.""" doc = _new_doc() diff --git a/backend/tests/services/exporters/test_full_report_html_part_b.py b/backend/tests/services/exporters/test_full_report_html_part_b.py index cb5239c8..4f296100 100644 --- a/backend/tests/services/exporters/test_full_report_html_part_b.py +++ b/backend/tests/services/exporters/test_full_report_html_part_b.py @@ -468,6 +468,67 @@ def test_permits_nearby_org_name_escaped() -> None: assert "hack" not in out +def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None: + """#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится, + вместо молчаливого среза до 10 (total_count — честный total апстрима, + permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT).""" + items = [ + { + "doc_group": "RS", + "doc_name": f"РНС №{i}", + "date_doc": "2025-01-01", + "approved_organization": "Администрация", + "distance_m": float(i * 10), + } + for i in range(12) + ] + result = { + "permits_nearby": { + "total_count": 15, # > len(items)=12: апстрим уже капнул items на 30, + # здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5. + "rs_count": 15, + "rv_count": 0, + "nearest_distance_m": 0.0, + "items": items, + "items_truncated": True, + } + } + out = _build_permits_nearby(result) + assert "РНС №9" in out # 10-й показанный (index 9, row cap=10) + assert "РНС №10" not in out # 11-й — за кэпом, НЕ показан напрямую + assert "и ещё 5 записей" in out + assert "веб-версии §6" in out + + +def test_permits_nearby_no_truncation_note_when_all_shown() -> None: + """total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior + change для маленьких выборок, как раньше).""" + items = [ + { + "doc_group": "RS", + "doc_name": f"РНС №{i}", + "date_doc": "2025-01-01", + "approved_organization": "Администрация", + "distance_m": float(i * 10), + } + for i in range(7) + ] + result = { + "permits_nearby": { + "total_count": 7, + "rs_count": 7, + "rv_count": 0, + "nearest_distance_m": 0.0, + "items": items, + "items_truncated": False, + } + } + out = _build_permits_nearby(result) + assert "и ещё" not in out + for i in range(7): + assert f"РНС №{i}" in out + + def test_section_6_wires_permits_nearby_from_analyze() -> None: """analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML.""" analyze = { diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index 414d2df2..5dd289e8 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -576,6 +576,108 @@ def test_rosreestr_fallback_project_absorption_is_none(): assert result.as_dict()["project_absorption_sqm_per_month"] is None +def test_competitors_truncated_false_below_cap(): + """#2464 cluster B: n_comps < 200 (Step 1 LIMIT) → competitors_truncated=False.""" + n = 12 + comp_rows = [_comp_row(i, distance_m=300.0 + i * 100) for i in range(1, n + 1)] + sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) + + assert result is not None + assert result.competitors_count == n + assert result.competitors_truncated is False + assert result.as_dict()["competitors_truncated"] is False + + +def test_competitors_truncated_true_at_cap(): + """#2464 cluster B: Step 1 competitor query капнут LIMIT 200 — если запрос вернул + ровно 200 строк, реальных ЖК в радиусе может быть больше → competitors_truncated=True. + Раньше этот silent-cap никак не сигнализировался наружу.""" + n = 200 + comp_rows = [_comp_row(i, distance_m=300.0 + i) for i in range(1, n + 1)] + sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) + + assert result is not None + assert result.competitors_count == n + assert result.competitors_truncated is True + assert result.as_dict()["competitors_truncated"] is True + + +def test_competitors_truncated_propagates_through_rosreestr_fallback(): + """competitors_truncated пробрасывается и через rosreestr_fallback путь (низкое + Objective-покрытие форсирует fallback, но Step 1 LIMIT 200 капается независимо + от того, какой путь дальше используется для объёма продаж).""" + n = 200 + comp_rows = [_comp_row(i, distance_m=300.0 + i) for i in range(1, n + 1)] + # Все конкуренты unmapped → forces rosreestr_fallback (coverage 0% < 50%). + sales_rows = [ + _sales_row(i, total_sqm=0.0, months=0, has_mapping=False) for i in range(1, n + 1) + ] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + rr_row = MagicMock() + start_d = datetime.date.fromisoformat("2025-01-01") + end_d = datetime.date.fromisoformat("2025-06-01") + rr_row.__getitem__ = lambda self, k: { + "total_deals": 60, + "period_start": start_d, + "period_end": end_d, + }[k] + rr_result_mock = MagicMock() + rr_result_mock.mappings.return_value.first.return_value = rr_row + + original_side_effect = list(db.execute.side_effect) + db.execute.side_effect = [*original_side_effect, rr_result_mock] + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT, cad_quarter="66:41:0702048") + + assert result is not None + assert result.velocity_source == "rosreestr_fallback" + assert result.competitors_truncated is True + + +def test_as_dict_includes_competitors_truncated_default_false(): + """as_dict() содержит competitors_truncated; дефолт False (backward-compat, + старые вызовы VelocityResult(...) без явного значения не ломаются).""" + vr = VelocityResult( + competitors_count=5, + monthly_velocity_sqm=3000.0, + ekb_median_sqm=4500.0, + velocity_score=0.333, + confidence="medium", + months_observed=4, + period_start="2024-11", + period_end="2025-02", + sample_competitors=[], + by_room_bucket={}, + velocity_data_available=True, + velocity_source="objective", + objective_coverage_pct=66.7, + proxy_used=False, + proxy_sqm_per_deal=None, + ekb_median_proxy_fraction=0.25, + ) + assert vr.competitors_truncated is False + assert vr.as_dict()["competitors_truncated"] is False + + def test_sample_competitors_include_by_room_bucket(): """sample_competitors каждого элемента содержит by_room_bucket.""" comp_rows = [_comp_row(1), _comp_row(2)]