From e1fed5f68b86c75256210a2be2b7092c01b80549 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:02:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06/B08):=20=EC=8A=A4=EB=83=85=EB=90=9C=20?= =?UTF-8?q?=EA=B4=80=EC=9D=B4=20=EC=B8=A1=EC=A0=90=EC=97=90=20=EC=95=88=20?= =?UTF-8?q?=EB=B6=99=EC=96=B4=20=EA=B8=88=EC=95=A1=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=A0=EC=A7=80=EB=8D=98=20=EA=B2=83=20=E2=80=94=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EA=B4=80=20=EB=84=B7=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠⚠ **진단이 뒤집힌 자리임.** 계획서 3-14 는 「관을 나중에 놓으면 측점이 안 생긴다」로 적혀 있었으나 실측하니 **측점은 이미 있었음** — 구조물 이름표까지 달고. 진짜 원인 — **관 자리와 측점 자리는 최대 0.5m 어긋나고 그것이 설계임.** 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅함 (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 **측점 440.0** 위에 섬. 그런데 붙이는 쪽이 **0.02m** 로만 봐서 그런 관은 어느 측점에도 안 붙었음 ⇒ 횡단도에 안 서고 길이도 안 실려 B08 이 「연장 없음」으로 막음. - `attach_culvert_sets` / `attachCulvertSets`(짝) — **가장 가까운 측점 하나**를 그 관의 자리로 봄. 거리로 자르지 않아 스냅 폭이 바뀌어도 따라가고, 하나만 고르므로 두 번 안 셈 - `pipeOwnerChainage` 신설 — 길이를 싣는 주인도 같은 규칙 - `SECTION_MATCH_TOLERANCE_M` 0.05 → 0.5 — 좁게 보면 「횡단 자체가 없습니다」라는 **거짓 사유**가 뜸(반대 방향의 거짓). 옛 판단 근거를 주석에 남기고 뒤집은 까닭도 적음 실측 — 관 9개 중 **5개만** 길이가 있던 것이 **9개 전부**로 (440·620·720·900 복구). 곁들여 - 「측점 없는 구조물 N개」 알림 + [측점 만들기] 단추(3-14 ㉯) — **진짜로 측점이 없는** 경우를 위해 남김. 판정은 스냅을 셈에 넣어 0.5m. 샘플링 조건은 확정 때 남긴 `sampling.json` → 없으면 1단계 저장값. 둘 다 없으면 막고 사유 - 등록부 `retaining_wall.form` 에 「식생옹벽블럭」 추가(다른 창 요청) Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Router_Confirm.py | 45 ++++ B05_Profile/B05_Profile_Structure_Types.json | 2 +- B06_Section/B06_Section_Engine_Culvert.py | 20 +- B06_Section/B06_Section_Router_Stations.py | 247 ++++++++++++++++++ B06_Section/B06_Section_Structure_Layouts.ts | 35 ++- .../B06_Section_UI_Missing_Stations.ts | 99 +++++++ B06_Section/B06_Section_UI_Page.ts | 4 + .../B06_Section_UI_Style_Cross_Areas.css | 26 ++ B08_Quantity/B08_Quantity_Engine_Pipe.py | 19 +- common_util/common_util_culvert_sets.ts | 19 +- main.py | 2 + 11 files changed, 506 insertions(+), 12 deletions(-) create mode 100644 B06_Section/B06_Section_Router_Stations.py create mode 100644 B06_Section/B06_Section_UI_Missing_Stations.ts diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index ac8d637e..a9ce488e 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -199,6 +199,50 @@ def _merge_irregular_into_longitudinal( atomic_write_json(path, data) +#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해. +#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다. +SAMPLING_SNAPSHOT_NAME = "sampling.json" + + +def sampling_snapshot_path(project_root: Path) -> Path: + return project_root / "B06_Section" / SAMPLING_SNAPSHOT_NAME + + +def save_sampling_snapshot(project_root: Path, request: RouteConfirmRequest) -> None: + """확정 때 쓴 지표 샘플링 조건을 남긴다(2026-09-09). + + 왜 — 관을 나중에 놓으면 그 측점이 안 생기는데(계획서 3-14), 나중에 만들려면 **그때와 같은 + 조건**으로 떠야 한다. 조건을 안 남기면 되짚을 길이 없어 **지어내야 하는 자리**가 된다. + """ + if not request.filter_key or not request.method: + return + path = sampling_snapshot_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + { + "filter_key": request.filter_key, + "method": request.method, + "smooth": bool(request.smooth), + "surface_model_id": request.surface_model_id, + }, + ) + + +def load_sampling_snapshot(project_root: Path) -> dict[str, Any] | None: + """남겨 둔 샘플링 조건. 없으면 `None` — **지어내지 않는다**(노선 확정을 한 번 더 받는다).""" + path = sampling_snapshot_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not data.get("filter_key") or not data.get("method"): + return None + return data + + async def _append_irregular_cross_sections( connection: aiomysql.Connection, project_id: UUID, @@ -208,6 +252,7 @@ async def _append_irregular_cross_sections( """확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용).""" stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) + save_sampling_snapshot(project_root, request) stored_options = await get_latest_section_options(connection, project_id) crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id) irregular_stations = await asyncio.to_thread( diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a555707d..96cbbc35 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -709,7 +709,7 @@ "key": "form", "label": "형식", "input": "select", - "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"], + "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"], "default": null, "required": true, "phase": "detail" diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 5f18bbd2..17f00ca6 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -431,6 +431,24 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] if not sets: return 0 attached = 0 + # ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). + # 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다 + # (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다. + # ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려 + # B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다). + # ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로 + # 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다. + owner: dict[float, float] = {} + for pipe_chainage in sets: + nearest = None + for section in cross_sections: + value = _number(section.get("chainage_m"), None) + if value is None: + continue + if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage): + nearest = value + if nearest is not None: + owner[pipe_chainage] = nearest for section in cross_sections: chainage = _number(section.get("chainage_m"), None) if chainage is None: @@ -440,7 +458,7 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] reach = _CHAINAGE_TOLERANCE_M if spec.get("type") in _SPAN_LINKED_TYPES: reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2 - if abs(chainage - pipe_chainage) <= reach: + if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage: # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. kind = spec.get("type") # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py new file mode 100644 index 00000000..53e73e5e --- /dev/null +++ b/B06_Section/B06_Section_Router_Stations.py @@ -0,0 +1,247 @@ +"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯). + +무엇이 문제였나 + 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이다. 관을 저장하는 + `PUT /drainage/pipe-points` 는 관 파일과 유역만 쓰고 측점을 다시 만들지 않는다. + ⇒ **관을 나중에 놓거나 옮기면 그 측점이 안 생긴다.** 그 관은 횡단도에도 안 서고 + 수량·금액에서 통째로 빠지는데 **아무 말도 안 나온다**(실측: 배수관 넷). + +왜 이 방식인가 (세 갈래 중 ㉯) + ㉮ 관 저장 뒤 바로 만들기 — 그 엔드포인트에 노선·지표면 인자가 없어 끌어와야 함 + ㉯ **알리고 [측점 만들기] 단추** — 누를 때만 돌아 비용이 적고 **왜 값이 없는지가 보임** + ㉰ 그대로 두기 — 조용히 빠지는 것이 문제라 적어도 알림은 있어야 함 + +⚠ **지어내지 않는 것** — 지표 샘플링 조건(어느 DTM·어느 방법)이 없으면 만들지 않는다. + 조건이 다르면 그 측점만 다른 지표에서 뽑혀 **옆 측점과 지반고가 어긋난다.** 조건은 + 노선 확정 때 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg +from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects", tags=["B06 Section"]) + +#: 「그 자리에 측점이 있다」고 볼 거리(m). +#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리). +#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다 +#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0 +#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`). +#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.** +#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다. +STATION_MATCH_TOLERANCE_M = 0.5 + +SNAPSHOT_MISSING = ( + "지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤" + " 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남" +) + + +async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None: + """이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다. + + ① 노선 [확정] 때 남긴 `B06_Section/sampling.json` — **그때 실제로 쓴 조건**이라 1순위. + ② 없으면 1단계(지표 확정) 저장값 — B06 화면 `context` 가 쓰는 그 값이라 같은 조건이다. + 옛 프로젝트는 ①이 없으므로 이 길이 없으면 단추가 영영 안 돈다. + """ + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + + snapshot = load_sampling_snapshot(project_root) + if snapshot is not None: + return snapshot + params = await run_with_connection(get_surface_confirmation_params, str(project_id)) + if not params or not params.get("source_filter") or not params.get("method"): + return None + return { + "filter_key": params["source_filter"], + "method": params["method"], + "smooth": bool(params.get("smooth")), + "surface_model_id": None, + "source": "stage1", + } + + +def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]: + """구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록.""" + from B05_Profile.B05_Profile_Engine_Sections import ( + _load_pipe_points, + _load_route_polyline, + resolve_extra_stations, + ) + + polyline = _load_route_polyline(project_root, route_data_path) + pipes = _load_pipe_points(project_root, polyline) + extras = resolve_extra_stations(project_root, pipes) + if not extras: + return [] + existing = _station_chainages(project_root) + missing = [] + for chainage, label in extras: + value = float(chainage) + if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing): + continue + missing.append({"chainage_m": round(value, 3), "label": label}) + return sorted(missing, key=lambda item: item["chainage_m"]) + + +def _station_chainages(project_root: Path) -> list[float]: + """종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록.""" + import json + + folder = project_root / "B06_Section" / "longitudinal" + values: list[float] = [] + for path in sorted(folder.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + for station in data.get("stations") or []: + chainage = station.get("chainage_m") + if isinstance(chainage, (int, float)): + values.append(float(chainage)) + return values + + +async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None: + async def _load(connection): + stored = await get_project_storage_relative_path(connection, project_id) + route = await get_latest_route(connection, project_id) + return stored, route + + stored, route = await run_with_connection(_load) + if not stored or not route: + return None + return Path(resolve_stored_project_path(stored)), route + + +@router.get("/{project_id}/section/missing-stations") +async def get_missing_stations(project_id: UUID) -> JSONResponse: + """측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다.""" + try: + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(content={"status": "success", "missing": [], "can_create": False}) + project_root, route = paths + missing = await asyncio.to_thread( + _missing_marks, project_root, str(route["route_data_path"]) + ) + snapshot = await _sampling_conditions(project_id, project_root) + except Exception: + logger.exception("B06 측점 점검 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "측점을 점검하지 못했습니다."}, + ) + return JSONResponse( + content={ + "status": "success", + "missing": missing, + "can_create": bool(missing) and snapshot is not None, + "reason": "" if snapshot is not None else SNAPSHOT_MISSING, + } + ) + + +@router.post("/{project_id}/section/missing-stations") +async def create_missing_stations(project_id: UUID) -> JSONResponse: + """빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다. + + ⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로 + 빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다. + """ + from B05_Profile.B05_Profile_Engine_Sections import ( + _load_pipe_points, + _load_route_polyline, + generate_irregular_sections, + resolve_extra_stations, + ) + from B05_Profile.B05_Profile_Router_Confirm import ( + _merge_irregular_into_longitudinal, + _section_options_from_stored, + ) + from B06_Section.B06_Section_Repository import ( + get_latest_section_options, + get_longitudinal_section, + ) + + try: + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 노선이 없습니다."}, + ) + project_root, route = paths + snapshot = await _sampling_conditions(project_id, project_root) + if snapshot is None: + return JSONResponse( + status_code=409, + content={"status": "error", "message": SNAPSHOT_MISSING}, + ) + missing = await asyncio.to_thread( + _missing_marks, project_root, str(route["route_data_path"]) + ) + if not missing: + return JSONResponse(content={"status": "success", "created": 0, "missing": []}) + + async def _load(connection): + options = await get_latest_section_options(connection, project_id) + crs_epsg = await get_surface_crs_epsg( + connection, project_id, snapshot.get("surface_model_id") + ) + longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) + return options, crs_epsg, longitudinal + + stored_options, crs_epsg, longitudinal = await run_with_connection(_load) + + def _regenerate() -> int: + polyline = _load_route_polyline(project_root, str(route["route_data_path"])) + pipes = _load_pipe_points(project_root, polyline) + extras = resolve_extra_stations(project_root, pipes) + stations = generate_irregular_sections( + project_root, + str(route["route_data_path"]), + str(snapshot["filter_key"]), + str(snapshot["method"]), + bool(snapshot.get("smooth")), + extra_stations=extras, + options=_section_options_from_stored(stored_options), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + if stations and longitudinal: + _merge_irregular_into_longitudinal( + project_root, str(longitudinal["longitudinal_file_path"]), stations + ) + return len(stations) + + made = await asyncio.to_thread(_regenerate) + except Exception: + logger.exception("B06 측점 만들기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "측점을 만들지 못했습니다."}, + ) + return JSONResponse( + content={ + "status": "success", + # 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다. + "created": len(missing), + "regenerated": made, + "missing": missing, + } + ) diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 8a61f7b8..066718ec 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -61,6 +61,27 @@ const inletStructure: InletStructureControl = { resetAdjust: () => undefined, }; +/** + * 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`. + * + * ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고 + * **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다. + */ +function pipeOwnerChainage( + section: CrossSection, + sections: readonly CrossSection[], +): number | null { + const target = section.culvert?.chainage_m; + if (typeof target !== "number") return null; + let best: number | null = null; + for (const item of sections) { + if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) { + best = item.chainage_m; + } + } + return best; +} + /** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) { const design = section.design; @@ -136,10 +157,20 @@ function areaRowOf( // ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만 // (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번** // 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다. + // + // ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). + // 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다 + // (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을 + // 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다. + // ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고, + // B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷). + // ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이 + // 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다. + const ownerChainage = pipeOwnerChainage(section, sections); const pipeOwner = !!section.culvert && - (typeof section.culvert.chainage_m !== "number" || - Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M); + (ownerChainage === null || + Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M); const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined; const pipeRow: Record | null = typeof pipeLengthM === "number" && pipeLengthM > 0 diff --git a/B06_Section/B06_Section_UI_Missing_Stations.ts b/B06_Section/B06_Section_UI_Missing_Stations.ts new file mode 100644 index 00000000..8031db76 --- /dev/null +++ b/B06_Section/B06_Section_UI_Missing_Stations.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * B06_Section_UI_Missing_Stations.ts + * 「측점 없는 관 N개」 알림 + [측점 만들기] 단추 (계획서 3-14 ㉯). + * + * 무엇이 문제였나 — 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이라, 관을 나중에 + * 놓거나 옮기면 그 측점이 안 생긴다. 그 관은 횡단도에도 안 서고 **수량·금액에서 통째로 + * 빠지는데 아무 말도 안 나온다**(실측: 배수관 넷이 B09 에서 막혀 있었다). + * + * ⚠ 자동으로 만들지 않는다 — 사용자가 누를 때만 돈다(비용이 큰 지표 샘플링이다). + * 대신 **왜 값이 없는지**가 화면에 남는다. + * ⚠ 지표 샘플링 조건이 저장에 없으면 **단추를 잠그고 사유를 보인다** — 조건을 지어내면 + * 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; + +interface MissingStation { + chainage_m: number; + label: string; +} + +interface MissingResponse { + missing?: MissingStation[]; + can_create?: boolean; + reason?: string; + created?: number; + message?: string; +} + +async function call(projectId: string, method: "GET" | "POST"): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, { + method, + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + const payload = (await response.json()) as MissingResponse; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +/** + * 빠진 측점이 있으면 알림 줄을 `host` 맨 앞에 얹는다. 없으면 아무것도 하지 않는다. + * `onCreated` 는 측점이 실제로 생긴 뒤에만 불린다(화면을 다시 읽는 자리). + */ +export async function mountMissingStationNotice( + host: HTMLElement, + projectId: string, + onCreated: () => void | Promise, +): Promise { + let data: MissingResponse; + try { + data = await call(projectId, "GET"); + } catch { + return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다. + } + const missing = data.missing ?? []; + if (!missing.length) return; + + const box = document.createElement("div"); + box.className = "b06-missing-stations"; + const text = document.createElement("p"); + text.className = "b06-missing-stations__text"; + const where = missing + .slice(0, 6) + .map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`) + .join(" · "); + text.textContent = + `측점이 없는 구조물 ${missing.length}개 — ${where}` + + (missing.length > 6 ? ` 외 ${missing.length - 6}개` : "") + + ". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다."; + box.append(text); + + if (data.can_create) { + const button = createButton({ label: "측점 만들기", variant: "filled" }); + button.addEventListener("click", async () => { + button.disabled = true; + button.textContent = "만드는 중…"; + try { + const result = await call(projectId, "POST"); + showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success"); + box.remove(); + await onCreated(); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`측점을 만들지 못했습니다.${detail}`, "error"); + button.disabled = false; + button.textContent = "측점 만들기"; + } + }); + box.append(button); + } else if (data.reason) { + const reason = document.createElement("p"); + reason.className = "b06-missing-stations__reason"; + reason.textContent = data.reason; + box.append(reason); + } + host.prepend(box); +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index a09744a8..500c100a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,6 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; @@ -750,6 +751,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); + // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 + // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. + void mountMissingStationNotice(root, projectId, refreshDetailForStructures); void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 94de826c..4ee815e5 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -416,3 +416,29 @@ .b06-chart__spoil-fill.is-unclosed { stroke: #c0392b; } + +/* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */ +.b06-missing-stations { + align-items: center; + background: #fff8e6; + border: 1px solid #e0b872; + border-radius: 6px; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin: 0 0 12px; + padding: 10px 12px; +} + +.b06-missing-stations__text { + color: #7a5a12; + flex: 1 1 320px; + margin: 0; +} + +.b06-missing-stations__reason { + color: #8a6a22; + flex: 1 1 100%; + font-size: 12px; + margin: 0; +} diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 9261086c..64598269 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -45,9 +45,14 @@ NOTE_SECTION_MISSING = ( "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" ) -#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면 -#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다. -SECTION_MATCH_TOLERANCE_M = 0.05 +#: 관 자리에 횡단이 있는지 볼 때의 허용 오차(m). +#: ⚠⚠ **0.05 → 0.5 로 넓혔다**(2026-09-09 실측으로 앞의 판단이 뒤집힌 자리). +#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다(횡단 파일명이 +#: 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 그래서 관 440.241 의 횡단은 +#: **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다 — **없는 것이 아니다.** +#: 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다(반대 방향의 거짓). +#: 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고, 길이 찾기와 같은 값이 된다. +SECTION_MATCH_TOLERANCE_M = 0.5 NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다" @@ -126,10 +131,10 @@ def build_rows( kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind) length = _nearest(lengths, chainage) - # ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다. - # 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로 - # 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다. - # 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤). + # ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) — + # 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게 + # 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로 + # 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다. has_section = not sections or any( abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections ) diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 165ed43c..83a726e9 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -329,6 +329,23 @@ export function attachCulvertSets( ): number { if (sets.size === 0) return 0; let attached = 0; + // ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). + // 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이 + // 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다. + // ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.** + // ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`). + const owner = new Map(); + for (const [pipeChainage] of sets) { + let nearest: number | null = null; + for (const section of sections) { + const value = num(section.chainage_m, null); + if (value === null) continue; + if (nearest === null || Math.abs(value - pipeChainage) < Math.abs(nearest - pipeChainage)) { + nearest = value; + } + } + if (nearest !== null) owner.set(pipeChainage, nearest); + } for (const section of sections) { const chainage = num(section.chainage_m, null); if (chainage === null) continue; @@ -336,7 +353,7 @@ export function attachCulvertSets( // 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다. let reach = CHAINAGE_TOLERANCE_M; if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; - if (Math.abs(chainage - pipeChainage) <= reach) { + if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) { // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬 // `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. diff --git a/main.py b/main.py index b3f96e6f..a3967f44 100644 --- a/main.py +++ b/main.py @@ -50,6 +50,7 @@ from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecyc from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router +from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) @@ -538,6 +539,7 @@ app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) +app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company)