3 carryover 🟡 items из re-review verdict на a88917f:
1. Coverage % denominator now post-filter (was mixing pre-filter count
с post-filter intersection → artificially low confidence when
user passed exclude/filter list)
2. MAX(snapshot_date) pre-computed via db.scalar() + bind :latest_snap
instead of subquery in WHERE (avoid re-execution per spatial scan)
3. LAYOUT_CONFIDENCE_HIGH_PCT / LAYOUT_CONFIDENCE_MEDIUM_PCT as
module-level constants instead of hardcoded literals
Integration test gap (#4 в re-review) — отдельный follow-up issue,
не в scope этого PR.
Tests: 10/10 best-layouts pass, 43/43 regression OK.
POST /api/v1/parcels/{cad_num}/best-layouts — top layout patterns
(room_bucket × area_bin) у конкурентов в радиусе с ranking by velocity
из mv_layout_velocity + unit-mix recommendation для архитектурного ТЗ.
Service (backend/app/services/site_finder/best_layouts.py, 563 LOC):
- centroid lookup с fallback (cad_parcels → cad_quarters)
- competitors-in-radius через ST_DWithin
- aggregate по mv_layout_velocity per room_bucket
- supply batch query (один SQL вместо N) для sold_pct_of_supply
- Hamilton largest-remainder normalizer (sum_pct=100 invariant)
Endpoint (backend/app/api/v1/parcels.py, +24 LOC) после /competitors.
Tests (backend/tests/api/v1/test_parcel_best_layouts.py, 10 mock tests):
404, empty competitors, MV empty, exclude/filter, min_velocity, time_window
scaling, obj_class_filter, target_total_flats, 3-bucket ranking.
Phase 2.1 (без layout_type / balcony_count — отсутствуют в БД,
ждут B2B Объектив #52). Reuses PR A schemas/helpers, PR B MV.
NOTE: competitors.py:143 status='sold' carryover bug НЕ fixed —
отдельный follow-up issue. PR C использует MV (через
objective_corpus_room_month) что работает корректно.