fix(site-finder): три честности-фикса — B1 ГРС «выход №N» + околонуль + Росреестр-квартал #2257

Merged
bot-backend merged 1 commit from fix/polish-b1-rosreestr into main 2026-07-03 06:27:29 +00:00
6 changed files with 148 additions and 48 deletions
Showing only changes of commit f7e2dba19c - Show all commits

View file

@ -216,6 +216,19 @@ def _confidence_label(c: float) -> str:
return "low"
_ROMAN_QUARTERS = ("I", "II", "III", "IV")
def _deals_quarter_label(d: _dt.date) -> str:
"""Дата сделок Росреестра → «I квартал 2026».
Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
Месяц 1-3I, 4-6II, 7-9III, 10-12IV (римские).
"""
return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
@ -2873,12 +2886,16 @@ def analyze_parcel(
_deals_stale = (
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
)
# Разные причины отсутствия сделок → честная микрокопия: устаревание
# источника vs просто нет сделок рядом (источник свеж). Обе показывают
# Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
# = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
# предложение как основной контент.
_offer_only_label = (
"Сделки устарели — показываем динамику цен предложения"
if _deals_stale
(
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); показываем динамику цен предложения"
)
if _deals_stale and max_period is not None
else "Нет сделок рядом — показываем динамику цен предложения"
)
market_trend = {
@ -2891,13 +2908,17 @@ def analyze_parcel(
"offer_trend": offer_block,
}
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
# прежний source_stale остаётся честным «нечего показать».
# Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
# ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
# называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
# ТОЛЬКО когда и offer-данных нет (offer_block is None).
market_trend = {
"status": "source_stale",
"as_of_date": max_period.isoformat(),
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
"label": (
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); тренд по сделкам не показываем"
),
"recent_deals_count": 0,
}
else:

View file

@ -94,7 +94,10 @@ _SVERDLOVSK_TOKEN = "свердлов"
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
# ON CONFLICT (grs_name_norm, output_name)).
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
@ -168,12 +171,16 @@ def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
«ГРС Арти (выход 2)» («ГРС Арти», «выход 2»); фрагмент «(выход N)» бывает
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход 1) (5)» («ГРС-1 Свердловск (5)»,
«выход 1»)) вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
output_name канонизируется в «выход N» (нижний регистр, ровно один пробел после
«выход», сразу «N» без пробела) разнобой пробелов вокруг «» иначе дал бы
разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
«ГРС Арамиль (5)» без изменений (цифра в скобках НЕ выход, часть имени).
"""
m = _OUTPUT_SUFFIX_RE.search(raw_name)
if not m:
return raw_name, None
output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
# Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
output = f"{m.group(1).lower()}{m.group(2)}"
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
# двойные пробелы, оставшиеся на месте выреза в середине).
grs_name = raw_name[: m.start()] + raw_name[m.end() :]

View file

@ -204,6 +204,36 @@ def test_split_grs_name_no_output() -> None:
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
def test_split_grs_name_output_before_station_digit_real_rows() -> None:
"""B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается.
Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход N)»
стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым
output_name (5 строк). Локим на точных прод-именах не регрессировать.
"""
cases = {
"ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"),
"ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"),
"ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"),
"ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"),
"ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"),
}
for raw, expected in cases.items():
assert gg._split_grs_name(raw) == expected, raw
def test_split_grs_name_output_canonicalized() -> None:
"""output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№».
«Выход 1» / «выход 1» / «выход 1» все дают «выход 1» (иначе разный
output_name дубли по ON CONFLICT (grs_name_norm, output_name)).
"""
for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход №1)"):
grs_name, output = gg._split_grs_name(raw)
assert output == "выход №1", raw
assert grs_name == "ГРС Х"
# ── _passes_region_filter ─────────────────────────────────────────────────────

View file

@ -38,34 +38,22 @@ function fmtPrice(v: number): string {
}
/**
* P2 (#1871): дата as_of_date (YYYY-MM-DD) «месяц год» для caption о
* устаревшем источнике. Возвращает null если дата отсутствует/некорректна.
* Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01
* означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата
* YYYY-MM-DD «I квартал 2026» (римские I/II/III/IV по месяцу). null при
* отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() TZ).
*/
const RU_MONTHS = [
"январь",
"февраль",
"март",
"апрель",
"май",
"июнь",
"июль",
"август",
"сентябрь",
"октябрь",
"ноябрь",
"декабрь",
];
const ROMAN_QUARTERS = ["I", "II", "III", "IV"];
function formatAsOfMonth(asOf: string | undefined): string | null {
function formatDealsQuarter(asOf: string | undefined | null): string | null {
if (!asOf) return null;
// Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы
// месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»).
const m = /^(\d{4})-(\d{2})/.exec(asOf);
if (!m) return null;
const year = Number(m[1]);
const monthIdx = Number(m[2]) - 1;
if (monthIdx < 0 || monthIdx > 11) return null;
return `${RU_MONTHS[monthIdx]} ${year}`;
const month = Number(m[2]);
if (month < 1 || month > 12) return null;
const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)];
return `${quarter} квартал ${year}`;
}
/**
@ -196,13 +184,15 @@ function OfferOnlyCard({
const sourceLine =
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
// Caveat — почему показываем предложение вместо сделок.
const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined);
// Caveat — почему показываем предложение вместо сделок. Сделки Росреестра
// приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ
// «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия.
const dealsQuarter = formatDealsQuarter(dealsAsOfDate);
const caveat = dealsStale
? dealsMonth != null
? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.`
: "Сделки Росреестра устарели — показана динамика цен предложения."
: "Сделок Росреестра рядом нет — показана динамика цен предложения.";
? dealsQuarter != null
? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).`
: "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)."
: "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив).";
return (
<div
@ -303,8 +293,8 @@ export function MarketTrendBlock({ trend }: Props) {
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
const title = `Тренд рынка${radiusSuffix}`;
// #2178: сделки устарели/отсутствуют, но живёт динамика предложения —
// полноценная карточка тренда по предложению (Объектив), НЕ caveat-тупик.
// #2178: сделок в recent-окне нет (поквартальная поставка / нет рядом), но живёт
// динамика предложения — полноценная карточка тренда по предложению (Объектив).
if (status === "offer_only") {
const offer = trend.offer_trend;
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
@ -321,19 +311,21 @@ export function MarketTrendBlock({ trend }: Props) {
return (
<TrendCaveat
title={title}
message="Источник сделок устарел — тренд не показываем."
message="Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."
/>
);
}
// P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда.
// #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало
// квартала поставки), НЕ «устарели». Показываем честную квартальную подпись
// вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете.
if (status === "source_stale") {
const asOfMonth = formatAsOfMonth(trend.as_of_date);
const quarter = formatDealsQuarter(trend.as_of_date);
const message =
trend.label ??
(asOfMonth != null
? `Источник сделок устарел (данные до ${asOfMonth}).`
: "Источник сделок устарел — тренд не показываем.");
(quarter != null
? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.`
: "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем.");
return <TrendCaveat title={title} message={message} />;
}

View file

@ -0,0 +1,45 @@
/**
* Unit tests for §3 «Сети» number formatters (connection-capacity.ts).
*
* Focus: honest near-zero rendering of ГРС free capacity. При 1 знаке после
* запятой «0,044 тыс. м³/ч» округлялось бы в «0,0» ГРС с крохотной, но
* НЕнулевой свободной мощностью выглядела бы закрытой. Для 0 < |v| < 0,05
* показываем 2 знака; настоящий 0 остаётся «0,0».
*/
import { describe, expect, it } from "vitest";
import { formatThousandM3Hour } from "../connection-capacity";
// toLocaleString("ru-RU") даёт запятую-десятичную и Unicode-минус () для
// отрицательных — сверяем именно на этих формах.
describe("formatThousandM3Hour — честность околонуля", () => {
it("крохотная положительная мощность не схлопывается в 0,0", () => {
expect(formatThousandM3Hour(0.044)).toBe("0,04 тыс. м³/ч");
expect(formatThousandM3Hour("0.044")).toBe("0,04 тыс. м³/ч");
});
it("крохотная отрицательная мощность (дефицит) сохраняет знак и не нулится", () => {
// toLocaleString даёт минус (ASCII «-» или Unicode «−» в зависимости от ICU
// рантайма) + 2 знака — не «0,0». Сверяем знак + значимую часть, не глиф.
const out = formatThousandM3Hour(-0.044);
expect(out).not.toBeNull();
expect(out).toMatch(/^[-]0,04 тыс\. м³\/ч$/u);
});
it("настоящий ноль остаётся 0,0 (1 знак)", () => {
expect(formatThousandM3Hour(0)).toBe("0,0 тыс. м³/ч");
expect(formatThousandM3Hour("0")).toBe("0,0 тыс. м³/ч");
});
it("значения ≥ 0,05 — прежний 1 знак после запятой", () => {
expect(formatThousandM3Hour(1.205)).toBe("1,2 тыс. м³/ч");
expect(formatThousandM3Hour(0.05)).toBe("0,1 тыс. м³/ч");
expect(formatThousandM3Hour(42.2)).toBe("42,2 тыс. м³/ч");
});
it("null/undefined/нечисло → null (поле скрывается, не «0»)", () => {
expect(formatThousandM3Hour(null)).toBeNull();
expect(formatThousandM3Hour(undefined)).toBeNull();
expect(formatThousandM3Hour("н/д")).toBeNull();
});
});

View file

@ -132,14 +132,19 @@ export function formatMeters(
}
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
//
// Честность околонуля: при 1 знаке «0,044» округлился бы в «0,0» и ГРС с
// НЕнулевой (но крохотной) свободной мощностью выглядела бы закрытой. Для
// 0 < |v| < 0,05 показываем 2 знака («0,04» / «0,04»); настоящий 0 — «0,0».
export function formatThousandM3Hour(
v: number | string | null | undefined,
): string | null {
const n = toFiniteNumber(v);
if (n === null) return null;
const digits = n !== 0 && Math.abs(n) < 0.05 ? 2 : 1;
return `${n.toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})} тыс. м³/ч`;
}