diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 2452bc99..33462243 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -192,6 +192,8 @@ class Competitor(BaseModel): lat: float lng: float stage: str | None + site_status: str | None = None + ready_dt: dt.date | None = None flats_total: int | None flats_sold: int | None sold_pct: float | None @@ -252,7 +254,8 @@ class TopLayoutRow(BaseModel): avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj avg_area_m2: float supply_units_in_radius: int - sold_pct_of_supply: float | None # NULL если supply=0 + sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0 + is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна) class LayoutTzMixRow(BaseModel): diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 01578244..364dc8ff 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -386,8 +386,13 @@ def get_best_layouts( supply_count = supply_map.get((room_bucket, ab), 0) sold_pct: float | None = None + is_oversold = False if supply_count > 0: - sold_pct = round(deals_window / supply_count * 100.0, 1) + sold_pct_raw = deals_window / supply_count * 100.0 + is_oversold = sold_pct_raw > 100.0 + # Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы. + # Значения >100% артефакт окна, не реальная «распроданность». + sold_pct = round(min(sold_pct_raw, 100.0), 1) # data window if r["window_start"] is not None: @@ -419,6 +424,7 @@ def get_best_layouts( "avg_area_m2": avg_area, "supply_units_in_radius": supply_count, "sold_pct_of_supply": sold_pct, + "is_oversold": is_oversold, } ) @@ -444,6 +450,7 @@ def get_best_layouts( avg_area_m2=round(row["avg_area_m2"], 1), supply_units_in_radius=row["supply_units_in_radius"], sold_pct_of_supply=row["sold_pct_of_supply"], + is_oversold=row["is_oversold"], ) ) diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 34591aa7..70066681 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -362,6 +362,64 @@ def test_target_total_flats_fills_abs_units() -> None: app.dependency_overrides.clear() +def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None: + """raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True.""" + id_rows = [_obj_id_row(1)] + # sum_deals=199, supply=100 → raw = 199% (несопоставимые окна) + vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])] + supply_rows = [_supply_row("2", "40-60", 100)] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + supply_rows=supply_rows, + latest_snap=dt.date.today(), + ) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app)) + assert resp.status_code == 200, resp.text + body = resp.json() + top = body["top_layouts"] + assert len(top) == 1 + row = top[0] + assert row["sold_pct_of_supply"] == 100.0, "sold_pct_of_supply должен быть clamped до 100" + assert row["is_oversold"] is True, "is_oversold должен быть True когда raw > 100" + finally: + app.dependency_overrides.clear() + + +def test_sold_pct_below_100_is_not_oversold() -> None: + """raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False.""" + id_rows = [_obj_id_row(1)] + # sum_deals=50, supply=100 → raw = 50% + vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])] + supply_rows = [_supply_row("1", "40-60", 100)] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + supply_rows=supply_rows, + latest_snap=dt.date.today(), + ) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app)) + assert resp.status_code == 200, resp.text + body = resp.json() + top = body["top_layouts"] + assert len(top) == 1 + row = top[0] + assert row["sold_pct_of_supply"] == pytest.approx(50.0) + assert row["is_oversold"] is False + finally: + app.dependency_overrides.clear() + + def test_filter_competitor_obj_ids_applied() -> None: """filter_competitor_obj_ids=[1] оставляет только obj_id=1.""" id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] diff --git a/data/sql/103_seed_prinzip_mappings.sql b/data/sql/103_seed_prinzip_mappings.sql new file mode 100644 index 00000000..c2dc23d9 --- /dev/null +++ b/data/sql/103_seed_prinzip_mappings.sql @@ -0,0 +1,48 @@ +-- 103_seed_prinzip_mappings.sql +-- Audit 2026-05-17: bulk insert missing PRINZIP project mappings +-- into objective_complex_mapping so mv_layout_velocity picks them up. +-- +-- Problem: objective_corpus_room_month had active deals for these projects +-- but no row in objective_complex_mapping → mv_layout_velocity JOIN skipped them. +-- +-- Audit source: audits/SiteFinder_FixList_May17.md #4 +-- +-- UNIQUE constraint on table: (objective_complex_name, objective_group) +-- MV join key: objective_complex_mapping.objective_complex_name = objective_corpus_room_month.project_name +-- One domrf_obj_id per project_name; mapped to the largest/most active corpus. +-- +-- Skipped: +-- Парк Победы — already mapped to obj_id=60160 (Строящиеся, PRINZIP) +-- Парковый квартал — 0 deals/12m, inactive +-- Первый — belongs to ГК ИКАР, NOT PRINZIP; name collision, not a real match +-- +-- After applying this migration run manually (post-deploy): +-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity; +-- See runbook: runbooks/Refresh_MV_Layout_Velocity.md +-- +-- Dependencies: objective_complex_mapping (exists), mv_layout_velocity (refresh separately) +-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking. +-- Idempotent: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING + +BEGIN; + +INSERT INTO objective_complex_mapping + (objective_complex_name, objective_group, domrf_obj_id, + match_method, is_reviewed, note) +VALUES + -- 98 deals/12m in Objective. + -- obj_id 64701 = ЖК "Малевич" (Строящиеся, 244 кв, сдача 2027-09, дев: СЗ МАЛЕВИЧ). + -- obj_id 3687 = ЖК "Малевич" (Сданный, 348 кв, сдан 2020-03, дев: PRINZIP) — исторический. + -- 64701 выбран как активный primary corpus. + ('Малевич', 'Екатеринбург', 64701, 'manual', true, + 'Audit 2026-05-17: Строящиеся 244кв сдача 2027-09; obj_id 3687 — сданный исторический'), + + -- 30 deals/12m in Objective. + -- Корпуса: obj_id 47390 (411кв Сданные), 37880 (157кв), 47393 (86кв), 47392 (0кв skip). + -- 47390 выбран как крупнейший corpus. + ('Ньютон парк', 'Екатеринбург', 47390, 'manual', true, + 'Audit 2026-05-17: largest corpus 411кв из трёх Ньютон Парк корпусов') + +ON CONFLICT (objective_complex_name, objective_group) DO NOTHING; + +COMMIT; diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index 63224696..9a1919af 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -172,9 +172,35 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) { fontVariantNumeric: "tabular-nums", }} > - {row.sold_pct_of_supply != null - ? `${(row.sold_pct_of_supply ?? 0).toFixed(0)}%` - : "—"} + {row.sold_pct_of_supply != null ? ( + + {`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`} + {row.is_oversold && ( + + >100% + + )} + + ) : ( + "—" + )} ))} diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index cfbc4df5..85f92eae 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -28,23 +28,20 @@ export function MarketTab({ data }: Props) { return (