From d0105470f4598bb28c072581e1e37d8bf7ac2c62 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 20:16:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/coverage):=20=D0=B1=D0=B5=D1=81?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=9C=D0=95=D0=A0=D0=90=20(#2894)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих объявлений в радиусе 1000м и медианный возраст листинга, без единой цены. Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap- канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей. Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8, жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days (не "срок продажи" — возраст активного объявления, цензурированная выборка). RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра вынесено в #2895. --- tradein-mvp/backend/app/api/v1/trade_in.py | 162 ++++++++++- tradein-mvp/backend/app/schemas/trade_in.py | 48 ++++ .../tests/test_coverage_probe_endpoint.py | 271 ++++++++++++++++++ 3 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_coverage_probe_endpoint.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 45d1a561..b0b4be85 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -10,7 +10,7 @@ import calendar import json import logging from datetime import UTC, date, datetime, timedelta -from typing import Annotated, Any +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile @@ -25,6 +25,8 @@ from app.schemas.trade_in import ( AnalogLot, AvitoImvSummary, CianPriceChangeStats, + CoverageProbeInput, + CoverageProbeResponse, DkpCorridor, HouseAnalyticsKpi, HouseAnalyticsResponse, @@ -2549,3 +2551,161 @@ def get_sales_vs_listings( data_quality="street_only" if total_deals > 0 else "no_data", pairs=pairs, ) + + +# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ───────────────── +# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО +# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring). +# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её +# планируется открыть анонимам отдельной задачей (#2895, со своим consent- +# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS). +# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000) +COVERAGE_RADIUS_M = 1000 +COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади +COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS) + +# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД. +COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск") +COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда") +COVERAGE_GREEN_MIN_N = 8 +COVERAGE_YELLOW_MIN_N = 12 + + +def _fold_city(name: str) -> str: + """ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774).""" + return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold() + + +_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { + **{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES}, + **{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES}, +} + + +def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]: + """Резолвит (display_city, threshold, is_supported) для пробы покрытия. + + Приоритет: явный city_hint фронта (тот же автокомплит, что заполняет + TradeInEstimateInput.city_hint) > мода city найденной SQL-когорты + (best-effort фолбэк, когда фронт его не передал). Город вне зелёного/ + жёлтого списка → threshold=0, is_supported=False — вызывающий обязан + трактовать это как not_covered независимо от n_listings. + """ + candidate = (city_hint or cohort_city or "").strip() + match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None + if match is not None: + display, threshold = match + return display, threshold, True + return candidate, 0, False + + +@router.post("/coverage", response_model=CoverageProbeResponse) +def coverage_probe( + payload: CoverageProbeInput, + db: Annotated[Session, Depends(get_db)], +) -> CoverageProbeResponse: + """Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом. + + Когорта — тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs + (rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же + приватных helper'ов эстиматора — импорт локальный, как и в остальных + ручках этого файла, чтобы не тащить тяжёлый app.services.estimator + в module-level import graph): ST_DWithin 1000м, rooms точное совпадение, + area ±15%, scraped_at не старше 14 дней, is_active. + + В ответе НЕТ ни одной цены — см. CoverageProbeResponse docstring. + + #oblast (2026-08): house_placement_history.exposure_days — реальная (не + цензурированная) экспозиция history-строк — НЕ используется здесь: это + house-level архив (join по house_id, не привязан к текущей radius/rooms/ + area когорте один-в-один), а не активные листинги в подобранном радиусе; + сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша + в честности (у нас и так честное имя поля — age активного объявления, не + срок продажи). См. openQuestions PR #2894 при ревью. + """ + from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS + + area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE) + area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE) + + row = ( + db.execute( + text( + f""" + WITH base AS ( + SELECT + city, + days_on_market, + row_number() OVER ( + PARTITION BY address ORDER BY scraped_at DESC + ) AS rn_addr, +{_RN_DUP_WINDOW} + FROM listings + WHERE is_active = true + AND rooms = :rooms + AND area_m2 BETWEEN :area_min AND :area_max + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND ST_DWithin( + geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius + ) + ) + SELECT + count(*) AS n_listings, + percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market) + AS median_age_days, + mode() WITHIN GROUP (ORDER BY city) + FILTER (WHERE city IS NOT NULL) AS cohort_city + FROM base + WHERE rn_addr <= :max_per_addr + AND rn_dup = 1 + """ + ), + { + "rooms": payload.rooms, + "area_min": area_min, + "area_max": area_max, + "fresh_days": COVERAGE_FRESH_DAYS, + "lat": payload.lat, + "lon": payload.lon, + "radius": COVERAGE_RADIUS_M, + "max_per_addr": MAX_ANALOGS_PER_ADDRESS, + }, + ) + .mappings() + .fetchone() + ) + + n_listings = int(row["n_listings"]) if row else 0 + median_age = ( + round(row["median_age_days"]) + if row is not None and row["median_age_days"] is not None + else None + ) + cohort_city = row["cohort_city"] if row else None + + city, threshold, supported = _resolve_coverage_city(payload.city_hint, cohort_city) + + if not supported or n_listings == 0: + status: Literal["ok", "thin", "not_covered"] = "not_covered" + elif n_listings >= threshold: + status = "ok" + else: + status = "thin" + + logger.info( + "coverage probe rooms=%d area=%.1f city=%r status=%s n=%d", + payload.rooms, + payload.area_m2, + city, + status, + n_listings, + ) + + return CoverageProbeResponse( + status=status, + n_listings=n_listings, + median_listing_age_days=median_age, + radius_m=COVERAGE_RADIUS_M, + city=city, + threshold=threshold, + ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index d7666f84..d8fc5c9b 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -756,3 +756,51 @@ class LocationIndexResponse(BaseModel): radius_m: int nearby_poi: list[NearbyPoiOut] poi_status: str + + +class CoverageProbeInput(BaseModel): + """Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия. + + lat/lon — координаты, уже разрезолвленные фронтом (тот же контракт, что + TradeInEstimateInput.lat/lon — geocode делает фронт/автокомплит, эта ручка + сама НИКОГО не геокодирует). city_hint — опционально, из того же + автокомплита (см. TradeInEstimateInput.city_hint); без него город + резолвится best-effort из моды city найденной когорты. + """ + + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + rooms: int = Field(ge=0, le=10) # 0 = студия + area_m2: float = Field(gt=10, lt=500) + city_hint: str | None = Field(default=None, max_length=100) + + +class CoverageProbeResponse(BaseModel): + """Ответ POST /api/v1/trade-in/coverage. + + НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни ₽/м²) — продуктовое + правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть + и как быстро они уходят, а саму цену продукт продаёт на платном шаге. + + status: + - "ok" — n_listings >= порога для этого города (зелёный/жёлтый список). + - "thin" — когорта непустая, но n_listings < порога. + - "not_covered" — город вне зелёного/жёлтого списка ИЛИ когорта пустая + (n_listings == 0) — независимо от того, поддерживается город или нет. + + median_listing_age_days — ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления + (days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная + выборка (активные объявления ещё висят) всегда завышена относительно + реального времени экспозиции проданных — не путать со «сроком продажи». + + threshold — n, начиная с которого статус переходит в "ok" для резолвленного + города; 0, если город не входит ни в один список (порог неприменим — + статус в этом случае всегда "not_covered" вне зависимости от n_listings). + """ + + status: Literal["ok", "thin", "not_covered"] + n_listings: int + median_listing_age_days: int | None + radius_m: int + city: str + threshold: int diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py new file mode 100644 index 00000000..42445e28 --- /dev/null +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -0,0 +1,271 @@ +"""Tests for POST /api/v1/trade-in/coverage (issue #2894). + +Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек +видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без +единой рублёвой цифры в ответе. Covers: + - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов + - пустая когорта (n=0) → not_covered даже в поддерживаемом городе + - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) + - city_hint приоритетнее моды city из когорты + - median_listing_age_days — median(days_on_market), None при пустой когорте +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import +# (trade_in.py imports generate_trade_in_pdf at module load). +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router with DB overridden.""" + from app.api.v1 import trade_in as trade_in_module + from app.core.db import get_db + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + def _override_db(): + yield MagicMock() + + application.dependency_overrides[get_db] = _override_db + return application + + +def _db_mock_returning(row: dict | None) -> MagicMock: + """DB session mock — coverage_probe reads db.execute(...).mappings().fetchone().""" + db = MagicMock() + mapping_result = MagicMock() + mapping_result.fetchone.return_value = row + execute_result = MagicMock() + execute_result.mappings.return_value = mapping_result + db.execute.return_value = execute_result + return db + + +def _override(app: FastAPI, db: MagicMock) -> None: + from app.core.db import get_db + + app.dependency_overrides[get_db] = lambda: (yield db) + + +_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0} + + +# ── Response schema: NO price anywhere (issue #2894 hard rule) ──────────────── + +_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost") + + +def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Regression guard: response schema must never grow a price-shaped field.""" + from app.schemas.trade_in import CoverageProbeResponse + + field_names = set(CoverageProbeResponse.model_fields.keys()) + offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}" + + +def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Same guard but on a live serialized response (belt-and-suspenders).""" + db = _db_mock_returning( + {"n_listings": 10, "median_age_days": 21.0, "cohort_city": "Екатеринбург"} + ) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"response body must not carry price fields: {offending} in {data}" + + +# ── Thresholds: green city ───────────────────────────────────────────────────── + + +def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" + db = _db_mock_returning( + {"n_listings": 8, "median_age_days": 15.0, "cohort_city": "Екатеринбург"} + ) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["n_listings"] == 8 + assert data["threshold"] == 8 + assert data["city"] == "Екатеринбург" + assert data["radius_m"] == 1000 + assert data["median_listing_age_days"] == 15 + + +def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered.""" + db = _db_mock_returning( + {"n_listings": 7, "median_age_days": 10.0, "cohort_city": "Екатеринбург"} + ) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["status"] == "thin" + assert data["n_listings"] == 7 + assert data["threshold"] == 8 + + +# ── Thresholds: yellow city ───────────────────────────────────────────────────── + + +def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Нижний Тагил (жёлтый, порог 12) — n=12 → ok.""" + db = _db_mock_returning( + {"n_listings": 12, "median_age_days": 30.0, "cohort_city": "Нижний Тагил"} + ) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, "city_hint": "Нижний Тагил"}, + ) + data = resp.json() + assert data["status"] == "ok" + assert data["threshold"] == 12 + assert data["city"] == "Нижний Тагил" + + +def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Ревда, n=11 (< порог 12) → thin.""" + db = _db_mock_returning({"n_listings": 11, "median_age_days": 40.0, "cohort_city": None}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Ревда"}) + data = resp.json() + assert data["status"] == "thin" + assert data["threshold"] == 12 + + +# ── City outside both lists → always not_covered ──────────────────────────────── + + +def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None: + """Город вне списков → not_covered независимо от n_listings (даже n=500).""" + db = _db_mock_returning({"n_listings": 500, "median_age_days": 5.0, "cohort_city": "Серов"}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Серов"}) + data = resp.json() + assert data["status"] == "not_covered" + assert data["threshold"] == 0 + assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует + + +# ── Empty cohort ────────────────────────────────────────────────────────────── + + +def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: + """n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.""" + db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post( + "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"} + ) + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + + +def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: + """DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но + coverage_probe обязан не падать, даже если mock/driver вернул пусто).""" + db = _db_mock_returning(None) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + + +# ── city_hint priority over cohort mode ───────────────────────────────────────── + + +def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None: + """city_hint (фронт) побеждает cohort_city (SQL mode) при определении города.""" + db = _db_mock_returning({"n_listings": 9, "median_age_days": 12.0, "cohort_city": "Серов"}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"}, + ) + data = resp.json() + assert data["city"] == "Екатеринбург" + assert data["status"] == "ok" # n=9 >= 8 (зелёный порог), не серовский not_covered + + +def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None: + """«Березовский» без ё должен резолвиться в тот же зелёный порог, что «Берёзовский».""" + db = _db_mock_returning({"n_listings": 8, "median_age_days": 5.0, "cohort_city": None}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, "city_hint": "березовский"}, + ) + data = resp.json() + assert data["status"] == "ok" + assert data["threshold"] == 8 + + +# ── DB dedup / cap params passed through ──────────────────────────────────────── + + +def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None: + """SQL params: radius=1000 (строго), area ±15%, rooms exact.""" + db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None}) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + assert db.execute.called + call_args = db.execute.call_args + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) + assert params["radius"] == 1000 + assert params["rooms"] == 2 + assert params["area_min"] == pytest.approx(50.0 * 0.85) + assert params["area_max"] == pytest.approx(50.0 * 1.15) + assert params["fresh_days"] == 14