fix(analytics): свежая ипотечная ставка + recommend last_updated + drop dead route (#236 #237 #251)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Successful in 1m1s
CI / frontend-tests (pull_request) Successful in 1m3s
CI / backend-tests (push) Successful in 6m46s
CI / backend-tests (pull_request) Successful in 6m42s
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Successful in 1m1s
CI / frontend-tests (pull_request) Successful in 1m3s
CI / backend-tests (push) Successful in 6m46s
CI / backend-tests (pull_request) Successful in 6m42s
#236: _current_mortgage_rate сортировал cbr_mortgage_series по TEXT-полю period лексикографически ('Январь'>'Февраль') → возвращал stale-точку. Сортировка по parsed (year, month) + regex-guard на malformed period-строки. Остаётся на subsidized weighted series (НЕ domrf market — иная метрика). Verified prod: Февраль 2026 (было Январь 2026). #237: scope.last_updated = MAX(snapshot_date) domrf_kn_objects в recommend_mix + подпись «данные на <дата>» в recommend page. #251: удалён never-implemented GET /parcels/{parcel_id} (501) + ParcelDetail схема (frontend bare 1-сегментный route не зовёт). Closes #236 Closes #237 Closes #251
This commit is contained in:
parent
d1bc59a5dd
commit
1836a13fb5
6 changed files with 53 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue