diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 601b834b..43c67e85 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3995,6 +3995,7 @@ def get_parcel_connection_capacity( power_summary=data["power_summary"], water=data["water"], gas=data.get("gas"), + gas_outlet_points=data.get("gas_outlet_points") or [], heat=data.get("heat"), ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index d52677d9..c0492037 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -168,13 +168,38 @@ class GasGrsRow(BaseModel): class GasCapacityBlock(BaseModel): - """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.""" + """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1. + + + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО + «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4. + """ # Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если # таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD). city_grs: list[GasGrsRow] # Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет). total_free_th_m3_h: float | None + # Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level — + # см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица + # gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4. + # ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). + outlets_total: int = 0 # всего точек выхода в последнем срезе + outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0) + outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт + + +class GasOutletPoint(BaseModel): + """Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4.""" + + outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст) + consumer_type: str | None # котельная / ТЭЦ / площадка … + # Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть + # <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True). + free_capacity_mln_m3: float | None + needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт + distance_m: float # расстояние от центроида участка, м + lat: float | None + lon: float | None class HeatSystemRow(BaseModel): @@ -202,6 +227,9 @@ class ConnectionCapacityResponse(BaseModel): water: list[WaterCapacityRow] # Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран. gas: GasCapacityBlock | None = None + # Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2 + # PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе. + gas_outlet_points: list[GasOutletPoint] = [] # Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран. heat: HeatCapacityBlock | None = None diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 91f6f87a..5fd57e08 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -9,6 +9,12 @@ - water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level). - gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью (gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1. + + счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО + «Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен + гидрорасчёт». #2119 B2 PR-4. + - gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от + участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя + на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2. - heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves) за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2. @@ -26,6 +32,13 @@ logger = logging.getLogger(__name__) _DEFAULT_RADIUS_M = 3000 _MAX_RADIUS_M = 10000 +# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м — +# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным +# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию, +# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2. +_GAS_OUTLET_RADIUS_M = 2000 +_GAS_OUTLET_LIMIT = 40 + # Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная # мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1. _EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)" @@ -45,7 +58,7 @@ def get_connection_capacity( Returns: {"power_points": [...], "power_summary": {...}, "water": [...], - "gas": {...}|None, "heat": {...}|None} + "gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None} Raises: ValueError: участок не найден в БД (нет geom). @@ -63,6 +76,7 @@ def get_connection_capacity( power_summary = _build_power_summary(power_points) water = _query_water_latest(db) gas = _query_gas_city_grs(db) + gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt) heat = _query_heat_latest(db) return { @@ -70,6 +84,7 @@ def get_connection_capacity( "power_summary": power_summary, "water": water, "gas": gas, + "gas_outlet_points": gas_outlet_points, "heat": heat, } @@ -211,8 +226,17 @@ def _query_gas_city_grs(db: Session) -> dict: → city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не существовать до применения миграции 181). - Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}. + Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из + gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний + period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc. + См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС». + + Returns: {"city_grs": [...], "total_free_th_m3_h": float|None, + "outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}. + Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода + (в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации. """ + outlet_counts = _query_gas_outlet_counts(db) try: rows = ( db.execute( @@ -235,9 +259,10 @@ def _query_gas_city_grs(db: Session) -> dict: ) except Exception as e: # Таблица может не существовать до применения миграции 181 (schema-first). - # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. + # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики + # точек выхода (миграция 184, независимая) — оставляем, что удалось собрать. logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e) - return {"city_grs": [], "total_free_th_m3_h": None} + return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts} city_grs: list[dict] = [] total_free = 0.0 @@ -262,9 +287,135 @@ def _query_gas_city_grs(db: Session) -> dict: return { "city_grs": city_grs, "total_free_th_m3_h": round(total_free, 3) if has_free else None, + **outlet_counts, } +def _query_gas_outlet_counts(db: Session) -> dict: + """Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month. + + По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в + форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде + «грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой + источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне + (имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ + целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3 + «сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС. + + Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем — + в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза. + + Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}. + - outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС); + - outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт). + Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно). + """ + try: + row = ( + db.execute( + text(""" + SELECT COUNT(*) AS outlets_total, + COUNT(*) FILTER ( + WHERE free_capacity_mln_m3 < 0 + ) AS outlets_deficit, + COUNT(*) FILTER ( + WHERE free_capacity_needs_calc + ) AS outlets_needs_calc + FROM gas_grs_outlet_points + WHERE period_month = ( + SELECT MAX(period_month) FROM gas_grs_outlet_points + ) + """) + ) + .mappings() + .first() + ) + except Exception as e: + # Таблица может не существовать до применения миграции 184 (schema-first). + # Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт. + logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e) + return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} + + if row is None: + return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} + return { + "outlets_total": int(row["outlets_total"] or 0), + "outlets_deficit": int(row["outlets_deficit"] or 0), + "outlets_needs_calc": int(row["outlets_needs_calc"] or 0), + } + + +def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]: + """Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3. + + ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний + period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19% + строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность + free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть + (миграция 184) → [] (graceful, блок аддитивный). + + План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует + geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST + по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом. + + Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc, + distance_m, lat, lon}] — отсортировано по distance ASC. + """ + try: + rows = ( + db.execute( + text(""" + SELECT outlet_name, consumer_type, free_capacity_mln_m3, + free_capacity_needs_calc AS needs_calc, + ST_Distance( + geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m, + ST_Y(geom) AS lat, + ST_X(geom) AS lon + FROM gas_grs_outlet_points + WHERE geom IS NOT NULL + AND period_month = ( + SELECT MAX(period_month) FROM gas_grs_outlet_points + ) + AND ST_DWithin( + geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + CAST(:radius_m AS float) + ) + ORDER BY distance_m ASC + LIMIT CAST(:limit AS int) + """), + { + "wkt": parcel_wkt, + "radius_m": _GAS_OUTLET_RADIUS_M, + "limit": _GAS_OUTLET_LIMIT, + }, + ) + .mappings() + .all() + ) + except Exception as e: + # Таблица может не существовать до применения миграции 184 (schema-first). + # Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт. + logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e) + return [] + + return [ + { + "outlet_name": r["outlet_name"], + "consumer_type": r["consumer_type"], + # млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком. + "free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]), + "needs_calc": bool(r["needs_calc"]), + "distance_m": round(float(r["distance_m"]), 1), + "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None, + "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None, + } + for r in rows + ] + + def _query_heat_latest(db: Session) -> dict: """Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации. diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 649e8964..958d2783 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -310,6 +310,20 @@ def build_beat_schedule() -> dict: "schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник "options": {"queue": "celery"}, } + # Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points + + # догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ + # non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа + # месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter + # даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется, + # не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб- + # кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК + # (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят + # ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно). + schedule["gas-outlet-points-sync-weekly"] = { + "task": "tasks.connection_capacity_sync.sync_gas_outlet_points", + "schedule": _parse_cron("15 5 * * tue"), # 05:15 МСК, вторник + "options": {"queue": "celery"}, + } # ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из # xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник- # кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа diff --git a/backend/app/workers/tasks/connection_capacity_sync.py b/backend/app/workers/tasks/connection_capacity_sync.py index 92c1750b..336fed7e 100644 --- a/backend/app/workers/tasks/connection_capacity_sync.py +++ b/backend/app/workers/tasks/connection_capacity_sync.py @@ -145,6 +145,52 @@ def sync_eesk_reserves() -> dict: raise +@celery_app.task( + name="tasks.connection_capacity_sync.sync_gas_outlet_points", + queue="celery", +) +def sync_gas_outlet_points() -> dict: + """Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960) → gas_grs_outlet_points, + затем догеокод новых адресных строк → geom. #2119 Фаза B2, PR-4. + + RUN-ON-PROD (гео-блок источника ekgas.ru + DaData-ключи на проде). Два шага: + 1. load_gas_outlet_points() — свежайшая форма 6 из каталога → UPSERT (идемпотентно: + помесячный UPSERT по (grs_name_norm, outlet_name, period_month, is_forecast), + geom НЕ затирается в DO UPDATE); + 2. geocode_gas_outlets(apply=True) — новые адресные строки последнего period_month + с geom IS NULL догеокодятся через DaData (in-memory кэш + cap внутри лоадера; + уже геокоженные строки прошлых месяцев не трогаются). + + Источник обновляется ~5-го числа месяца, но расписание еженедельное (вторник) — + не-новый период просто пере-UPSERT-ится без эффекта (geom сохраняется). Геокод + graceful: сбой шага 2 не откатывает загруженные строки (шаг 1 уже закоммичен своей + сессией). Каждый шаг логируется breadcrumb-ом в nspd_geo_log. + """ + _log_breadcrumb("gas_outlet_sync", "info", "task started") + result: dict = {} + try: + from app.services.site_finder.ekgas_outlet_loader import load_gas_outlet_points + + result["load"] = load_gas_outlet_points() + except Exception as e: + logger.exception("sync_gas_outlet_points: load failed: %s", e) + _log_breadcrumb("gas_outlet_sync", "error", f"load {type(e).__name__}: {e}") + result["load"] = {"error": str(e)} + + try: + from app.services.site_finder.ekgas_outlet_loader import geocode_gas_outlets + + result["geocode"] = geocode_gas_outlets(apply=True) + except Exception as e: + logger.exception("sync_gas_outlet_points: geocode failed: %s", e) + _log_breadcrumb("gas_outlet_sync", "error", f"geocode {type(e).__name__}: {e}") + result["geocode"] = {"error": str(e)} + + logger.info("sync_gas_outlet_points done: %s", result) + _log_breadcrumb("gas_outlet_sync", "info", f"done: {result}") + return result + + @celery_app.task( name="tasks.connection_capacity_sync.sync_heat_reserves", queue="celery", diff --git a/backend/tests/api/v1/test_parcel_connection_capacity.py b/backend/tests/api/v1/test_parcel_connection_capacity.py index 322907d0..766c2c50 100644 --- a/backend/tests/api/v1/test_parcel_connection_capacity.py +++ b/backend/tests/api/v1/test_parcel_connection_capacity.py @@ -59,10 +59,52 @@ def _heat_block( return {"systems": systems, "total_reserve_gcal_h": total} +def _gas_block( + outlets_total: int = 1037, + outlets_deficit: int = 218, + outlets_needs_calc: int = 144, +) -> dict[str, Any]: + return { + "city_grs": [ + { + "grs_name": "ГРС Свердловская", + "design_capacity_th_m3_h": 210.0, + "free_capacity_th_m3_h": 40.5, + "free_capacity_pct": 20.2, + "upgrade_due": "2028", + "outputs_count": 3, + } + ], + "total_free_th_m3_h": 40.5, + "outlets_total": outlets_total, + "outlets_deficit": outlets_deficit, + "outlets_needs_calc": outlets_needs_calc, + } + + +def _gas_outlet_point( + outlet_name: str = "Котельная (Складской проезд,4а)", + free: float | None = 0.512, + needs_calc: bool = False, + distance_m: float = 145.3, +) -> dict[str, Any]: + return { + "outlet_name": outlet_name, + "consumer_type": "котельная", + "free_capacity_mln_m3": free, + "needs_calc": needs_calc, + "distance_m": distance_m, + "lat": 56.812345, + "lon": 60.611111, + } + + def _make_response( power_points: list[dict[str, Any]] | None = None, water: list[dict[str, Any]] | None = None, heat: dict[str, Any] | None = None, + gas: dict[str, Any] | None = None, + gas_outlet_points: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: if power_points is None: power_points = [_power_point()] @@ -89,6 +131,10 @@ def _make_response( "nearest_with_reserve": nearest, }, "water": water, + "gas": gas if gas is not None else _gas_block(), + "gas_outlet_points": ( + gas_outlet_points if gas_outlet_points is not None else [_gas_outlet_point()] + ), "heat": heat if heat is not None else _heat_block(), } @@ -281,3 +327,67 @@ def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None: assert r.status_code == 200, r.text assert r.json()["power_points"][0]["reserve_note"] is None + + +# ── Phase B2 PR-4: газовые точки выхода (счётчики в gas + радиус-слой) ───────── + + +def test_connection_capacity_gas_outlet_counters() -> None: + """gas-блок несёт счётчики точек выхода последнего среза (city-level). #2119 B2 PR-4.""" + with patch( + "app.api.v1.parcels.get_connection_capacity", + return_value=_make_response(), + ): + client = TestClient(app) + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") + + assert r.status_code == 200, r.text + gas = r.json()["gas"] + assert gas["outlets_total"] == 1037 + assert gas["outlets_deficit"] == 218 + assert gas["outlets_needs_calc"] == 144 + # ГРС-часть B1 не сломана. + assert gas["total_free_th_m3_h"] == pytest.approx(40.5) + + +def test_connection_capacity_gas_outlet_points_layer() -> None: + """gas_outlet_points — гео-слой точек выхода: дистанция/needs_calc/дефицит. #2119 B2 PR-4.""" + pts = [ + _gas_outlet_point(distance_m=145.3, free=0.512, needs_calc=False), + # дефицит со знаком. + _gas_outlet_point( + outlet_name="Промплощадка №2", distance_m=980.4, free=-0.084, needs_calc=False + ), + # «0.000*» → NULL мощность + needs_calc=True. + _gas_outlet_point(outlet_name="ТЭЦ узел", distance_m=1500.0, free=None, needs_calc=True), + ] + with patch( + "app.api.v1.parcels.get_connection_capacity", + return_value=_make_response(gas_outlet_points=pts), + ): + client = TestClient(app) + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") + + assert r.status_code == 200, r.text + outlets = r.json()["gas_outlet_points"] + assert len(outlets) == 3 + assert outlets[0]["distance_m"] == pytest.approx(145.3) + assert outlets[0]["free_capacity_mln_m3"] == pytest.approx(0.512) + assert outlets[1]["free_capacity_mln_m3"] == pytest.approx(-0.084) # дефицит-знак + assert outlets[2]["free_capacity_mln_m3"] is None + assert outlets[2]["needs_calc"] is True + + +def test_connection_capacity_gas_outlet_points_empty_default() -> None: + """gas_outlet_points пуст (нет geom в радиусе) → []; ключ может отсутствовать → [].""" + resp = _make_response(gas_outlet_points=[]) + del resp["gas_outlet_points"] # сервис может не собрать ключ (миграция 184 не применена) + with patch( + "app.api.v1.parcels.get_connection_capacity", + return_value=resp, + ): + client = TestClient(app) + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") + + assert r.status_code == 200, r.text + assert r.json()["gas_outlet_points"] == [] diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index 934288ec..622343cc 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -239,20 +239,32 @@ class _FakeResult: def all(self) -> list[dict]: return self._mappings_rows + def first(self) -> dict | None: + return self._mappings_rows[0] if self._mappings_rows else None + class _FakeSession: - """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах).""" + """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах). + + Один набор строк на все execute (mappings_rows) ИЛИ скрипт по вызовам + (results_script: список наборов строк, отдаётся по порядку execute-вызовов; после + исчерпания — последний). results_script нужен для функций с НЕСКОЛЬКИМИ разными + SELECT (напр. _query_gas_city_grs: счётчики точек выхода + агрегация ГРС). + """ def __init__( self, scalar_value: Any = True, mappings_rows: list[dict] | None = None, execute_raises: Exception | None = None, + results_script: list[list[dict]] | None = None, ) -> None: self.calls: list[tuple[str, Any]] = [] self._scalar = scalar_value self._mappings_rows = mappings_rows self._execute_raises = execute_raises + self._results_script = results_script + self._call_idx = 0 self.committed = False @contextmanager @@ -263,6 +275,11 @@ class _FakeSession: self.calls.append((str(stmt), params)) if self._execute_raises is not None: raise self._execute_raises + if self._results_script is not None: + idx = min(self._call_idx, len(self._results_script) - 1) + rows = self._results_script[idx] + self._call_idx += 1 + return _FakeResult(self._scalar, rows) return _FakeResult(self._scalar, self._mappings_rows) def commit(self) -> None: @@ -291,6 +308,11 @@ def test_upsert_grs_rows_counts_and_savepoint() -> None: # ── _query_gas_city_grs: агрегация ──────────────────────────────────────────── +# Счётчики точек выхода (первый execute в _query_gas_city_grs) — нейтральный дефолт для +# тестов B1-агрегации, которым важна только ГРС-часть (второй execute). +_OUTLET_COUNTS_ROW = {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} + + def test_query_gas_city_grs_aggregation() -> None: """Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total.""" rows = [ @@ -311,7 +333,8 @@ def test_query_gas_city_grs_aggregation() -> None: "outputs_count": 1, }, ] - db = _FakeSession(mappings_rows=rows) + # execute #1 = счётчики точек выхода, #2 = агрегация ГРС. + db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], rows]) out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] assert len(out["city_grs"]) == 2 assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская" @@ -319,19 +342,144 @@ def test_query_gas_city_grs_aggregation() -> None: assert out["city_grs"][0]["free_capacity_pct"] == 20.2 # round(20.25, 1) assert out["city_grs"][0]["outputs_count"] == 3 assert out["total_free_th_m3_h"] == 50.5 # 40.5 + 10.0 + # Счётчики точек выхода вплетены в тот же gas-блок (аддитивно). + assert out["outlets_total"] == 0 + assert out["outlets_deficit"] == 0 + assert out["outlets_needs_calc"] == 0 def test_query_gas_city_grs_empty_table() -> None: - db = _FakeSession(mappings_rows=[]) + db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], []]) out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] - assert out == {"city_grs": [], "total_free_th_m3_h": None} + assert out == { + "city_grs": [], + "total_free_th_m3_h": None, + "outlets_total": 0, + "outlets_deficit": 0, + "outlets_needs_calc": 0, + } def test_query_gas_city_grs_missing_table_degrades() -> None: - """Таблица не существует (миграция 181 не применена) → пустой блок, не падаем.""" + """Таблица gas_grs_capacity не существует (миграция 181) → пустой блок, не падаем. + + _query_gas_outlet_counts (первый execute) тоже упадёт на той же raises-заглушке → + счётчики деградируют в нули, блок цел. + """ db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist')) out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] - assert out == {"city_grs": [], "total_free_th_m3_h": None} + assert out == { + "city_grs": [], + "total_free_th_m3_h": None, + "outlets_total": 0, + "outlets_deficit": 0, + "outlets_needs_calc": 0, + } + + +# ── _query_gas_outlet_counts: счётчики точек выхода (city-level) ─────────────── + + +def test_query_gas_outlet_counts_flags() -> None: + """COUNT + FILTER (дефицит / needs_calc) маппятся в счётчики (по одной row-агрегату).""" + row = {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144} + db = _FakeSession(mappings_rows=[row]) + out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] + assert out == {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144} + + +def test_query_gas_outlet_counts_null_row_zeroes() -> None: + """Пустая таблица (агрегат вернул NULL/нет строки) → все нули.""" + db = _FakeSession(mappings_rows=[]) # .first() → None + out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] + assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} + + +def test_query_gas_outlet_counts_missing_table_degrades() -> None: + """Таблица не существует (миграция 184 не применена) → нули, не падаем.""" + db = _FakeSession( + execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist') + ) + out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] + assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} + + +# ── _query_gas_outlet_points: гео-точки выхода в радиусе (слой карты §3) ─────── + + +def test_query_gas_outlet_points_maps_and_flags() -> None: + """Строки радиус-запроса → точки: дистанция округлена, needs_calc/дефицит, lat/lon.""" + rows = [ + { + "outlet_name": "Котельная (г.Екатеринбург, Складской проезд,4а)", + "consumer_type": "котельная", + "free_capacity_mln_m3": 0.512, + "needs_calc": False, + "distance_m": 145.27, + "lat": 56.812345, + "lon": 60.611111, + }, + { + # Дефицит со знаком сохраняется; needs_calc отдельным флагом. + "outlet_name": "Промплощадка №2", + "consumer_type": None, + "free_capacity_mln_m3": -0.084, + "needs_calc": False, + "distance_m": 980.4, + "lat": 56.80, + "lon": 60.60, + }, + { + # «0.000*» → free_capacity_mln_m3=NULL, needs_calc=True. + "outlet_name": "ТЭЦ узел", + "consumer_type": "ТЭЦ", + "free_capacity_mln_m3": None, + "needs_calc": True, + "distance_m": 1500.0, + "lat": None, + "lon": None, + }, + ] + db = _FakeSession(mappings_rows=rows) + out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] + assert len(out) == 3 + # Порядок сохранён как пришёл из SQL (ORDER BY distance ASC) — ближайший первым. + assert out[0]["distance_m"] == 145.3 # round(145.27, 1) + assert out[0]["free_capacity_mln_m3"] == 0.512 + assert out[0]["needs_calc"] is False + assert out[0]["lat"] == 56.812345 + # дефицит сохраняет знак. + assert out[1]["free_capacity_mln_m3"] == -0.084 + # «0.000*»: NULL мощность + needs_calc=True; координаты None → пробрасываются null. + assert out[2]["free_capacity_mln_m3"] is None + assert out[2]["needs_calc"] is True + assert out[2]["lat"] is None and out[2]["lon"] is None + + +def test_query_gas_outlet_points_passes_radius_and_limit() -> None: + """Радиус-запрос параметризован _GAS_OUTLET_RADIUS_M и _GAS_OUTLET_LIMIT.""" + db = _FakeSession(mappings_rows=[]) + ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] + assert db.calls, "ожидался хотя бы один execute" + params = db.calls[0][1] + assert params["radius_m"] == ccl._GAS_OUTLET_RADIUS_M + assert params["limit"] == ccl._GAS_OUTLET_LIMIT + assert params["wkt"] == "POINT(60.6 56.81)" + + +def test_query_gas_outlet_points_empty() -> None: + db = _FakeSession(mappings_rows=[]) + out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] + assert out == [] + + +def test_query_gas_outlet_points_missing_table_degrades() -> None: + """Таблица не существует (миграция 184 не применена) → [], не падаем.""" + db = _FakeSession( + execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist') + ) + out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] + assert out == [] # ── _query_heat_latest: per-org период + суммарный резерв + graceful ─────────── diff --git a/backend/tests/workers/tasks/test_gas_outlet_sync.py b/backend/tests/workers/tasks/test_gas_outlet_sync.py new file mode 100644 index 00000000..4cd5c056 --- /dev/null +++ b/backend/tests/workers/tasks/test_gas_outlet_sync.py @@ -0,0 +1,106 @@ +"""Smoke-тесты Celery-обвязки точек выхода газосети (#2119 Фаза B2, PR-4). + +- sync_gas_outlet_points зарегистрирована (module в include-списке celery_app); +- beat содержит gas-outlet-points-sync-weekly (еженедельно, вторник); +- таск дважды дёргает лоадер: load_gas_outlet_points → geocode_gas_outlets(apply=True), + каждый шаг graceful (сбой одного не роняет второй) — mock без сети/БД. +""" + +from __future__ import annotations + +from unittest.mock import patch + + +def test_task_registered_in_celery_include() -> None: + """Модуль connection_capacity_sync в include-списке celery_app.""" + from app.workers.celery_app import celery_app + + assert "app.workers.tasks.connection_capacity_sync" in celery_app.conf.include + + +def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None: + """gas-outlet-points-sync-weekly зарегистрирована еженедельно во вторник.""" + from app.workers import beat_schedule + + # build_beat_schedule читает DB → мокаем _build_beat_schedule_from_db, + # чтобы тест не зависел от живого Postgres. + with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}): + schedule = beat_schedule.build_beat_schedule() + + assert ( + "gas-outlet-points-sync-weekly" in schedule + ), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}" + entry = schedule["gas-outlet-points-sync-weekly"] + assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points" + # crontab day_of_week = вторник (2). + assert 2 in entry["schedule"].day_of_week + + +def test_sync_gas_outlet_points_load_then_geocode() -> None: + """Таск: load_gas_outlet_points → geocode_gas_outlets(apply=True), оба результата в ответе.""" + from app.workers.tasks import connection_capacity_sync as ccs + + load_result = {"rows": 1037, "inserted": 10, "updated": 1027} + geo_result = {"candidates": 5, "geocoded": 3, "applied": True} + with ( + patch( + "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points", + return_value=load_result, + ), + patch( + "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets", + return_value=geo_result, + ) as mock_geo, + patch.object(ccs, "_log_breadcrumb"), + ): + out = ccs.sync_gas_outlet_points() + + assert out["load"] == load_result + assert out["geocode"] == geo_result + # геокод вызван С apply=True (реальная запись geom на проде). + assert mock_geo.call_args.kwargs.get("apply") is True + + +def test_sync_gas_outlet_points_geocode_failure_graceful() -> None: + """Сбой geocode-шага не роняет таск: load-результат сохраняется, geocode → error-словарь.""" + from app.workers.tasks import connection_capacity_sync as ccs + + load_result = {"rows": 1037, "inserted": 0, "updated": 1037} + with ( + patch( + "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points", + return_value=load_result, + ), + patch( + "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets", + side_effect=RuntimeError("DaData quota exhausted"), + ), + patch.object(ccs, "_log_breadcrumb"), + ): + out = ccs.sync_gas_outlet_points() + + assert out["load"] == load_result # шаг 1 не откатывается сбоем шага 2 + assert "error" in out["geocode"] + assert "DaData" in out["geocode"]["error"] + + +def test_sync_gas_outlet_points_load_failure_still_attempts_geocode() -> None: + """Сбой load-шага не мешает попытке geocode (шаги независимы, каждый graceful).""" + from app.workers.tasks import connection_capacity_sync as ccs + + geo_result = {"candidates": 0, "geocoded": 0, "applied": True} + with ( + patch( + "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points", + side_effect=RuntimeError("ekgas catalog 503"), + ), + patch( + "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets", + return_value=geo_result, + ), + patch.object(ccs, "_log_breadcrumb"), + ): + out = ccs.sync_gas_outlet_points() + + assert "error" in out["load"] + assert out["geocode"] == geo_result diff --git a/frontend/src/components/site-finder/GasOutletPointsLayer.tsx b/frontend/src/components/site-finder/GasOutletPointsLayer.tsx new file mode 100644 index 00000000..168c6621 --- /dev/null +++ b/frontend/src/components/site-finder/GasOutletPointsLayer.tsx @@ -0,0 +1,163 @@ +"use client"; + +/** + * GasOutletPointsLayer — точки выхода газосети (форма 6 АО «Екатеринбурггаз», + * ФАС №960) в радиусе от участка для §3 «Сети» (#2119 B2 PR-4). Отдельный слой + * поверх карты: CircleMarker per gas_outlet_point, цвет по ЗНАКУ свободной + * мощности (профицит → --success, дефицит → --danger, needs_calc → --warn). + * + * Меньший радиус (7) чем у центров питания Россетей (9) — точки выхода это + * низовой слой газосети, визуально второстепенный относительно ЦП. Свободная + * мощность здесь в млн м³/МЕС (НЕ тыс. м³/ч как у ГРС) — единица зашита в + * formatMlnM3Month, чтобы не перепутать. + * + * Только ~19% строк источника геокодированы → список часто пуст (это норма, + * слой не рисуется). Цвета/подписи/приведение чисел — из общего модуля + * connection-capacity.ts. Inline token-hex в popup — документированное + * исключение raw-Leaflet попапов (ui-tokens.md). + */ + +import { CircleMarker, LayerGroup, Popup } from "react-leaflet"; + +import type { GasOutletPoint } from "@/hooks/useConnectionCapacity"; +import { + classifyGasOutlet, + formatMeters, + formatMlnM3Month, + gasOutletColor, + toFiniteNumber, +} from "@/components/site-finder/connection-capacity"; + +// ── Point coordinate guard ───────────────────────────────────────────────────── +// lat/lon могут прийти строкой (Decimal→str) — приводим через toFiniteNumber, а +// не typeof-гвардом (иначе валидная точка молча не рисуется). +function pointLatLon(p: GasOutletPoint): [number, number] | null { + const lat = toFiniteNumber(p.lat); + const lon = toFiniteNumber(p.lon); + if (lat === null || lon === null) return null; + return [lat, lon]; +} + +// ── Popup ────────────────────────────────────────────────────────────────────── + +function GasOutletPopup({ point }: { point: GasOutletPoint }) { + const state = classifyGasOutlet(point); + const color = gasOutletColor(point); + const consumer = point.consumer_type?.trim() || null; + const free = formatMlnM3Month(point.free_capacity_mln_m3); + const isDeficit = state === "deficit"; + const distance = formatMeters(point.distance_m); + + return ( + +
+
+ + Точка выхода газосети +
+
+ {point.outlet_name} +
+ {consumer && ( +
+ Тип потребителя: {consumer} +
+ )} + {state === "calc" ? ( +
+ Свободная мощность: требует гидравлического расчёта +
+ ) : ( + free && ( +
+ {isDeficit ? "Дефицит" : "Свободная мощность"}:{" "} + + {free} + +
+ ) + )} + {distance && ( +
+ До участка: {distance} +
+ )} +
+ Форма 6 АО «Екатеринбурггаз» · млн м³/мес +
+
+
+ ); +} + +// ── Layer ────────────────────────────────────────────────────────────────────── + +interface Props { + points: GasOutletPoint[]; +} + +export function GasOutletPointsLayer({ points }: Props) { + return ( + + {points.map((point, idx) => { + const latLon = pointLatLon(point); + if (!latLon) return null; + const color = gasOutletColor(point); + return ( + + + + ); + })} + + ); +} + +// Число рисуемых точек (с валидными координатами) — для легенды/чекбокса. +export function countMappableGasOutlets( + points: GasOutletPoint[] | undefined, +): number { + if (!points) return 0; + return points.filter((p) => pointLatLon(p) !== null).length; +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 489c8e85..e2943592 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -57,10 +57,19 @@ import { countMappablePowerPoints, } from "@/components/site-finder/PowerConnectionPointsLayer"; import { + GasOutletPointsLayer, + countMappableGasOutlets, +} from "@/components/site-finder/GasOutletPointsLayer"; +import { + GAS_OUTLET_MARKER_COLOR, LOAD_INDEX_MARKER_COLOR, LOAD_INDEX_NULL_COLOR, + pluralizeOutletPoints, } from "@/components/site-finder/connection-capacity"; -import type { PowerConnectionPoint } from "@/hooks/useConnectionCapacity"; +import type { + GasOutletPoint, + PowerConnectionPoint, +} from "@/hooks/useConnectionCapacity"; import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer"; import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton"; import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal"; @@ -155,6 +164,9 @@ interface Props { // Центры питания Россетей со свободной мощностью для ТП (#connection-capacity). // Отдельный слой (не OSM): цвет по индексу загрузки. powerConnectionPoints?: PowerConnectionPoint[]; + // Точки выхода газосети (форма 6 Екатеринбурггаза) в радиусе от участка + // (#2119 B2 PR-4). Отдельный слой: цвет по знаку свободной мощности. + gasOutletPoints?: GasOutletPoint[]; customPois?: CustomPoi[]; parcelCad?: string; // #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них). @@ -201,6 +213,7 @@ export function SiteMap({ connectionPoints, utilityInfrastructure, powerConnectionPoints, + gasOutletPoints, customPois, parcelCad, competitors, @@ -294,6 +307,11 @@ export function SiteMap({ ); } + // Точки выхода газосети (#2119 B2 PR-4) — единичный слой, тумблер-чекбокс + // под картой. По умолчанию виден (как ЦП Россетей): точек мало (LIMIT 40, + // только геокодированные), карту не зашумляет. + const [showGasOutlets, setShowGasOutlets] = useState(true); + // Custom POI add mode state const [addMode, setAddMode] = useState(false); const [pendingCoords, setPendingCoords] = useState<{ @@ -416,6 +434,12 @@ export function SiteMap({ const powerPointMappable = countMappablePowerPoints(powerPointList); const hasPowerPoints = powerPointMappable > 0; + // Точки выхода газосети (#2119 B2 PR-4). Только ~19% строк источника + // геокодированы → список часто пуст (норма). Считаем рисуемые точки. + const gasOutletList = gasOutletPoints ?? []; + const gasOutletMappable = countMappableGasOutlets(gasOutletList); + const hasGasOutlets = gasOutletMappable > 0; + return (
{/* Map */} @@ -644,6 +668,13 @@ export function SiteMap({ )} + {/* Точки выхода газосети (форма 6 Екатеринбурггаза, #2119 B2 PR-4). + Отдельный слой поверх ЦП: цвет по знаку свободной мощности, + меньший радиус (низовой слой газосети). Тумблер-чекбокс под картой. */} + {hasGasOutlets && showGasOutlets && ( + + )} + {/* Custom POI markers — topmost layer */} {customPois && customPois.length > 0 && ( )} + {/* Точки выхода газосети (#2119 B2 PR-4). Чекбокс-тумблер + легенда цветов + по знаку свободной мощности. Показываем только при непустом списке + рисуемых точек (LIMIT 40, ~19% геокодировано). */} + {hasGasOutlets && ( +
+ + {( + [ + ["profit", "профицит"], + ["deficit", "дефицит"], + ["calc", "нужен гидрорасчёт"], + ] as const + ).map(([state, label]) => ( + + + {label} + + ))} + + Форма 6 АО «Екатеринбурггаз» · млн м³/мес + +
+ )} + {!data.geom_geojson && (

+ Точки выхода газосети (город): {total.toLocaleString("ru-RU")} · дефицит{" "} + {deficit.toLocaleString("ru-RU")} · требуют гидрорасчёта{" "} + {needsCalc.toLocaleString("ru-RU")} + + ); +} + function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) { - if (!gas || gas.city_grs.length === 0) { + // Нет ни ГРС-строк, ни счётчиков точек выхода → данные ещё грузятся. + if (!gas || (gas.city_grs.length === 0 && (gas.outlets_total ?? 0) === 0)) { return (

Данные о загрузке ГРС загружаются. @@ -354,6 +377,8 @@ function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) { )} + + Газпром трансгаз Екатеринбург · техвозможность ГРС diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx index d442d3d7..d4628897 100644 --- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx +++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx @@ -581,7 +581,12 @@ function ConnectionPointsMap({ !!utilityInfrastructure && utilityInfrastructure.features.length > 0; const powerPoints = connectionCapacity?.power_points ?? []; const hasPowerPoints = powerPoints.length > 0; - const hasAnyLayer = hasPoints || hasUtility || hasPowerPoints; + // Точки выхода газосети (#2119 B2 PR-4) — геокодированы только ~19% строк, + // список часто пуст (это норма). + const gasOutletPoints = connectionCapacity?.gas_outlet_points ?? []; + const hasGasOutlets = gasOutletPoints.length > 0; + const hasAnyLayer = + hasPoints || hasUtility || hasPowerPoints || hasGasOutlets; return (
@@ -648,6 +653,7 @@ function ConnectionPointsMap({ connectionPoints={hasPoints ? connectionPoints : undefined} utilityInfrastructure={hasUtility ? utilityInfrastructure : undefined} powerConnectionPoints={hasPowerPoints ? powerPoints : undefined} + gasOutletPoints={hasGasOutlets ? gasOutletPoints : undefined} mapHeight={CP_MAP_HEIGHT} /> ) : ( diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts index 068ddfc8..4b587b0d 100644 --- a/frontend/src/components/site-finder/connection-capacity.ts +++ b/frontend/src/components/site-finder/connection-capacity.ts @@ -7,6 +7,7 @@ */ import type { + GasOutletPoint, PowerConnectionPoint, WaterCapacityRow, } from "@/hooks/useConnectionCapacity"; @@ -142,6 +143,21 @@ export function formatThousandM3Hour( })} тыс. м³/ч`; } +// млн. м³/мес (свободная мощность точки выхода газосети, форма 6 Екатеринбурггаза — +// ВНИМАНИЕ: единица отличается от ГРС, где тыс. м³/ч). Может быть ОТРИЦАТЕЛЬНОЙ +// (дефицит со знаком) — знак сохраняем. Три знака после запятой: источник даёт +// «0.000». Приводит строку→число (Decimal→str). +export function formatMlnM3Month( + v: number | string | null | undefined, +): string | null { + const n = toFiniteNumber(v); + if (n === null) return null; + return `${n.toLocaleString("ru-RU", { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + })} млн м³/мес`; +} + // Гкал/ч (резерв тепловой мощности системы теплоснабжения). Может быть // ОТРИЦАТЕЛЬНЫМ (система исчерпана) — знак сохраняем. Приводит строку→число. export function formatGcalHour( @@ -197,3 +213,42 @@ export function powerPointReserveIsNegative(p: PowerConnectionPoint): boolean { const n = toFiniteNumber(p.reserve_mva); return n !== null && n <= 0; } + +// ── Точки выхода газосети (форма 6 Екатеринбурггаза) ─────────────────────────── +// +// Свободная мощность точки выхода классифицируется по ЗНАКУ: +// profit — free_capacity_mln_m3 > 0 (профицит), зелёный; +// deficit — < 0 (дефицит со знаком), красный; +// calc — needs_calc=true / null (нужен гидравлический расчёт), жёлтый. +// Маркеры карты используют hex токенов --success/--danger/--warn (документированное +// исключение raw-Leaflet попапов, ui-tokens.md), карточка — CSS-переменные напрямую. + +export type GasOutletState = "profit" | "deficit" | "calc"; + +// Hex-зеркала токенов (--success/--danger/--warn) для CircleMarker fillColor. +export const GAS_OUTLET_MARKER_COLOR: Record = { + profit: "#0A7A3A", // --success + deficit: "#B3261E", // --danger + calc: "#9A6700", // --warn +}; + +export function classifyGasOutlet(p: GasOutletPoint): GasOutletState { + if (p.needs_calc) return "calc"; + const n = toFiniteNumber(p.free_capacity_mln_m3); + if (n === null) return "calc"; + return n < 0 ? "deficit" : "profit"; +} + +export function gasOutletColor(p: GasOutletPoint): string { + return GAS_OUTLET_MARKER_COLOR[classifyGasOutlet(p)]; +} + +// Множественное число «точка» для «Точки выхода газа (N)» / счётчиков. +export function pluralizeOutletPoints(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return `${n} точка`; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) + return `${n} точки`; + return `${n} точек`; +} diff --git a/frontend/src/hooks/useConnectionCapacity.ts b/frontend/src/hooks/useConnectionCapacity.ts index c2332f84..e1cf03d4 100644 --- a/frontend/src/hooks/useConnectionCapacity.ts +++ b/frontend/src/hooks/useConnectionCapacity.ts @@ -15,6 +15,7 @@ export type PowerCapacitySummary = export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"]; export type GasCapacityBlock = components["schemas"]["GasCapacityBlock"]; export type GasGrsRow = components["schemas"]["GasGrsRow"]; +export type GasOutletPoint = components["schemas"]["GasOutletPoint"]; export type HeatCapacityBlock = components["schemas"]["HeatCapacityBlock"]; export type HeatSystemRow = components["schemas"]["HeatSystemRow"]; diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 88879caf..258bdbf0 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3527,6 +3527,11 @@ export interface components { /** Water */ water: components["schemas"]["WaterCapacityRow"][]; gas?: components["schemas"]["GasCapacityBlock"] | null; + /** + * Gas Outlet Points + * @default [] + */ + gas_outlet_points: components["schemas"]["GasOutletPoint"][]; heat?: components["schemas"]["HeatCapacityBlock"] | null; }; /** ConnectionPointsResponse */ @@ -3925,12 +3930,30 @@ export interface components { /** * GasCapacityBlock * @description Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1. + * + * + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО + * «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4. */ GasCapacityBlock: { /** City Grs */ city_grs: components["schemas"]["GasGrsRow"][]; /** Total Free Th M3 H */ total_free_th_m3_h: number | null; + /** + * Outlets Total + * @default 0 + */ + outlets_total: number; + /** + * Outlets Deficit + * @default 0 + */ + outlets_deficit: number; + /** + * Outlets Needs Calc + * @default 0 + */ + outlets_needs_calc: number; }; /** * GasGrsRow @@ -3950,6 +3973,26 @@ export interface components { /** Outputs Count */ outputs_count: number; }; + /** + * GasOutletPoint + * @description Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4. + */ + GasOutletPoint: { + /** Outlet Name */ + outlet_name: string; + /** Consumer Type */ + consumer_type: string | null; + /** Free Capacity Mln M3 */ + free_capacity_mln_m3: number | null; + /** Needs Calc */ + needs_calc: boolean; + /** Distance M */ + distance_m: number; + /** Lat */ + lat: number | null; + /** Lon */ + lon: number | null; + }; /** * GroundedIn * @description Привязка ответа к источнику — какой ран/схема/секции легли в основу ответа.