From edbdd28286bfe51895340781d5c1e4c871b84455 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 09:06:51 +0300 Subject: [PATCH 1/4] =?UTF-8?q?fix(tradein/security):=20photo-upload=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B0=D0=BD=D0=BA=D0=BE=D0=B2=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=BC=2010MB=20+=20Caddy=20request=5Fbody=2012MB?= =?UTF-8?q?=20(#2233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/trade_in.py | 14 ++++- .../backend/tests/test_estimate_idor.py | 63 +++++++++++++++++++ tradein-mvp/deploy/Caddyfile.tradein-fragment | 7 +++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 2aae5075..d215df3c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -518,11 +518,19 @@ async def upload_photo( status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})" ) - content = await file.read() + # #2233: читаем тело чанками с жёстким капом, а не await file.read() целиком — + # иначе multi-GB аплоад буферизуется в RAM и OOM-killed backend (mem_limit 768m, #2214). + # 413 бросается СРАЗУ при превышении, остаток тела запроса НЕ читается. + chunks: list[bytes] = [] + total = 0 + while chunk := await file.read(64 * 1024): + total += len(chunk) + if total > _MAX_PHOTO_BYTES: + raise HTTPException(status_code=413, detail="file too large (max 10 MB)") + chunks.append(chunk) + content = b"".join(chunks) if not content: raise HTTPException(status_code=400, detail="empty file") - if len(content) > _MAX_PHOTO_BYTES: - raise HTTPException(status_code=413, detail="file too large (max 10 MB)") # Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads, # cap dimensions. Closes finding #6 from 2026-05-24 audit. diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 1b790640..3583f0ac 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -491,6 +491,69 @@ def test_upload_photo_owner_can_upload(trade_in_app: FastAPI, monkeypatch) -> No assert resp.status_code == 200 +class _InfiniteUploadFile: + """UploadFile stub whose async read(size) yields endless 64KB chunks (#2233). + + Simulates a multi-GB stream. Counts read() calls so the test can assert the + handler stops reading right after the cap is exceeded instead of draining the + whole body into RAM. + """ + + content_type = "image/png" + filename = "huge.png" + + def __init__(self) -> None: + self.read_calls = 0 + + async def read(self, size: int = -1) -> bytes: + self.read_calls += 1 + return b"\x00" * (64 * 1024) + + +async def test_upload_photo_streams_over_cap_returns_413_without_full_read() -> None: + """#2233: a stream larger than 10 MB → 413 raised early, body NOT fully read. + + Acceptance: read() is called only ~(10MB/64KB)+1 times (161), proving the loop + bails on the first chunk that pushes total past _MAX_PHOTO_BYTES rather than + buffering an unbounded upload (which would OOM the 768m-capped container, #2214). + """ + from uuid import UUID + + from fastapi import HTTPException + + import app.core.auth as auth_mod + from app.api.v1.trade_in import _MAX_PHOTO_BYTES, upload_photo + + auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] + + # guard SELECT → owner; count SELECT → 0. INSERT must never be reached. + db = MagicMock() + guard_result = MagicMock() + guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov") + count_result = MagicMock() + count_result.scalar_one.return_value = 0 + db.execute.side_effect = [guard_result, count_result] + + upload = _InfiniteUploadFile() + + with pytest.raises(HTTPException) as exc_info: + await upload_photo( + estimate_id=UUID(_ESTIMATE_ID), + db=db, + file=upload, # type: ignore[arg-type] + x_authenticated_user="kopylov", + ) + + assert exc_info.value.status_code == 413 + expected_reads = _MAX_PHOTO_BYTES // (64 * 1024) + 1 # 161 + assert upload.read_calls == expected_reads + # sanity: bounded, nowhere near infinite + assert upload.read_calls < 200 + # INSERT (3rd execute) never fired — nothing persisted + assert db.execute.call_count == 2 + db.commit.assert_not_called() + + # ── Derived analytics routes (representative: /houses, /imv-benchmark) ──────── diff --git a/tradein-mvp/deploy/Caddyfile.tradein-fragment b/tradein-mvp/deploy/Caddyfile.tradein-fragment index 73809453..4b22592f 100644 --- a/tradein-mvp/deploy/Caddyfile.tradein-fragment +++ b/tradein-mvp/deploy/Caddyfile.tradein-fragment @@ -15,6 +15,13 @@ handle_path /trade-in/api/* { # handle_path вырезает префикс /trade-in перед прокси → # FastAPI получает /api/v1/... + # + # #2233: первый рубеж против OOM на фото-аплоаде — режем тело >12MB на + # уровне Caddy (backend капит на 10MB чанково, см. upload_photo). Scoped + # ТОЛЬКО на /trade-in/api/* — не трогает фронт/остальной сайт. + request_body { + max_size 12MB + } reverse_proxy tradein-backend:8000 } From c65a73ca9a48e15bc7d4f9aa94bb61cf622eeeff Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 09:16:34 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(tradein/security):=20admin.py=20?= =?UTF-8?q?=E2=80=94=20generic=20detail=20+=20error-id=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=81=D1=8B=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20(#2234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 52 +++++++++++++------ .../backend/tests/test_scraper_admin_apis.py | 30 ++++++++++- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 8b243d1b..c6197edb 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -11,6 +11,7 @@ import logging import time from typing import Annotated, Any, Literal from urllib.parse import urlparse, urlunparse +from uuid import uuid4 import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query @@ -68,6 +69,22 @@ def _assert_allowed_url(url: str) -> None: raise HTTPException(status_code=400, detail="host not allowed") +def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException: + """Defense-in-depth: не отдаём текст исключения в HTTP-ответ. + + Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние + хосты, пути. Логируем полный exception с коротким error-id через + ``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail + с тем же error-id для корреляции. + + ВАЖНО: вызывать только внутри ``except``-блока — ``logger.exception`` + полагается на активный exc_info. + """ + error_id = uuid4().hex[:8] + logger.exception("%s (error-id=%s)", log_context, error_id) + return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})") + + _ALL_SOURCES = ["avito", "cian", "yandex"] @@ -547,14 +564,16 @@ async def scrape_avito_house( try: enrichment = await fetch_house_catalog(house_url) except Exception as e: - logger.exception("avito-house: fetch failed for %s", house_url) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error( + 502, "fetch failed", f"avito-house: fetch failed for {house_url}" + ) from e try: counters = save_house_catalog_enrichment(db, enrichment) except Exception as e: - logger.exception("avito-house: save failed for %s", house_url) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error( + 500, "save failed", f"avito-house: save failed for {house_url}" + ) from e logger.info("avito-house ok: %s → %s", house_url, counters) return {"ok": True, "house_url": house_url, "counters": counters} @@ -576,14 +595,16 @@ async def scrape_avito_detail( try: enrichment = await fetch_detail(item_url) except Exception as e: - logger.exception("avito-detail: fetch failed for %s", item_url) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error( + 502, "fetch failed", f"avito-detail: fetch failed for {item_url}" + ) from e try: updated = save_detail_enrichment(db, enrichment) except Exception as e: - logger.exception("avito-detail: save failed for %s", item_url) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error( + 500, "save failed", f"avito-detail: save failed for {item_url}" + ) from e if not updated: # Listing с этим source_id ещё не существует в БД — был bypass через @@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill( try: result = await run_avito_detail_backfill(db, run_id=run_id, params=params) except Exception as e: - logger.exception("avito-detail-backfill: run_id=%d crashed", run_id) - raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e + raise _safe_http_error( + 502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed" + ) from e logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict()) return {"ok": True, "run_id": run_id, "counters": result.to_dict()} @@ -666,17 +688,17 @@ async def scrape_avito_imv( has_loggia=has_loggia, ) except IMVAddressNotFoundError as e: - raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e + raise _safe_http_error( + 404, "IMV address not found", f"avito-imv: address not found for {address}" + ) from e except Exception as e: - logger.exception("avito-imv: fetch failed for %s", address) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e try: eval_id = save_imv_evaluation(db, result) history_saved = save_imv_placement_history(db, eval_id, result.placement_history) except Exception as e: - logger.exception("avito-imv: save failed for %s", address) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e logger.info( "avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d", diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 497b1c46..4d1a4390 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from datetime import UTC, datetime from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI @@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None: assert ( 0.0 <= pct <= 100.0 ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + + +# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ──────────────── + + +def test_admin_error_detail_hides_exception_text(client: TestClient) -> None: + """Внутренняя зависимость кидает исключение с 'секретным' текстом → + + в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний. + Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением. + """ + secret = "postgres://user:SECRETPASS@internal-host:5432/db" + with patch( + "app.api.v1.admin.fetch_detail", + new=AsyncMock(side_effect=Exception(secret)), + ): + r = client.post( + "/api/v1/admin/scrape/avito-detail", + params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"}, + ) + + assert r.status_code == 502 + body = r.json() + detail = body["detail"] + assert "SECRETPASS" not in detail + assert secret not in detail + assert "error-id:" in detail + assert detail.startswith("fetch failed") From efa0bafa18d981dc712aa759416b3ccbf2b12d82 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 09:20:44 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(tradein/security):=20404=20IMV-=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=20=E2=80=94=20warning=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20traceback,=20=D0=BD=D0=B5=20exception-=D1=88=D1=83?= =?UTF-8?q?=D0=BC=20(#2234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index c6197edb..ff45e927 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -688,8 +688,13 @@ async def scrape_avito_imv( has_loggia=has_loggia, ) except IMVAddressNotFoundError as e: - raise _safe_http_error( - 404, "IMV address not found", f"avito-imv: address not found for {address}" + # Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning + # без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог, + # не в HTTP-ответ. + error_id = uuid4().hex[:8] + logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id) + raise HTTPException( + status_code=404, detail=f"IMV address not found (error-id: {error_id})" ) from e except Exception as e: raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e From 21608eced1b308b82e2fa7cede21ca6006ac7685 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 06:27:27 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(site-finder):=20=D1=82=D1=80=D0=B8=20?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8-=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B0=20=E2=80=94=20B1=20=D0=93=D0=A0=D0=A1?= =?UTF-8?q?=20=C2=AB=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=20=E2=84=96N=C2=BB=20+?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D1=83=D0=BB=D1=8C=20+?= =?UTF-8?q?=20=D0=A0=D0=BE=D1=81=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80-?= =?UTF-8?q?=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0=D0=BB=20(#2257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 37 ++++++++--- .../site_finder/gazprom_grs_loader.py | 11 +++- backend/tests/test_gas_grs_loader.py | 30 +++++++++ .../site-finder/MarketTrendBlock.tsx | 64 ++++++++----------- .../connection-capacity-format.test.ts | 45 +++++++++++++ .../site-finder/connection-capacity.ts | 9 ++- 6 files changed, 148 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts 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 ; } diff --git a/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts b/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts new file mode 100644 index 00000000..d6ff88ab --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts index 4b587b0d..d0410252 100644 --- a/frontend/src/components/site-finder/connection-capacity.ts +++ b/frontend/src/components/site-finder/connection-capacity.ts @@ -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, })} тыс. м³/ч`; }