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:
@@ -47,6 +47,8 @@ export interface DesignDrawingListResponse {
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
drawings: DesignDrawingItem[];
|
||||
/** 확정을 건너뛰고 개발 우회로로 열렸나 — 화면이 그 사실을 알려야 한다. */
|
||||
dev_bypass?: boolean;
|
||||
}
|
||||
|
||||
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user