fix(b07): 횡단도 [확정]은 설계 상태만 올림 — 입력 셋으로 다시 계산한 값으로 정본을 덮던 것 걷음(브레인 ②)

- 옛 길: 지반·단면·측구 쪽 셋만으로 compute_cross_design 을 돌려 설계를 통째로 update
  → 암선·절토경사·표준 횡단·구조물 트림·사용자 입력(측구 끔)이 사라짐
- 936be972 실측(읽기만): 62측점 전부 단면적 바뀜 · 절토 5,868.92 → 4,650.76㎥(−20.8%) · 성토 +5.8% · 종점 1078.01 측구 끔→켬
- 지금: patch = {status: confirmed} 뿐 · 응답 설계는 저장값 그대로 · _recompute_confirmed_design 지움
- 확인: 실제 라우터로 23장 전부 [확정] → 62측점 상태 뺀 값 차이 0 · 상태 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 23:43:58 +09:00
co-authored by Claude Opus 5
parent 0fa6cd0898
commit 419148901b
6 changed files with 120 additions and 63 deletions
+13 -31
View File
@@ -43,7 +43,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_invalidate_drawing,
_read_drawing,
_read_json,
_recompute_confirmed_design,
_store_confirmed_drawing,
landuse_source,
lidar_source,
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
횡단도 확정 시 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
"""
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
@@ -444,9 +443,10 @@ async def confirm_design_drawing(
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
# 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
# B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
# 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, confirmed_design in recomputed:
for chainage_int, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
if all_confirmed:
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=recomputed[0][1] if len(recomputed) == 1 else None,
design=confirmed_designs[0][1] if len(confirmed_designs) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -646,32 +646,3 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
if entry:
entry["confirmed"] = False
_write_manifest(project_root, manifest)
def _recompute_confirmed_design(
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
design_elevation = design_elevation_from_longitudinal(
longitudinal, float(source.get("chainage_m", 0.0))
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=designation["ground_type"],
section_mode=designation["section_mode"],
ditch_side=designation.get("ditch_side"),
**curve_widening_args(source),
)
design["status"] = "confirmed"
return design
+1 -1
View File
@@ -86,7 +86,7 @@ class DesignDrawingConfirmResponse(BaseModel):
id: str
confirmed: bool
all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
# 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
design: dict[str, Any] | None = None
+1 -1
View File
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
}
/**
* / 2 (, B07 ).
* / 2 (B06 · B07 ).
*
* **( ) ** `design`
* (2026-09-03 ).
@@ -0,0 +1,104 @@
"""B07 횡단도 [확정]은 설계 **상태만** 올린다 — 정본 설계값을 덮지 않는다 (2026-09-14 브레인 ②).
실측(936be972 · 읽기만): [확정] 입력 (지반·단면·측구 )만으로 단면적을 다시 계산해
설계를 통째로 덮었다 62측점 전부 단면적이 바뀌고(절토 20.8% · 성토 +5.8%) 암선·절토경사·
표준 횡단·구조물 트림이 빠졌으며, 종점 1078.01 **사용자가 측구가 켜졌다**.
B07 CAD 에는 설계를 고치는 자리가 없으므로 덮을 값이 없다 반만 계산할 거면 반만 덮는다.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID
import B07_DesignDetail.B07_DesignDetail_Router as router
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingConfirmRequest
STORED = {
"status": "provisional",
"ground_type": "ripping_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"ditch_enabled": False,
"rock_boundary_offset_m": 0.8,
"cut_slope_ratio": 0.5,
"cut_area_m2": 48.894,
"fill_area_m2": 54.84,
}
class _Cursor:
async def __aenter__(self) -> _Cursor:
return self
async def __aexit__(self, *_: object) -> bool:
return False
class _Connection:
async def begin(self) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
def cursor(self) -> _Cursor:
return _Cursor()
class _Acquire:
async def __aenter__(self) -> _Connection:
return _Connection()
async def __aexit__(self, *_: object) -> bool:
return False
def test_확정은_상태만_올리고_설계값을_안_덮는다(monkeypatch, tmp_path: Path) -> None:
patches: list[dict] = []
async def source(_project_id: UUID) -> tuple[int, Path, Path, bool]:
return 182, tmp_path, tmp_path / "longitudinal.json", False
async def designs(_route_id: int) -> dict[int, dict]:
return {700: dict(STORED)}
async def merge(_connection: object, **kwargs: object) -> bool:
patches.append(dict(kwargs["patch"])) # type: ignore[arg-type]
return True
async def stage(*_: object) -> None: ...
monkeypatch.setattr(router, "_confirmed_source", source)
monkeypatch.setattr(router, "_designs_by_chainage", designs)
monkeypatch.setattr(
router, "_drawing_list", lambda *_: [SimpleNamespace(id="cross_00700m", kind="cross")]
)
monkeypatch.setattr(router, "extract_quantity_table", lambda *_: None)
monkeypatch.setattr(router, "_store_confirmed_drawing", lambda *_: False)
monkeypatch.setattr(router, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
monkeypatch.setattr(router, "merge_cross_section_design_by_round", merge)
monkeypatch.setattr(router, "start_stage", stage)
monkeypatch.setattr(router, "complete_stage", stage)
# 옛 길 — 입력 셋으로 다시 계산한 값(암 절토경사가 빠져 절토가 줄어든 모양)을 흉내 낸다.
monkeypatch.setattr(
router,
"_recompute_confirmed_design",
lambda *_: {**STORED, "ditch_enabled": True, "cut_area_m2": 7.527, "status": "confirmed"},
raising=False,
)
response = asyncio.run(
router.confirm_design_drawing(
UUID("936be972-11bc-46c2-8bf3-b15d8de7df0d"),
"cross_00700m",
DesignDrawingConfirmRequest(drawing={}),
)
)
# 덮는 것은 상태 하나뿐 — 단면적·사용자 입력(측구 끔)은 그대로.
assert patches == [{"status": "confirmed"}]
# 화면 정보 패널이 받는 설계도 저장값 그대로(상태만 확정).
assert response.design == {**STORED, "status": "confirmed"}