From 247c252b04d5cfc16f3140c8d6a8fc2d82d2124a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 11:57:29 +0300 Subject: [PATCH] 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 { -- 2.45.3