From 81bf798c27c1237c3271838770f2b615eadcb46f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 11:52:45 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(sf-06):=20MarketTab=20reorder=20?= =?UTF-8?q?=E2=80=94=20Competitors=20=E2=86=92=20BestLayouts=20=E2=86=92?= =?UTF-8?q?=20Velocity=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/site-finder/MarketTab.tsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) 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 (
- {/* D4 (#36) — Pipeline 24mo */} - {data.pipeline_24mo && } - - {/* Market trend */} - {hasTrend && ( -
- + {/* Competitors */} + {data.competitors.length > 0 && ( +
+ Конкуренты +
)} + {/* Issue #113 — data-driven ТЗ на проектирование */} + + {/* D2 (#34) — Velocity-score */} {hasVelocity && (
)} - {/* Competitors */} - {data.competitors.length > 0 && ( -
- Конкуренты - + {/* Market trend */} + {hasTrend && ( +
+
)} + {/* D4 (#36) — Pipeline 24mo */} + {data.pipeline_24mo && } + {/* Success recommendation */} {hasRecommendation && (
@@ -82,9 +85,6 @@ export function MarketTab({ data }: Props) {
)} - {/* Issue #113 — data-driven ТЗ на проектирование */} - - {!hasAny && }
); From 31b13c18b1708f895e40329555a610d744c18ca2 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 11:57:06 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(sf-04):=20seed=20PRINZIP=20project=20ma?= =?UTF-8?q?ppings=20=D0=B2=20objective=5Fcomplex=5Fmapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-17: objective_corpus_room_month содержал активные сделки для Малевич (98/12m) и Ньютон парк (30/12m), но записей в objective_complex_mapping не было → mv_layout_velocity пропускал оба проекта в velocity calculations. Добавлены маппинги: - Малевич → domrf_obj_id 64701 (Строящиеся, 244кв, сдача 2027-09) - Ньютон парк → domrf_obj_id 47390 (Сданные, 411кв, крупнейший corpus) ON CONFLICT DO NOTHING — идемпотентно. REFRESH mv_layout_velocity выполняется вручную post-deploy (runbooks/Refresh_MV_Layout_Velocity.md). Closes (epic part) #271 item 4 --- data/sql/103_seed_prinzip_mappings.sql | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 data/sql/103_seed_prinzip_mappings.sql 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; From 247c252b04d5cfc16f3140c8d6a8fc2d82d2124a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 11:57:29 +0300 Subject: [PATCH 3/3] fix(sf-05): clamp sold_pct_of_supply at 100% + add is_oversold flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: sold_pct_of_supply = sum_deals_24mo / supply_count_snapshot * 100 yields >100% (e.g. 199%) for fast-selling small formats due to incompatible time windows (24-month deals vs point-in-time supply snapshot). Option A (hotfix): clamp to 100.0, expose is_oversold=True for UI badge. - best_layouts.py: compute sold_pct_raw, clamp with min(..., 100.0), set is_oversold = raw > 100% - parcel.py TopLayoutRow: add is_oversold: bool field - best-layouts.ts TopLayoutRow: add is_oversold: boolean - BestLayoutsBlock.tsx: show warn badge ">100%" when is_oversold=True - tests: two new cases — raw 199% clamps + is_oversold=True; raw 50% passes through + is_oversold=False Closes (epic part) #271 item 5 --- backend/app/schemas/parcel.py | 5 +- .../app/services/site_finder/best_layouts.py | 9 ++- .../tests/api/v1/test_parcel_best_layouts.py | 58 +++++++++++++++++++ .../site-finder/BestLayoutsBlock.tsx | 32 +++++++++- frontend/src/types/best-layouts.ts | 3 +- 5 files changed, 101 insertions(+), 6 deletions(-) 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 ac3ca21c..4057e6ce 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -345,8 +345,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(sum_deals / supply_count * 100.0, 1) + sold_pct_raw = sum_deals / 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: @@ -378,6 +383,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, } ) @@ -403,6 +409,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 8b391f90..5421e79f 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -356,6 +356,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/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/types/best-layouts.ts b/frontend/src/types/best-layouts.ts index 15015fab..03fb4932 100644 --- a/frontend/src/types/best-layouts.ts +++ b/frontend/src/types/best-layouts.ts @@ -29,7 +29,8 @@ export interface TopLayoutRow { avg_price_per_m2_rub: number | null; avg_area_m2: number; supply_units_in_radius: number; - sold_pct_of_supply: number | null; + sold_pct_of_supply: number | null; // clamped at 100.0 + is_oversold: boolean; // raw ratio was >100% (incompatible time windows) } export interface LayoutTzMixRow {