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:
2026-09-09 12:02:27 +09:00
co-authored by Claude Opus 5
parent 513aff3ec0
commit e1fed5f68b
11 changed files with 506 additions and 12 deletions
+19 -1
View File
@@ -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). 세트는 폭의
+247
View File
@@ -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,
}
)
+33 -2
View File
@@ -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
View File
@@ -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;
}