fix(sf-01): time_window honest velocity — inline SQL с реальным фильтром report_month #275

Merged
lekss361 merged 2 commits from fix/sf-01-velocity-time-window into main 2026-05-17 09:24:57 +00:00
Owner

Summary

SF FixList #1 — fix главного «обмана» в Анализе планировок.

Раньше _VELOCITY_DIVISORS делил агрегаты mv_layout_velocity (всегда 24 мес) на 4/12 для time_window=quarter/year, не меняя реальное окно данных → mix/цена/число ЖК идентичные при переключении.

Изменения

  • Удалены _VELOCITY_DIVISORS и _VELOCITY_BY_ROOM_SQL (через mv_layout_velocity)
  • Добавлен _TIME_WINDOW_PARAMS:
    • last_month → 1 мес
    • last_quarter → 3 мес
    • last_year → 12 мес
  • Новый _INLINE_VELOCITY_SQL читает objective_corpus_room_month напрямую:
    WHERE crm.report_month >= NOW() - CAST(:window_interval AS interval)
    
  • velocity_per_month = deals_window / months_in_window

Test plan

  • 11/11 unit tests pass (test_best_layouts.py extended + new tests)
  • ruff + pre-commit pass
  • After deploy smoke: /best-layouts?time_window=last_quarter vs ?time_window=last_yearразный mix (не только divisor)

Files

  • backend/app/services/site_finder/best_layouts.py — main fix
  • backend/tests/services/site_finder/test_best_layouts.py — new, 11 tests
  • backend/tests/api/v1/test_parcel_best_layouts.py — mock updated
  • Vault: fixes/Fix_SF_TimeWindow_Honest_May17.md

Related: epic #271 item #1 (Wave 1)

## Summary SF FixList #1 — fix главного «обмана» в Анализе планировок. Раньше `_VELOCITY_DIVISORS` делил агрегаты `mv_layout_velocity` (всегда 24 мес) на 4/12 для `time_window=quarter`/`year`, не меняя реальное окно данных → mix/цена/число ЖК идентичные при переключении. ## Изменения - Удалены `_VELOCITY_DIVISORS` и `_VELOCITY_BY_ROOM_SQL` (через `mv_layout_velocity`) - Добавлен `_TIME_WINDOW_PARAMS`: - `last_month` → 1 мес - `last_quarter` → 3 мес - `last_year` → 12 мес - Новый `_INLINE_VELOCITY_SQL` читает `objective_corpus_room_month` напрямую: ```sql WHERE crm.report_month >= NOW() - CAST(:window_interval AS interval) ``` - `velocity_per_month = deals_window / months_in_window` ## Test plan - [x] 11/11 unit tests pass (`test_best_layouts.py` extended + new tests) - [x] ruff + pre-commit pass - [ ] After deploy smoke: `/best-layouts?time_window=last_quarter` vs `?time_window=last_year` → **разный mix** (не только divisor) ## Files - `backend/app/services/site_finder/best_layouts.py` — main fix - `backend/tests/services/site_finder/test_best_layouts.py` — new, 11 tests - `backend/tests/api/v1/test_parcel_best_layouts.py` — mock updated - Vault: `fixes/Fix_SF_TimeWindow_Honest_May17.md` Related: epic #271 item #1 (Wave 1)
lekss361 added 1 commit 2026-05-17 09:13:42 +00:00
Раньше _VELOCITY_DIVISORS делил агрегаты mv_layout_velocity (24 мес)
на 4/12 для quarter/year, не меняя реальное окно данных. Теперь
inline SQL из objective_corpus_room_month с CAST(:window_interval AS interval).

velocity_per_month = deals_window / months_in_window (1.0/3.0/12.0).
Разные time_window → разные строки из БД → разный mix/velocity/jk_count.

Closes (epic part) #271 item 1
lekss361 added 1 commit 2026-05-17 09:15:48 +00:00
Resolve conflict in best_layouts.py: keep SF-01 inline velocity
(deals_window numerator) + add SF-05 sold_pct clamp at 100% and
is_oversold flag from main.
Author
Owner

Deep Code Review — verdict APPROVE

Head SHA: c7016365 (mergeable: true — rebase уже выполнен автором в merge-commit 12:15 MSK).

Pre-check на конфликт с PR #274

Merge-commit c7016365 корректно резолвит конфликт в best_layouts.py:

  • SF-01 inline velocity (deals_window / months_in_window) сохранён
  • SF-05 is_oversold flag + sold_pct = min(round(raw, 1), 100.0) clamp (PR #274) сохранён в строках 388-396

Оба фикса сосуществуют корректно — sold_pct_raw = deals_window / supply_count * 100.0 использует новый deals_window (не sum_deals 24 мес), что делает clamp ещё точнее.

Что проверено

SQL / DB schema:

  • _INLINE_VELOCITY_SQL использует существующие колонки objective_corpus_room_month: deals_total_count, deals_total_avg_area_m2, deals_total_avg_price_thousand_rub_per_m2, report_month, project_name, room_bucket — проверено через MCP.
  • JOIN objective_complex_mapping.objective_complex_name = crm.project_name — корректный после PR #273 seed.
  • CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
  • 'студия' → 'studio' normalization в обоих CASE (SELECT + GROUP BY) — consistent.

Performance (EXPLAIN на проде через MCP):

Aggregate (cost 299..301, rows 5)
  Nested Loop (cost 0.29..296.28, rows 87)
    Seq Scan on objective_complex_mapping (rows 5)   -- ОК, мелкая таблица
    Index Scan: objective_crm_project_month_idx       -- (project_name, report_month DESC) подхвачен

Total cost ~300 — приемлемо для on-request без MV. Регрессии нет.

Correctness:

  • _TIME_WINDOW_PARAMS.get(request.time_window, ("3 months", 3.0)) — defensive fallback
  • months_in_window > 0 гарантирован конструкцией dict → нет div-by-zero
  • time_window: Literal["last_month","last_quarter","last_year"] в schemas/parcel.py:235 — frontend type-safe

Tests: 11 unit-тестов в новом test_best_layouts.py. Ключевой acceptance — test_different_time_windows_produce_different_velocity (last_month > last_quarter > last_year при одинаковом deals_window).

Vault: fixes/Fix_SF_TimeWindow_Honest_May17.md создана, корректно фиксирует что mv_layout_velocity оставлена (используется в admin_etl.py REFRESH + layout_velocity_refresh.py).

🟢 Low / nits (не блокирующее, future PR)

  1. best_layouts.py:394 — комментарий внутри clamp-блока («сделки за 24 мес») устарел: теперь deals_window фильтрован по окну. Унаследовано от PR #274.
  2. api/v1/parcels.py:2381 (вне scope) — docstring Reads from mv_layout_velocity устарел для /best-layouts.

Verdict

APPROVE — оба SF-01 и SF-05 фикса работают вместе, performance ОК, schema совпадает, tests покрывают acceptance criteria. На следующей итерации polling — можно мержить.

## Deep Code Review — verdict ✅ APPROVE **Head SHA:** `c7016365` (mergeable: true — rebase уже выполнен автором в merge-commit 12:15 MSK). ### Pre-check на конфликт с PR #274 Merge-commit `c7016365` корректно резолвит конфликт в `best_layouts.py`: - SF-01 inline velocity (`deals_window / months_in_window`) сохранён ✅ - SF-05 `is_oversold` flag + `sold_pct = min(round(raw, 1), 100.0)` clamp (PR #274) сохранён в строках 388-396 ✅ Оба фикса сосуществуют корректно — `sold_pct_raw = deals_window / supply_count * 100.0` использует **новый** `deals_window` (не sum_deals 24 мес), что делает clamp ещё точнее. ### Что проверено **SQL / DB schema:** - `_INLINE_VELOCITY_SQL` использует существующие колонки `objective_corpus_room_month`: `deals_total_count`, `deals_total_avg_area_m2`, `deals_total_avg_price_thousand_rub_per_m2`, `report_month`, `project_name`, `room_bucket` — проверено через MCP. - JOIN `objective_complex_mapping.objective_complex_name = crm.project_name` — корректный после PR #273 seed. - `CAST(:window_interval AS interval)` — psycopg v3 / SQLAlchemy 2.0 safe (не `::interval`). - `'студия' → 'studio'` normalization в обоих CASE (SELECT + GROUP BY) — consistent. **Performance (EXPLAIN на проде через MCP):** ``` Aggregate (cost 299..301, rows 5) Nested Loop (cost 0.29..296.28, rows 87) Seq Scan on objective_complex_mapping (rows 5) -- ОК, мелкая таблица Index Scan: objective_crm_project_month_idx -- (project_name, report_month DESC) подхвачен ``` Total cost ~300 — приемлемо для on-request без MV. Регрессии нет. **Correctness:** - `_TIME_WINDOW_PARAMS.get(request.time_window, ("3 months", 3.0))` — defensive fallback ✅ - `months_in_window > 0` гарантирован конструкцией dict → нет div-by-zero ✅ - `time_window: Literal["last_month","last_quarter","last_year"]` в `schemas/parcel.py:235` — frontend type-safe ✅ **Tests:** 11 unit-тестов в новом `test_best_layouts.py`. Ключевой acceptance — `test_different_time_windows_produce_different_velocity` (last_month > last_quarter > last_year при одинаковом deals_window). **Vault:** `fixes/Fix_SF_TimeWindow_Honest_May17.md` создана, корректно фиксирует что `mv_layout_velocity` оставлена (используется в `admin_etl.py` REFRESH + `layout_velocity_refresh.py`). ### 🟢 Low / nits (не блокирующее, future PR) 1. `best_layouts.py:394` — комментарий внутри clamp-блока («сделки за 24 мес») устарел: теперь `deals_window` фильтрован по окну. Унаследовано от PR #274. 2. `api/v1/parcels.py:2381` (вне scope) — docstring `Reads from mv_layout_velocity` устарел для `/best-layouts`. ### Verdict ✅ APPROVE — оба SF-01 и SF-05 фикса работают вместе, performance ОК, schema совпадает, tests покрывают acceptance criteria. На следующей итерации polling — можно мержить.
lekss361 merged commit 15e75c20dc into main 2026-05-17 09:24:57 +00:00
Author
Owner

Re-merge after rebase via deep-code-reviewer — все blockers закрыты.

Previous APPROVE: #275 (comment)

Sanity check pre-merge:

  • Head SHA c7016365158a0dbceee3aef6c0750e71f427d042 — стабильный с момента previous review (no new pushes)
  • mergeable: true, state: open, draft: false
  • SF-01 + SF-05 coexist, psycopg v3 CAST safe, 11/11 unit-tests pass

Merge commit: 15e75c20dc8f21462f8b49974d5fcb5a27a6a7ef

Post-deploy smoke checks (manual):

  • GET /best-layouts?time_window=last_quarter vs ?time_window=last_year → mix/цена/число ЖК должны отличаться (не просто divisor)
  • EXPLAIN на objective_corpus_room_month filter — Index Scan, cost ~300
  • sold_pct clamp в [0,1] + is_oversold флаг работает на oversold кейсах
Re-merge after rebase via deep-code-reviewer — все blockers закрыты. Previous APPROVE: https://git.gendsgn.ru/lekss361/gendesign/pulls/275#issuecomment-1205 Sanity check pre-merge: - Head SHA `c7016365158a0dbceee3aef6c0750e71f427d042` — стабильный с момента previous review (no new pushes) - `mergeable: true`, `state: open`, `draft: false` - SF-01 + SF-05 coexist, psycopg v3 CAST safe, 11/11 unit-tests pass Merge commit: `15e75c20dc8f21462f8b49974d5fcb5a27a6a7ef` Post-deploy smoke checks (manual): - `GET /best-layouts?time_window=last_quarter` vs `?time_window=last_year` → mix/цена/число ЖК должны **отличаться** (не просто divisor) - EXPLAIN на `objective_corpus_room_month` filter — Index Scan, cost ~300 - `sold_pct` clamp в [0,1] + `is_oversold` флаг работает на oversold кейсах
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#275
No description provided.