diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 80d82b6d..616702e2 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -27,7 +27,6 @@ from app.schemas.parcel import ( ConnectionPointsResponse, OpportunityParcel, ParcelBboxResponse, - ParcelDetail, ParcelMapMarker, ParcelMeta, ParcelSearchRequest, @@ -1047,12 +1046,10 @@ async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse: # ── #994 (961-C3, EPIC 961): run-history read endpoints ─────────────────────── # -# ВАЖНО про порядок маршрутов: `/runs/{run_id}` объявлен ВЫШЕ `/{parcel_id}`. -# FastAPI матчит роуты в порядке регистрации — если бы `/{parcel_id}` шёл первым, -# запрос `GET /runs/123` ушёл бы в get_parcel с parcel_id="runs" (литерал "runs" -# съелся бы как path-param). Объявление здесь, до `/{parcel_id}`, гарантирует, что -# `/runs/{run_id}` резолвится корректно. `/{cad_num}/runs` (2 сегмента) с -# `/{parcel_id}` (1 сегмент) по числу сегментов не конфликтует, но держим рядом. +# #251: мёртвый 1-сегментный catch-all `GET /{parcel_id}` (501 Not implemented, +# Stage 2b так и не наступил, фронт его не звал) удалён. Поэтому 1-сегментного +# конфликта с `/runs/{run_id}` больше нет. Catch-all на этом router снова заводить +# НЕ стоит без явной нужды — он перехватывал бы любой 1-сегментный путь. @router.get("/runs/{run_id}", response_model=AnalysisRunDetail) @@ -1094,12 +1091,6 @@ def list_analysis_runs( return AnalysisRunListResponse(runs=runs) -@router.get("/{parcel_id}", response_model=ParcelDetail) -async def get_parcel(parcel_id: str) -> ParcelDetail: - """TODO Stage 2b: fetch parcel by id from DB.""" - raise HTTPException(status_code=501, detail="Not implemented yet") - - @router.get("/{cad_num}/fetch-status") def get_fetch_status( cad_num: str, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 77eda79a..25b3b7f0 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -165,11 +165,6 @@ class ParcelSearchResponse(BaseModel): total: int -class ParcelDetail(ParcelSummary): - geometry_geojson: dict[str, Any] - enrichment: dict[str, Any] - - # ── Competitors endpoint schemas ────────────────────────────────────────────── TimeWindow = Literal["last_month", "last_quarter", "last_year"] diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2e0c35c9..ce9657ee 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1651,6 +1651,13 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]: Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим 'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'. + + #236: period — ТЕКСТ вида 'Февраль 2026', поэтому `ORDER BY period DESC` + сортировал лексикографически по названию месяца ('Январь' > 'Февраль', + т.к. «Я» > «Ф») и возвращал устаревшую точку. Сортируем по разобранной + дате (год DESC, затем порядковый номер месяца DESC). Regex-guard + `'^[А-Яа-я]+ [0-9]{4}$'` отсекает мусорные строки, где в period лежит + число (доля ИЖС вида '5.57'), иначе split_part(...)::int упал бы. """ row = ( db.execute( @@ -1662,7 +1669,15 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]: AND title ILIKE '%в рублях, %' AND value IS NOT NULL AND value BETWEEN 1 AND 30 -- защита от мусорных - ORDER BY period DESC + AND period ~ '^[А-Яа-я]+ [0-9]{4}$' -- 'Месяц YYYY', иначе skip + ORDER BY + split_part(period, ' ', 2)::int DESC, -- год + CASE split_part(period, ' ', 1) -- порядковый № месяца + WHEN 'Январь' THEN 1 WHEN 'Февраль' THEN 2 WHEN 'Март' THEN 3 + WHEN 'Апрель' THEN 4 WHEN 'Май' THEN 5 WHEN 'Июнь' THEN 6 + WHEN 'Июль' THEN 7 WHEN 'Август' THEN 8 WHEN 'Сентябрь' THEN 9 + WHEN 'Октябрь' THEN 10 WHEN 'Ноябрь' THEN 11 WHEN 'Декабрь' THEN 12 + END DESC LIMIT 1 """ ) @@ -1675,6 +1690,27 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]: return _f(row["value"]), row["period"] +def _recommend_data_last_updated(db: Session, *, region_code: int) -> str | None: + """#237: дата актуальности данных recommend — MAX(snapshot_date) по основному + источнику (domrf_kn_objects, ЖК региона). ISO 'YYYY-MM-DD' для UI-индикатора + свежести; None если в регионе нет объектов с непустым snapshot_date. + """ + val = db.execute( + text( + """ + SELECT MAX(snapshot_date) + FROM domrf_kn_objects + WHERE region_cd = :rc + AND snapshot_date IS NOT NULL + """ + ), + {"rc": region_code}, + ).scalar() + if val is None: + return None + return val.isoformat() + + def _active_competitors_count( db: Session, *, @@ -2611,6 +2647,9 @@ def recommend_mix( mortgage_rate, mortgage_period = _current_mortgage_rate(db) + # #237: индикатор актуальности данных — MAX(snapshot_date) основного источника + data_last_updated = _recommend_data_last_updated(db, region_code=region_code) + # #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"]) @@ -2892,6 +2931,8 @@ def recommend_mix( "poi_factor": round(poi_factor, 4), "mortgage_rate_pct": mortgage_rate, "mortgage_rate_period": mortgage_period, + # #237: дата актуальности данных recommend (ISO 'YYYY-MM-DD' | null) + "last_updated": data_last_updated, "elasticity": elasticity, "elasticity_r2": elast["r2"], "elasticity_n": elast["n"], diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py index 12451ecc..1a36345d 100644 --- a/backend/tests/services/test_recommend_mix_velocity.py +++ b/backend/tests/services/test_recommend_mix_velocity.py @@ -322,6 +322,7 @@ def _run_recommend_mix_full( patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)), patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])), patch(f"{_MOD}._bucket_success_ranking", return_value=[]), + patch(f"{_MOD}._recommend_data_last_updated", return_value=None), ] with ( @@ -339,6 +340,7 @@ def _run_recommend_mix_full( patches[11], patches[12], patches[13], + patches[14], ): return recommend_mix( db, diff --git a/frontend/src/app/analytics/recommend/page.tsx b/frontend/src/app/analytics/recommend/page.tsx index e362b69d..bcfb7045 100644 --- a/frontend/src/app/analytics/recommend/page.tsx +++ b/frontend/src/app/analytics/recommend/page.tsx @@ -193,6 +193,9 @@ export default function RecommendPage() { data.scope.poi_score_city_avg != null ? ` · POI ${data.scope.poi_score.toFixed(0)}/${data.scope.poi_score_city_avg.toFixed(0)} (${data.scope.poi_score > data.scope.poi_score_city_avg ? "выше" : "ниже"} среднего)` : ""} + {data.scope.last_updated + ? ` · данные на ${new Date(data.scope.last_updated).toLocaleDateString("ru")}` + : ""} ) : null} diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index d8b9c80f..e1bd8eca 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -395,6 +395,8 @@ export interface RecommendMixOutput { poi_factor: number; mortgage_rate_pct: number | null; mortgage_rate_period: string | null; + /** #237 — дата актуальности данных (ISO 'YYYY-MM-DD') | null */ + last_updated: string | null; elasticity: number; elasticity_r2: number; elasticity_n: number;