diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 43c67e85..a189c916 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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-3→I, 4-6→II, 7-9→III, 10-12→IV (римские). + """ + 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: diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py index 1915fbc5..034692b3 100644 --- a/backend/app/services/site_finder/gazprom_grs_loader.py +++ b/backend/app/services/site_finder/gazprom_grs_loader.py @@ -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() :] diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index 622343cc..54687e72 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -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 ───────────────────────────────────────────────────── diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx index ad84dfda..81f85d24 100644 --- a/frontend/src/components/site-finder/MarketTrendBlock.tsx +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -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 (
); } - // 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