feat(B07): 확정 없이 도면을 열 수 있게 — 개발 우회로(서버가 막고 개발에서만 엶)

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 20:25:06 +09:00
co-authored by Claude Opus 5
parent b375ba8114
commit d93bfca3e0
6 changed files with 60 additions and 12 deletions
@@ -47,6 +47,8 @@ export interface DesignDrawingListResponse {
project_id: string;
route_id: number;
drawings: DesignDrawingItem[];
/** 확정을 건너뛰고 개발 우회로로 열렸나 — 화면이 그 사실을 알려야 한다. */
dev_bypass?: boolean;
}
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
+31 -9
View File
@@ -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` 확정 후 확정 해제 불가).
@@ -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):
+6 -3
View File
@@ -97,14 +97,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
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<void> {
}
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
@@ -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");
@@ -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);
}