Merge pull request 'fix(site-finder): honest POI-score label + drop duplicate MOI column (audit #1871 P2)' (#1877) from audit/1871-p2-invest-moi into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 2m54s
Deploy / build-frontend (push) Successful in 3m15s
Deploy / deploy (push) Successful in 1m22s

This commit is contained in:
bot-backend 2026-06-23 07:39:47 +00:00
commit bfa95e4aee
7 changed files with 43 additions and 25 deletions

View file

@ -2966,8 +2966,9 @@ def analyze_parcel(
"score_label": _score_label(score_final), "score_label": _score_label(score_final),
"score_max_reference": SCORE_MAX_REFERENCE, "score_max_reference": SCORE_MAX_REFERENCE,
"score_explanation": ( "score_explanation": (
"Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. " "POI-скор (040, оценочный) — взвешенная сумма близких POI с учётом "
">40 = редко, типичный город. центр 15-30." "расстояния + бонус за центр. Не инвест-рейтинг: школы/сады/парки "
"повышают, трамвайные пути снижают. Типичный город 1530, >40 редко."
), ),
"score_breakdown": by_category, "score_breakdown": by_category,
# X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group. # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.

View file

@ -245,8 +245,8 @@ function SiteFinderContent() {
? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}`
: "—"; : "—";
const scoreLabel = data?.score_label const scoreLabel = data?.score_label
? `Score · ${data.score_label}` ? `POI-скор · ${data.score_label} · оценочный`
: "Score"; : "POI-скор · оценочный";
const scoreCardColor: KpiColor = data const scoreCardColor: KpiColor = data
? scoreKpiColor(data.score, scoreMax) ? scoreKpiColor(data.score, scoreMax)
: "neutral"; : "neutral";

View file

@ -74,9 +74,9 @@ const COMPARISON_ROWS: ComparisonRow[] = [
gendesign: "DOM.РФ + Objective, еженедельно", gendesign: "DOM.РФ + Objective, еженедельно",
}, },
{ {
feature: "Инвест-балл участка и объяснение", feature: "POI-скор участка и объяснение",
nspd: null, nspd: null,
gendesign: "Score 0100 с разбором факторов", gendesign: "POI-скор 040 с разбором факторов",
}, },
{ {
feature: "Прогноз цены и горизонты выхода", feature: "Прогноз цены и горизонты выхода",

View file

@ -22,9 +22,14 @@ interface Props {
value: string; value: string;
label: string; label: string;
color?: KpiColor; color?: KpiColor;
/**
* Опциональная подпись под значением (caption-токен 12/16, --fg-tertiary).
* Видимая (не hover) для честных квалификаторов вроде «оценочный».
*/
caption?: string;
} }
export function KpiCard({ value, label, color = "neutral" }: Props) { export function KpiCard({ value, label, color = "neutral", caption }: Props) {
return ( return (
<div <div
style={{ style={{
@ -47,6 +52,18 @@ export function KpiCard({ value, label, color = "neutral" }: Props) {
> >
{value} {value}
</div> </div>
{caption ? (
<div
style={{
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{caption}
</div>
) : null}
<div <div
style={{ style={{
fontSize: 11, fontSize: 11,

View file

@ -3,9 +3,13 @@
/** /**
* 6.1 Прогноз по горизонтам compact table over future_market.forecasts_by_horizon. * 6.1 Прогноз по горизонтам compact table over future_market.forecasts_by_horizon.
* *
* Columns: горизонт (мес) · индекс дефицита (color-coded) · месяцы предложения · * Columns: горизонт (мес) · индекс дефицита (color-coded) ·
* прогноз спроса/предложения · ставка (% годовых) · фраза чувствительности к ставке. * прогноз спроса/предложения · ставка (% годовых) · фраза чувствительности к ставке.
* *
* MOI (months_of_inventory) НЕ выводим по горизонтам: по определению он не зависит
* от горизонта проекции, поэтому колонка дублировала одно и то же число 4 раза
* (выглядело сломанным). MOI показан один раз в Section6Forecast key_numbers.
*
* Deficit semantics (HARD, see frontend rules): * Deficit semantics (HARD, see frontend rules):
* deficit_index > 0 недонасыщенность (повод строить) success/green * deficit_index > 0 недонасыщенность (повод строить) success/green
* deficit_index < 0 затоварка (предложения > спроса) warn/danger * deficit_index < 0 затоварка (предложения > спроса) warn/danger
@ -15,7 +19,6 @@
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import type { DemandSupplyForecast } from "@/types/forecast"; import type { DemandSupplyForecast } from "@/types/forecast";
import { import {
DEFICIT_BALANCE_EPS,
deficitVariant, deficitVariant,
deficitWord, deficitWord,
fmtNum, fmtNum,
@ -77,12 +80,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<tr> <tr>
<th style={TH_STYLE}>Горизонт</th> <th style={TH_STYLE}>Горизонт</th>
<th style={TH_STYLE}>Индекс дефицита</th> <th style={TH_STYLE}>Индекс дефицита</th>
<th
style={{ ...TH_STYLE, textAlign: "right" }}
title="Во сколько месяцев распродаётся текущий объём при базовом темпе"
>
Мес. предложения
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}> <th style={{ ...TH_STYLE, textAlign: "right" }}>
Прогноз спроса, квартир Прогноз спроса, квартир
</th> </th>
@ -95,7 +92,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<tbody> <tbody>
{rows.map((f) => { {rows.map((f) => {
const di = f.deficit_index; const di = f.deficit_index;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
const isTarget = f.horizon_months === targetHorizon; const isTarget = f.horizon_months === targetHorizon;
return ( return (
<tr <tr
@ -142,9 +138,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
{deficitWord(di)} {deficitWord(di)}
</span> </span>
</td> </td>
<td style={NUM_TD}>
{balanced ? "—" : fmtNum(f.months_of_inventory, 1)}
</td>
<td style={NUM_TD}> <td style={NUM_TD}>
{Math.round(f.projected_demand_units).toLocaleString("ru")} {Math.round(f.projected_demand_units).toLocaleString("ru")}
</td> </td>

View file

@ -202,13 +202,15 @@ export function Section1ParcelInfo({ cad }: Props) {
: "—"; : "—";
const scoreStr = `${data.score}`; const scoreStr = `${data.score}`;
// Score color // Score color — пороги выровнены под калиброванную 040 шкалу _score_label
// (#1871 P2: было 0100, давало red на хорошем ~30/40 участке).
// <5 плохо · 515 средне · 1525 хорошо · ≥25 отлично.
const scoreColor = const scoreColor =
data.score >= 80 data.score >= 25
? "green" ? "green"
: data.score >= 65 : data.score >= 15
? "blue" ? "blue"
: data.score >= 45 : data.score >= 5
? "amber" ? "amber"
: "red"; : "red";
@ -276,7 +278,12 @@ export function Section1ParcelInfo({ cad }: Props) {
<KpiCard value={areaHa} label="Площадь" color="neutral" /> <KpiCard value={areaHa} label="Площадь" color="neutral" />
<KpiCard value={district} label="Район" color="neutral" /> <KpiCard value={district} label="Район" color="neutral" />
<KpiCard value={medianPrice} label="Медиана рынка" color="blue" /> <KpiCard value={medianPrice} label="Медиана рынка" color="blue" />
<KpiCard value={scoreStr} label="Инвест-балл" color={scoreColor} /> <KpiCard
value={scoreStr}
label="POI-скор · 040"
caption="оценочный"
color={scoreColor}
/>
</div> </div>
{/* 2-column grid: map (left) + EGRN + POI (right) */} {/* 2-column grid: map (left) + EGRN + POI (right) */}

View file

@ -102,7 +102,7 @@ const METRIC_ROWS: MetricRow[] = [
}, },
{ {
key: "score", key: "score",
label: "Инвест-балл", label: "POI-скор · 040",
winner: "high", winner: "high",
value: (c) => c.score ?? null, value: (c) => c.score ?? null,
render: (c) => ( render: (c) => (