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:
commit
c701636515
7 changed files with 174 additions and 31 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
48
data/sql/103_seed_prinzip_mappings.sql
Normal file
48
data/sql/103_seed_prinzip_mappings.sql
Normal 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;
|
||||
|
|
@ -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%"
|
||||
>
|
||||
>100%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue