fix(B06/B08): 스냅된 관이 측점에 안 붙어 금액에서 빠지던 것 — 배수관 넷 복구
⚠⚠ **진단이 뒤집힌 자리임.** 계획서 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -709,7 +709,7 @@
|
||||
"key": "form",
|
||||
"label": "형식",
|
||||
"input": "select",
|
||||
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
|
||||
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"],
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
|
||||
@@ -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). 세트는 폭의
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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<string, number> | null =
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
|
||||
@@ -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<MissingResponse> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<number, number>();
|
||||
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`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
|
||||
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user