Merge forgejo/main into fix/sf-01-velocity-time-window

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.
This commit is contained in:
lekss361 2026-05-17 12:15:40 +03:00
commit c701636515
7 changed files with 174 additions and 31 deletions

View file

@ -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):

View file

@ -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"],
)
)

View file

@ -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)]

View file

@ -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;

View file

@ -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 ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
{row.is_oversold && (
<span
style={{
background: "var(--warn-soft)",
color: "var(--warn)",
fontSize: 10,
fontWeight: 600,
padding: "1px 5px",
borderRadius: 4,
whiteSpace: "nowrap",
}}
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
>
&gt;100%
</span>
)}
</span>
) : (
"—"
)}
</td>
</tr>
))}

View file

@ -28,23 +28,20 @@ export function MarketTab({ data }: Props) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* D4 (#36) — Pipeline 24mo */}
{data.pipeline_24mo && <Pipeline24moBlock data={data.pipeline_24mo} />}
{/* Market trend */}
{hasTrend && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<MarketTrendBlock trend={data.market_trend} />
{/* Competitors */}
{data.competitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>Конкуренты</SectionLabel>
<CompetitorTable
competitors={data.competitors}
districtName={data.district?.district_name}
/>
</div>
)}
{/* Issue #113 — data-driven ТЗ на проектирование */}
<BestLayoutsBlock cadNum={data.cad_num} />
{/* D2 (#34) — Velocity-score */}
{hasVelocity && (
<div
@ -59,17 +56,23 @@ export function MarketTab({ data }: Props) {
</div>
)}
{/* Competitors */}
{data.competitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>Конкуренты</SectionLabel>
<CompetitorTable
competitors={data.competitors}
districtName={data.district?.district_name}
/>
{/* Market trend */}
{hasTrend && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
{/* D4 (#36) — Pipeline 24mo */}
{data.pipeline_24mo && <Pipeline24moBlock data={data.pipeline_24mo} />}
{/* Success recommendation */}
{hasRecommendation && (
<div>
@ -82,9 +85,6 @@ export function MarketTab({ data }: Props) {
</div>
)}
{/* Issue #113 — data-driven ТЗ на проектирование */}
<BestLayoutsBlock cadNum={data.cad_num} />
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
</div>
);

View file

@ -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 {