From d93bfca3e06a19a621d2f13c430b243e78c559a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 20:25:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=99=95=EC=A0=95=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8F=84=EB=A9=B4=EC=9D=84=20=EC=97=B4=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EA=B2=8C=20=E2=80=94=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C=EB=A1=9C(=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EB=A7=89=EA=B3=A0=20=EA=B0=9C=EB=B0=9C=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=A7=8C=20=EC=97=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B07 409 는 워크플로 단계가 아니라 **종단 레코드 상태**(`longitudinal_sections.status`)를 보는 가드라 `dev/unlock` 으로 안 풀렸음. 읽는 쪽만 우회함. 지킨 것 넷 1. **문은 서버가 막음** — 화면 표시로 여는 것이 아님. 운영에서는 그대로 409 (`is_dev_environment()` 가 거짓이면 우회가 아예 없음). 2. **표식이 실제로 있을 때만 엶** — 개발환경이라고 무조건 열지 않음. `dev/unlock` 이 4단계를 풀어 둔 프로젝트만. 그래야 되돌리기(DELETE)로 다시 닫힘. 3. **레코드는 안 건드림** — `status` 는 `DRAFT` 그대로. 상태를 올리면 그것이 곧 「확정 흉내」라 `common_util_dev_unlock` 이 스스로 금지한 자리와 같아짐. 4. **열렸다는 것이 화면에 뜸** — 좌측 목록 머리 아래 「확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.」 조용히 열면 다음 사람이 헤맴. 실화면 확인 (프로젝트 936be972) 목록 200 · `dev_bypass: true` · 도면 34장 · 안내 문구 뜸 표준도 4장 제목에 판정된 기울기 — 「표준도 2장 (돌쌓기(메) H=2 **1:0.35** 뒷길이 35㎝ 야면석·호박돌)」 · 찰은 1:0.3 자체검증 — 회귀 573 통과 · 0 실패, `tsc --noEmit` 0, prettier 는 바꾼 파일만. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 2 + B07_DesignDetail/B07_DesignDetail_Router.py | 40 ++++++++++++++----- B07_DesignDetail/B07_DesignDetail_Schema.py | 3 ++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 9 +++-- .../B07_DesignDetail_UI_Panels.ts | 9 +++++ .../B07_DesignDetail_UI_Style.css | 9 +++++ 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 0c79a83a..c50c1f5c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -47,6 +47,8 @@ export interface DesignDrawingListResponse { project_id: string; route_id: number; drawings: DesignDrawingItem[]; + /** 확정을 건너뛰고 개발 우회로로 열렸나 — 화면이 그 사실을 알려야 한다. */ + dev_bypass?: boolean; } /** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */ diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 4817f469..c972e108 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -8,6 +8,7 @@ from pathlib import Path, PurePosixPath from typing import Any from uuid import UUID +from aiomysql import DictCursor from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -56,6 +57,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingListResponse, DesignDrawingResponse, ) +from common_util.common_util_dev_unlock import is_dev_environment, unlock_status from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage, start_stage @@ -68,8 +70,21 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") -async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: - """확정된 B06 종단 레코드와 프로젝트 저장 경로를 반환한다.""" +#: B07(상세설계) 워크플로 단계 번호 — 개발 우회로가 이 단계를 풀었는지 본다. +_B07_STAGE_NO = 4 + + +async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path, bool]: + """확정된 B06 종단 레코드와 프로젝트 저장 경로. 넷째는 **개발 우회로로 열렸나**. + + ⚠ **문은 서버가 막는다.** 화면 표시로 여는 것이 아니라 여기서 막고, **운영에서는 그대로 + 409** 다(`is_dev_environment()` 가 거짓이면 우회가 아예 없다). + ⚠ **개발환경이라고 무조건 열지 않는다** — `dev/unlock` 표식이 실제로 있을 때만 연다. + 그래야 되돌리기(DELETE)로 **다시 닫힌다**. + ⚠ **레코드는 손대지 않는다** — `longitudinal_sections.status` 는 `DRAFT` 그대로 두고 + **읽는 쪽만** 우회한다. 상태를 올리면 그것이 곧 「확정 흉내」라 `common_util_dev_unlock` + 이 스스로 금지한 자리와 같아진다. + """ pool = get_db_pool() async with pool.acquire() as connection: route_context = await get_confirmed_route_context(connection, project_id) @@ -77,15 +92,22 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: raise FileNotFoundError("확정된 경로가 없습니다.") route_id = int(route_context["route_id"]) longitudinal = await get_longitudinal_section(connection, project_id, route_id) + bypassed = False if not longitudinal or longitudinal.get("status") != "CONFIRMED": - raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.") + if is_dev_environment(): + async with connection.cursor(DictCursor) as cursor: + status = await unlock_status(cursor, str(project_id)) + bypassed = _B07_STAGE_NO in (status.get("bypassed_stages") or []) + if not (bypassed and longitudinal): + raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.") + logger.info("B07 개발 우회로로 열림 — 확정 건너뜀: project_id=%s", project_id) stored_path = await get_project_storage_relative_path(connection, project_id) root = Path(resolve_stored_project_path(stored_path)).resolve() longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve() if root not in longitudinal_path.parents or not longitudinal_path.is_file(): raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.") - return route_id, root, longitudinal_path + return route_id, root, longitudinal_path, bypassed async def _company_dir(project_id: UUID) -> Path: @@ -287,11 +309,11 @@ async def get_design_drawing_list( ) -> DesignDrawingListResponse | JSONResponse: """B07 좌측 패널용 도면 메타데이터만 캐시한다.""" try: - route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) designs = await _designs_by_chainage(route_id) drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) return DesignDrawingListResponse( - project_id=str(project_id), route_id=route_id, drawings=drawings + project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass ) except FileNotFoundError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) @@ -311,7 +333,7 @@ async def get_design_drawing( ) -> DesignDrawingResponse | JSONResponse: """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" try: - route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) # 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각). # 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다. use_company_templates(project_root.parent.parent) @@ -423,7 +445,7 @@ async def confirm_design_drawing( 횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다. """ try: - route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) designs = await _designs_by_chainage(route_id) items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) item = next((candidate for candidate in items if candidate.id == drawing_id), None) @@ -542,7 +564,7 @@ async def invalidate_design_drawing( ) -> DesignDrawingInvalidateResponse | JSONResponse: """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: - route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) # 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로, # 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다 # (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가). diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 9e3b2f07..eb8efa66 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -36,6 +36,9 @@ class DesignDrawingListResponse(BaseModel): project_id: str route_id: int drawings: list[DesignDrawingItem] + #: 확정을 건너뛰고 개발 우회로로 열렸나. **조용히 열지 않기 위해** 응답에 싣는다 — + #: 그냥 열면 다음 사람이 「왜 값이 없나」로 헤맨다(2026-09-09). + dev_bypass: bool = False class DesignDrawingResponse(BaseModel): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index fa37be21..01d264ee 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -97,14 +97,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; + let devBypass = false; if (projectId) { const [workflowResult, drawingResult] = await Promise.allSettled([ fetchWorkflowState(projectId), fetchDesignDrawingList(projectId), ]); if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; - if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings; - else + if (drawingResult.status === "fulfilled") { + drawings = drawingResult.value.drawings; + devBypass = drawingResult.value.dev_bypass === true; + } else drawingError = drawingResult.reason instanceof Error ? drawingResult.reason.message @@ -580,7 +583,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } }); - const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); + const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 79d1458f..f1791aef 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -47,6 +47,7 @@ export function buildDrawingSidePanel( drawings: DesignDrawingItem[], onSelect: (drawing: DesignDrawingItem) => void, errorMessage?: string, + devBypass = false, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; @@ -58,6 +59,14 @@ export function buildDrawingSidePanel( count.textContent = `${drawings.length}건`; heading.append(title, count); panel.append(heading); + // 확정을 건너뛴 상태라는 것을 **조용히 두지 않는다** — 그냥 열면 다음 사람이 + // 「왜 값이 없나」로 헤맨다(2026-09-09). 되돌리기는 B08 좌측 [확정 없이 다음으로] 옆. + if (devBypass) { + const notice = document.createElement("p"); + notice.className = "b07-drawing-list__bypass"; + notice.textContent = "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다."; + panel.append(notice); + } if (errorMessage || drawings.length === 0) { const empty = document.createElement("p"); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 7bdd7f9f..2e900720 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -327,3 +327,12 @@ padding: 4px 8px; font-size: 0.78rem; } + +/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ +.b07-drawing-list__bypass { + margin: var(--spacing-4) 0 0; + padding: var(--spacing-4) var(--spacing-8); + border-left: 3px solid var(--color-warning, #d9a441); + color: var(--color-text-muted); + font-size: var(--text-caption); +}