diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 9b2c1a00a..7959a910c 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -2,8 +2,8 @@ * design_dock.ts * B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b). * - * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**: - * [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 + * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 둘은 두 보기가 **같다**: + * [저장] [초기화](앞은 공용 [?] [<] [>] · B06 [>] = 확정 뒤 B07 · 요구 280-2 · 298-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 * 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫). * ========================================================================== */ @@ -24,12 +24,11 @@ export interface DesignDockAction { export interface DesignDockActions { reset: DesignDockAction; save: DesignDockAction; - confirm: DesignDockAction; } export interface DesignDock { root: HTMLElement; - /** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ + /** 단추 둘의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ setActions: (actions: Partial) => void; /** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */ setView: (view: DesignView) => void; @@ -72,7 +71,7 @@ export function createDesignDock( }; const resetButton = createButton({ label: label("Common_Btn_Reset"), - variant: "danger", + variant: "danger-ghost", onClick: press("reset"), }); const saveButton = createButton({ @@ -80,18 +79,11 @@ export function createDesignDock( variant: "ghost", onClick: press("save"), }); - const confirmButton = createButton({ - label: label("B06_Profile_Btn_Confirm"), - variant: "filled", - onClick: press("confirm"), - }); resetButton.dataset.dockAction = "reset"; saveButton.dataset.dockAction = "save"; - confirmButton.dataset.dockAction = "confirm"; const buttons = { reset: resetButton, save: saveButton, - confirm: confirmButton, }; const sync = (): void => { @@ -104,7 +96,7 @@ export function createDesignDock( row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; }; - row.append(resetButton, saveButton, confirmButton); + row.append(saveButton, resetButton); root.append(...above, divider, row, busy); sync(); diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 1d54d57e2..02d492400 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -21,6 +21,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../b_page_scaffold"; import { + clearWorkflowStateCache, fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, @@ -28,7 +29,7 @@ import { } from "../b_workflow_nav"; import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; -import { saveSharedDesign, type DesignPageShared } from "./design_shared"; +import { confirmAndGo, saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import { createFacilityLane } from "./design_facility_lane"; @@ -152,6 +153,12 @@ export function renderDesignPage(root: HTMLElement): MountedPage { lane: createFacilityLane(projectId, () => shared.profile), revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, + refreshStages: () => { + clearWorkflowStateCache(); + void fetchWorkflowState(projectId) + .then((state) => layout?.setStages(state.stages)) + .catch(() => undefined); + }, }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2) @@ -177,12 +184,14 @@ export function renderDesignPage(root: HTMLElement): MountedPage { leftPanel: left, mainContent: main, stages: state?.stages, - currentStage: state?.current_stage, + // 종단 · 횡단 · 상세설계 칸은 늘 켬 — B07 로 가는 길이 곧 확정(496-1 · 운영 잠금 흠) + currentStage: [2, 3, 4], routes: WORKFLOW_STEP_ROUTES, - // 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. + // 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. B07~B09 = 확정 뒤 이동(496-1). onStepClick: (stepIndex) => { const route = WORKFLOW_STEP_ROUTES[stepIndex]; if (designViewOf(route)) navigateTo(route); + else if (stepIndex >= 4) confirmAndGo(shared, route); else goToWorkflowStage(projectId, route); }, }); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index eda24c8cf..0317095d6 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -65,6 +65,8 @@ export interface DesignSaveContext { done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; + /** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */ + routeMoved?: () => Promise; } export type PartOverride = ( @@ -222,9 +224,12 @@ export async function saveDesign( } const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse; noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0)); - // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. - if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); - else await ctx.done?.(false); + // 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움. + if (confirm) { + hideLoadingOverlay(); // 물음 동안 가림막을 걷음 + await ctx.routeMoved?.(); + showLoadingOverlay(); + } else await ctx.done?.(false); await rebuildAfterRouteSave(ctx.projectId); // 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3) if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId); diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index cef8daf8e..c808a2c5d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -7,14 +7,19 @@ * `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도 * 받는다 — 다시 보일 때 같은 값을 들고 있게. * 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김. - * [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의 - * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). + * [저장] · 확정(B06 [>] · 단계 판 B07~B09 · 496-1)은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 + * 보낸다. 종단 보기의 확정은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; +import type { RoutePath } from "@config/config_frontend"; +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; +import { + clearWorkflowStateCache, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../b_workflow_nav"; import { readState, writeState } from "../b_page_state"; import { currentRoute, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; @@ -116,6 +121,10 @@ export interface DesignPageShared { revealLeft: () => void; /** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */ ensureView: (view: DesignView) => Promise; + /** 자료가 섰다(`designReady`) — 그 전 확정 이동은 막는다. */ + ready?: boolean; + /** [저장] 뒤 진행 단계 판 색을 서버 단계 상태로 다시 맞춘다(496-1). */ + refreshStages?: () => void; } const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {}; @@ -342,7 +351,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { }, // 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다. save: { run: () => saveSharedDesign(shared, false), enabled: false }, - confirm: { run: () => saveSharedDesign(shared, true), enabled: false }, }, }, shared.view(), @@ -355,16 +363,29 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { return panel; } -/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ +/** 노선 바뀐 채 확정 → [B07 로] 고름 — 다시 세운 페이지가 자료를 세우면 새 노선으로 확정 뒤 그 주소(요구 300 B2). */ +const confirmOnReady = new Map(); + +/** 자료가 섰다 — [저장] · 확정 이동을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ export function designReady(shared: DesignPageShared): void { + shared.ready = true; shared.panel?.dock.setActions({ save: { run: () => saveSharedDesign(shared, false), enabled: true, title: L("B06_Profile_Btn_Save_Tip"), }, - confirm: { run: () => saveSharedDesign(shared, true), enabled: true }, }); + const target = confirmOnReady.get(shared.projectId); + if (!target) return; + confirmOnReady.delete(shared.projectId); + void saveSharedDesign(shared, true, target); +} + +/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */ +export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void { + if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info"); + void saveSharedDesign(shared, true, route); } /** @@ -384,10 +405,14 @@ async function routeChanged(shared: DesignPageShared): Promise { } /** - * [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, + * [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. */ -export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise { +export async function saveSharedDesign( + shared: DesignPageShared, + confirm: boolean, + target: RoutePath = WORKFLOW_STEP_ROUTES[4], +): Promise { if (await routeChanged(shared)) return; // [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도 // 세움(B05 만 연 캐시 패치 길 지움 · 386-6) @@ -432,7 +457,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea // 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1) invalidateSectionDetail(shared.projectId); showToast(L("B06_Profile_Confirm_Success"), "success"); - goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); + goToWorkflowStage(shared.projectId, target); return; } // 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를 @@ -441,6 +466,17 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea await profileSave?.done?.(false); await crossSave?.done?.(false); showToast(L("B06_Profile_Save_Success"), "success"); + shared.refreshStages?.(); + }, + // 노선 모양 바뀐 채 확정 — 새 노선은 저장됨 · 머묾 / 새 노선으로 확정 뒤 이동을 고름(요구 300 B2) + routeMoved: async () => { + clearWorkflowStateCache(); + const go = await showConfirmDialog( + L("Design_Route_Moved"), + L("Design_Route_Moved_Go"), + L("Design_Route_Moved_Stay"), + ); + if (go) confirmOnReady.set(shared.projectId, target); }, failed: (error, isConfirm) => { const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"; diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 11a5f15eb..d0c71d585 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -331,7 +331,7 @@ export function createStandardPanel( pending.className = "b06-std__pending"; pending.textContent = "고친 값이 측점에 아직 안 실렸습니다 — [전체 측점 반영]을 누르면 바로 따라오고, " + - "[저장]·[확정] 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다."; + "[저장]·[>](확정) 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다."; const showPending = (): void => { const now = JSON.stringify(state); // 값이 실렸는지 눈으로도 캘 수 있게 남긴다 — 화면 실측에서 판정 근거가 된다. diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Reset.py b/B07_DesignDetail/B07_DesignDetail_Router_Reset.py new file mode 100644 index 000000000..bf448ede1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Reset.py @@ -0,0 +1,102 @@ +"""B07 [초기화](요구 298-2 · 300 C1 · C3) — 그 페이지 사용자 값 · 설정을 지움. + + POST /api/projects/{id}/design-drawings/reset 본문 없음 + +기준 = 저장본 없이 지금 B06 정본으로 그린 원본(떠 두지 않음 · 앞 단계 저장 = 저절로 새 기준). +지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 · json 남김 = [원본에서 다시 그리기] 꼴) · +B07 이 확정으로 올린 측점 설계 → 잠정 · 설정 파일(축척 · 횡단 배치 · 표 배율 · 평면도 종류 · 표식). +M02 층(도각 · 표지)은 안 건드림. 단계 = [저장]과 같음(`mark_stage` save). +""" + +import asyncio +import logging +import shutil +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B06_Section.B06_Section_Repository import merge_cross_section_design_by_round +from B07_DesignDetail.B07_DesignDetail_Engine_Template import drawing_scales_path +from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source, _designs_by_chainage +from B07_DesignDetail.B07_DesignDetail_Router_Support import CROSS_LAYOUT_FILE +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _design_root, + _read_manifest, + _write_manifest, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import _kinds_dir, _marks_path +from common_util.common_util_workflow_state import mark_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + + +def reset_design_files(project_root: Path) -> dict[str, Any]: + """저장본 풂 + 설정 파일 지움 → {released: 풀린 장 수, cleared: 지운 설정 이름}.""" + manifest = _read_manifest(project_root) + released = 0 + for entry in manifest["drawings"].values(): + if entry.get("confirmed"): + entry["confirmed"] = False + released += 1 + if released: + _write_manifest(project_root, manifest) + cleared = [] + for path in ( + drawing_scales_path(project_root), + _design_root(project_root) / CROSS_LAYOUT_FILE, + _marks_path(project_root), + ): + if path.is_file(): + path.unlink() + cleared.append(path.name) + if _kinds_dir(project_root).is_dir(): + shutil.rmtree(_kinds_dir(project_root)) + cleared.append(_kinds_dir(project_root).name) + return {"released": released, "cleared": cleared} + + +@router.post("/{project_id}/design-drawings/reset") +async def reset_design_drawings(project_id: UUID) -> Any: + try: + route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id) + result = await asyncio.to_thread(reset_design_files, project_root) + designs = await _designs_by_chainage(route_id) + confirmed = [chainage for chainage, d in designs.items() if d.get("status") == "confirmed"] + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + for chainage in confirmed: + await merge_cross_section_design_by_round( + connection, + route_id=route_id, + chainage=chainage, + patch={"status": "provisional"}, + ) + async with connection.cursor() as cursor: + await mark_stage(str(project_id), 4, "save", cursor) + await connection.commit() + except Exception: + await connection.rollback() + raise + return { + "status": "success", + "project_id": str(project_id), + **result, + "provisional": len(confirmed), + } + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 초기화 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "상세 설계를 초기화하지 못했습니다."}, + ) diff --git a/B08_Quantity/B08_Quantity_Router_Options.py b/B08_Quantity/B08_Quantity_Router_Options.py index de4c9c179..82ae34159 100644 --- a/B08_Quantity/B08_Quantity_Router_Options.py +++ b/B08_Quantity/B08_Quantity_Router_Options.py @@ -3,6 +3,13 @@ GET /api/projects/{id}/quantity/options 지금 값 PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀 POST /api/projects/{id}/quantity/complete [>] — 단계 5 완료 · 6 진행 중(요구 300 B1) + POST /api/projects/{id}/quantity/reset [초기화](요구 298-2 C1 · C3 · C4) — 사용자 값 · + 설정 지움 → 다음 GET = 지금 B05 · B06 저장본으로 셈한 기준 · 단계 = [저장]과 같음 + +[초기화] 지움 = `project_settings.json › quantity` 구획 통째(연암 · 할증 · 재료 칸 · 그 밖 → 기본) +· `hydro_inputs.json` · `milestone.json`(사용자 그림이 붙은 거래처 경로 줄만 남김 — 다음 [저장]이 +그림을 안 지우게 · 그 줄 `to` = 시점) · 남김 = 1본 길이 · 조합 연결(M02 층) · 올린 그림 파일 · +초안 넷 = 화면 몫. 1본 길이 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` 한 벌(98 `one_pipe_length` 이 읽는 자리 · M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 없으면 시스템 층 문서를 바탕으로 깔고 씀 · @@ -23,10 +30,21 @@ from uuid import UUID from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field +from B08_Quantity.B08_Quantity_Hydro_Site import SAVED_FILE as HYDRO_FILE from B08_Quantity.B08_Quantity_Material_Calc import PANEL_KEY, panel_surcharge +from B08_Quantity.B08_Quantity_Milestone_Calc import SITE from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Milestone import DATA_FILE as MILESTONE_FILE +from B08_Quantity.B08_Quantity_Router_Milestone import Milestone, images, read_data +from B08_Quantity.B08_Quantity_Router_Milestone import folder as milestone_folder from B08_Quantity.B08_Quantity_Router_Summary import ONE_LENGTH_VAR, TABLE_NAME, one_pipe_length -from common_util.common_util_project_settings import quantity_settings, save_section, soft_rock_pct +from common_util.common_util_json import atomic_write_json +from common_util.common_util_project_settings import ( + quantity_settings, + reset_section, + save_section, + soft_rock_pct, +) from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Template_Layers as layers @@ -113,3 +131,30 @@ async def quantity_complete(project_id: UUID) -> dict[str, Any]: await _project_root(project_id) # 없는 프로젝트는 404 await mark_stage(str(project_id), 5, "next") return {"status": "success", "project_id": str(project_id)} + + +def reset_quantity(root: str | Path) -> dict[str, Any]: + """[초기화] 파일 몫 — 지운 것 · 남긴 거래처 경로 줄 수를 옵션 꼴에 얹어 돌려줌.""" + root = Path(root) + reset_section(root, "quantity") + hydro = root / HYDRO_FILE + cleared_hydro = hydro.is_file() + hydro.unlink(missing_ok=True) + kept_routes = 0 + if (milestone_folder(root) / MILESTONE_FILE).is_file(): + pictured = set(images(root)) # 사용자 그림 = 하위 폴더 밖 `<경로 id>.<확장자>` + routes = [ + {**route, "to": SITE} for route in read_data(root)["routes"] if route["id"] in pictured + ] + kept_routes = len(routes) + data = Milestone.model_validate({"routes": routes}).model_dump() + atomic_write_json(milestone_folder(root) / MILESTONE_FILE, data) + return {**read_options(root), "cleared_hydro": cleared_hydro, "kept_routes": kept_routes} + + +@router.post("/{project_id}/quantity/reset") +async def quantity_reset(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + out = await asyncio.to_thread(reset_quantity, root) + await mark_stage(str(project_id), 5, "save") # [초기화] = 작업본 바꿈 = [저장]과 같음 + return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index a3e8ef66a..841f6caa3 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,10 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/complete [>] — 단계 6 완료 · 프로젝트 DONE(요구 300 B1 · 본문 없음) + POST /api/projects/{id}/estimation/reset + [초기화](요구 298-2 C1 · C3 · C4 · C5 · 301 · 본문 없음) — 사용자 값 전부(내역 · 자재 · + 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → + 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 POST /api/projects/{id}/estimation/prices {user?} [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 @@ -63,6 +67,7 @@ from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session +from common_util.common_util_project_settings import reset_section from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer @@ -231,6 +236,22 @@ async def estimation_complete(project_id: UUID) -> dict[str, Any]: return {"status": "success", "project_id": str(project_id)} +def reset_files(root: Path) -> None: + """[초기화] 파일 몫 — 사용자 값 · 단가 책 지움 · 운반 연결(B08 몫)이 안 잇는 서고 사본 지움.""" + (store.folder(root) / store.USER).unlink(missing_ok=True) + (store.folder(root) / store.BOOK).unlink(missing_ok=True) + library.prune(root, calc_user(root)) + reset_section(root, "estimation") + + +@router.post("/{project_id}/estimation/reset") +async def estimation_reset(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + await asyncio.to_thread(reset_files, root) + await mark_stage(str(project_id), 6, "save") # [초기화] = 작업본 바꿈 = [저장]과 같음 + return await payload(project_id, root) + + @router.post("/{project_id}/estimation/prices") async def refresh_prices( project_id: UUID, body: dict[str, Any] | None = Body(None) diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 4975baa94..c3688ebc7 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -251,6 +251,17 @@ def save_section( return settings +def reset_section(project_root: str | Path, section: str) -> None: + """한 구획을 기본값으로 — 페이지 [초기화](요구 298-2 C3) · 남의 구획 · 파일 없음 = 그대로.""" + if section not in SECTIONS: + raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})") + if not settings_path(project_root).exists(): + return + settings = load_settings(project_root) + settings[section] = default_settings()[section] + atomic_write_json(settings_path(project_root), settings) + + def quantity_settings(project_root: str | Path) -> dict[str, Any]: """B08 구획만 꺼낸다.""" return load_settings(project_root).get("quantity") or {} diff --git a/main.py b/main.py index f7448a5bb..b8135cf87 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cros from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router +from B07_DesignDetail.B07_DesignDetail_Router_Reset import router as b07_reset_router from B07_DesignDetail.B07_DesignDetail_Router_Support_Scale import router as b07_scale_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router @@ -656,6 +657,7 @@ app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) app.include_router(b07_scale_router, dependencies=protected_with_company) +app.include_router(b07_reset_router, dependencies=protected_with_company) app.include_router(b08_hydro_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs index b3b98a334..003add5f3 100644 --- a/resources/tester/helper_280_2_step_nav.cjs +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -16,6 +16,7 @@ class FakeNode { this.dataset = {}; this.listeners = {}; this.disabled = false; + this.isConnected = true; this.title = ""; this._text = ""; this._classes = new Set(); @@ -87,6 +88,16 @@ class CustomEvent { } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; globalThis.location = { hash: "" }; +// 주소 바뀜 듣개 — B05 ↔ B06 은 페이지를 다시 안 세우고 [<] [>] 켬 · 끔만 다시 맞춤(496-1) +const hashListeners = new Set(); +globalThis.window = { + addEventListener: (type, fn) => type === "hashchange" && hashListeners.add(fn), + removeEventListener: (type, fn) => type === "hashchange" && hashListeners.delete(fn), +}; +const goHash = (route) => { + location.hash = `#/${route}`; + [...hashListeners].forEach((fn) => fn()); +}; globalThis.CustomEvent = CustomEvent; globalThis.HTMLButtonElement = FakeNode; @@ -190,9 +201,9 @@ for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: row // ② [<] [>] = 이웃 단계 누름 const STEP_ROUTES = ALL.slice(0, 7); -function page(route, { stages, dev = false, module = layout } = {}) { +function page(route, { stages, dev = false, module = layout, current } = {}) { globalThis.__DEV = dev; - location.hash = `#/${route}`; + goHash(route); const calls = []; const handle = module.createWorkflowLayout({ title: route, @@ -201,18 +212,23 @@ function page(route, { stages, dev = false, module = layout } = {}) { mainContent: new FakeNode("div"), routes: STEP_ROUTES, stages, - currentStage: STEP_ROUTES.indexOf(route), + currentStage: current ?? STEP_ROUTES.indexOf(route), onStepClick: (index, to) => calls.push(`${index}:${to}`), }); - const press = (step) => { - toasts.length = 0; - const button = handle.root + const navButton = (step) => + handle.root .querySelectorAll(".ui-step-nav-btn") .find((b) => b.dataset.stepNav === String(step)); - button.click(); + const press = (step) => { + toasts.length = 0; + navButton(step).click(); return { calls: calls.splice(0), toasts: [...toasts] }; }; - return { handle, press }; + const stepButton = (index) => + handle.root.querySelectorAll(".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)")[ + index + ]; + return { handle, press, navButton, stepButton }; } (async () => { @@ -220,11 +236,16 @@ function page(route, { stages, dev = false, module = layout } = {}) { const b05 = page("b05-profile"); nav.b05Prev = b05.press(-1); nav.b05Next = b05.press(1); - // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) - location.hash = "#/b06-section"; + nav.prevDisabled = { b05: b05.navButton(-1).disabled }; + // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) · [<] 는 주소 따라 켬 + goHash("b06-section"); b05.handle.setActiveStep(3); + nav.prevDisabled.b06 = b05.navButton(-1).disabled; nav.b06Prev = b05.press(-1); nav.b06Next = b05.press(1); + goHash("b05-profile"); + b05.handle.setActiveStep(2); + nav.prevDisabled.back = b05.navButton(-1).disabled; const b09 = page("b09-estimation"); nav.b09Prev = b09.press(-1); nav.b09Next = b09.press(1); @@ -236,11 +257,32 @@ function page(route, { stages, dev = false, module = layout } = {}) { })); const blocked = page("b06-section", { stages }); nav.blockedNext = blocked.press(1); + // [저장] 뒤 서버 단계로 다시 칠함 — 시작함(IN_PROGRESS)이면 켬 + blocked.handle.setStages( + stages.map((s) => ({ ...s, state: s.stage_no === 4 ? "IN_PROGRESS" : s.state })), + ); + nav.repaintNext = blocked.press(1); + nav.repaintClass = blocked.stepButton(4).className; + // STALE = 색만 · 막지 않음(요구 300 B1) + const stale = page("b06-section", { + stages: stages.map((s) => ({ ...s, state: s.stage_no >= 4 ? "STALE" : s.state })), + }); + nav.staleNext = stale.press(1); + nav.staleClass = stale.stepButton(4).className; + // B05 · B06 화면 = 2 · 3 · 4 늘 켬(운영 · 체인 직후 NOT_STARTED 라도) · B08 은 막힘 + const chain = page("b06-section", { stages, current: [2, 3, 4] }); + nav.listNext = chain.press(1); + nav.listB08 = chain.stepButton(5).disabled; // 일반 사용자 — B05 [<](B04) = 관리자 전용 알림(역할은 모듈이 한 번 물어 기억 → 새로 읽음) role = "USER"; const fresh = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts")); - const user = page("b05-profile", { dev: true, module: fresh }); + const user = page("b06-section", { dev: true, module: fresh }); await new Promise((r) => setTimeout(r, 0)); + // B06 [<] = B05(같은 페이지) · B04 단계 단추 = 관리자 전용 잠금(다시 칠해도 남음) nav.userPrev = user.press(-1); + const lockOf = (b) => ({ disabled: b.disabled, title: b.title }); + nav.userB04 = lockOf(user.stepButton(1)); + user.handle.setStages(stages); + nav.userB04Repaint = lockOf(user.stepButton(1)); process.stdout.write(JSON.stringify({ rows, nav })); })(); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 3f05a78a4..e8a83b677 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -123,7 +123,6 @@ function html(node) { const LOCALES = { Common_Btn_Reset: ["초기화", "Reset"], B06_Profile_Btn_Save: ["저장", "Save"], - B06_Profile_Btn_Confirm: ["확정", "Confirm"], Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2) }; const STUBS = { @@ -203,7 +202,6 @@ const built = panel.createDesignPanel( dock: { reset: { run: run("reset") }, save: { run: run("save") }, - confirm: { run: run("confirm") }, }, }, "profile", @@ -232,6 +230,7 @@ function snapshot() { dockIsLast: built.root.children[built.root.children.length - 1] === dock, dockLabels: buttons.map((b) => b.textContent), dockActions: buttons.map((b) => b.dataset.dockAction), + dockClasses: buttons.map((b) => b.className), dockDisabled: buttons.map((b) => b.disabled), }; } diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py index 5d95793fe..7693b2b47 100644 --- a/resources/tester/test_280_2_step_nav.py +++ b/resources/tester/test_280_2_step_nav.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """요구 280-2 · PLAN 481-1 — 좌측 아래 [?] [<] [>] 페이지 이동. -① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B09 [>] 흐림 -② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계 = 누르지 않고 그 단추 title 알림 -③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · B05 · B06 도크 = [초기화] [저장] [확정] 셋 +① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B05 [<] · B09 [>] 흐림 +② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계(NOT_STARTED 만) = 누르지 않고 그 단추 title 알림 +③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · 주소 바뀌면 [<] 다시 맞춤 · 도크 = [저장] [초기화] 둘(496-1) """ import json @@ -40,15 +40,15 @@ def test_다섯_주소만_물음_앞뒤_차례(results, shape): assert results["rows"][route][shape]["labels"] == ["?", "저장"], route -def test_B09_다음만_흐림(results): +def test_B05_앞_B09_다음만_흐림(results): for route in FIVE: - expected = [False, False, route == "b09-estimation", False] + expected = [False, route == "b05-profile", route == "b09-estimation", False] assert results["rows"][route]["helpRow"]["disabled"] == expected, route def test_앞뒤는_이웃_단계_누름(results): nav = results["nav"] - assert nav["b05Prev"] == {"calls": ["1:b04-preprocess"], "toasts": []} + assert nav["b05Prev"] == {"calls": [], "toasts": []} # 흐림 — 누름 안 감(496-1) assert nav["b05Next"] == {"calls": ["3:b06-section"], "toasts": []} # B05 → B06 은 같은 페이지 — setActiveStep 으로 켜진 칸이 바뀐 뒤 이웃 assert nav["b06Prev"] == {"calls": ["2:b05-profile"], "toasts": []} @@ -57,10 +57,32 @@ def test_앞뒤는_이웃_단계_누름(results): assert nav["b09Next"] == {"calls": [], "toasts": []} +def test_B05_앞_흐림은_주소_따라_다시_맞춤(results): + assert results["nav"]["prevDisabled"] == {"b05": True, "b06": False, "back": True} + + def test_막힌_단계는_알림만(results): nav = results["nav"] assert nav["blockedNext"] == {"calls": [], "toasts": ["WF_State_NotStarted"]} - assert nav["userPrev"] == {"calls": [], "toasts": ["시스템 관리자 전용 단계입니다."]} + assert nav["userPrev"] == {"calls": ["2:b05-profile"], "toasts": []} + lock = {"disabled": True, "title": "시스템 관리자 전용 단계입니다."} + assert nav["userB04"] == lock and nav["userB04Repaint"] == lock + + +def test_STALE_은_색만_막지_않음(results): + nav = results["nav"] + assert nav["staleNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert "state-stale" in nav["staleClass"].split() + + +def test_다시_칠하기와_켤_칸_목록(results): + nav = results["nav"] + assert nav["repaintNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert "state-in_progress" in nav["repaintClass"].split() + assert "state-not_started" not in nav["repaintClass"].split() + # B05 · B06 화면 = 2 · 3 · 4 늘 켬 — 운영 · 체인 직후 B07 NOT_STARTED 라도 [>] 가 감 · B08 은 막힘 + assert nav["listNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert nav["listB08"] is True def test_B05_B06_사이는_주소만_다시_안_실음(): @@ -70,6 +92,10 @@ def test_B05_B06_사이는_주소만_다시_안_실음(): shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") for text in (dock, shared): assert "switchView" not in text - assert "row.append(resetButton, saveButton, confirmButton);" in dock + assert "row.append(saveButton, resetButton);" in dock + assert "confirmButton" not in dock and 'variant: "danger-ghost"' in dock + assert "currentStage: [2, 3, 4]" in page + assert "else if (stepIndex >= 4) confirmAndGo(shared, route);" in page locale = (ROOT / "ui_template" / "ui_template_locale_b2.ts").read_text(encoding="utf-8") assert "Design_Btn_ToCross" not in locale and "Design_Btn_ToProfile" not in locale + assert "B06_Profile_Btn_Confirm" not in locale diff --git a/resources/tester/test_298_2_page_reset.py b/resources/tester/test_298_2_page_reset.py new file mode 100644 index 000000000..2b8656e54 --- /dev/null +++ b/resources/tester/test_298_2_page_reset.py @@ -0,0 +1,228 @@ +"""요구 298-2 · 300 C1 · C3 · C4 · C5 — B07 · B08 · B09 [초기화] 서버 창구(PLAN 497-0). + +셋 다: 지운 것(사용자 값 · 설정) · 남긴 것(도면번호 · 올린 파일 · 남의 구획) · 단계(= [저장]) · +두 번 눌러도 같음. 임시 storage · 단계 표는 메모리 흉내(`test_298_2_stage_state`). +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from uuid import uuid4 + +from test_298_2_stage_state import _Pool, _Table + +from common_util import common_util_project_settings as ps + +C, P, S = "COMPLETE", "IN_PROGRESS", "STALE" + + +def _write(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _read(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def _mark_calls(monkeypatch, mod, root: Path) -> list: + calls = [] + + async def project_root(_pid): + return root + + async def mark(_project_id, no, action, cursor=None): + calls.append((no, action)) + + monkeypatch.setattr(mod, "_project_root", project_root) + monkeypatch.setattr(mod, "mark_stage", mark) + return calls + + +# ── B07 ───────────────────────────────────────────── + + +def _b07_root(tmp_path: Path) -> Path: + b07 = tmp_path / "B07_DesignDetail" + drawings = { + "cross_sheet_1": {"kind": "cross", "confirmed": True, "number": 7}, + "longitudinal": {"kind": "longitudinal", "confirmed": True, "number": 3}, + "mass_haul": {"kind": "mass_haul", "confirmed": False, "number": 9}, + } + _write(b07 / "manifest.json", {"drawings": drawings}) + _write(b07 / "drawings" / "cross_sheet_1.json", {"entities": [], "layers": []}) + _write(b07 / "drawing_scales.json", {"cross": {"x": 200, "y": 200}}) + _write(b07 / "cross_layout.json", {"default": [3, 3], "sheets": {}, "table_scale": 1.5}) + _write(b07 / "plan_marks.json", {"marks": [{"kind": "shelter"}]}) + _write(b07 / "plan_kinds" / "k1.json", {"name": "사용자 종류", "pieces": []}) + return tmp_path + + +def test_b07_reset(monkeypatch, tmp_path): + from B07_DesignDetail import B07_DesignDetail_Router_Reset as reset + + root = _b07_root(tmp_path) + designs = {10: {"status": "confirmed"}, 20: {"status": "provisional"}, 30: {}} + merged = [] + table = _Table([C, C, C, C, C, C, P]) + + async def source(_pid): + return 1, root, root / "l.json", False + + async def by_chainage(_route_id): + return designs + + async def merge(_connection, *, route_id, chainage, patch): + merged.append((chainage, patch["status"])) + designs[chainage] = {**designs[chainage], **patch} + + monkeypatch.setattr(reset, "_confirmed_source", source) + monkeypatch.setattr(reset, "_designs_by_chainage", by_chainage) + monkeypatch.setattr(reset, "merge_cross_section_design_by_round", merge) + monkeypatch.setattr(reset, "get_db_pool", lambda: _Pool(table)) + + out = asyncio.run(reset.reset_design_drawings(uuid4())) + assert out["status"] == "success" + assert out["released"] == 2 and out["provisional"] == 1 + assert sorted(out["cleared"]) == sorted( + ["drawing_scales.json", "cross_layout.json", "plan_marks.json", "plan_kinds"] + ) + manifest = _read(root / "B07_DesignDetail" / "manifest.json")["drawings"] + assert not any(entry["confirmed"] for entry in manifest.values()) + assert {k: v["number"] for k, v in manifest.items()} == { + "cross_sheet_1": 7, + "longitudinal": 3, + "mass_haul": 9, + } + assert (root / "B07_DesignDetail" / "drawings" / "cross_sheet_1.json").is_file() + assert not (root / "B07_DesignDetail" / "plan_kinds").exists() + assert merged == [(10, "provisional")] + assert table.states == [C, C, C, C, P, S, S] + + again = asyncio.run(reset.reset_design_drawings(uuid4())) + assert (again["released"], again["provisional"], again["cleared"]) == (0, 0, []) + assert _read(root / "B07_DesignDetail" / "manifest.json")["drawings"] == manifest + assert table.states == [C, C, C, C, P, S, S] + + +def test_b07_reset_wired(): + main = (Path(__file__).parents[2] / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b07_reset_router, dependencies=protected_with_company)" in main + + +# ── B08 ───────────────────────────────────────────── + + +def _b08_root(tmp_path: Path) -> Path: + ps.save_section( + tmp_path, + "quantity", + { + "rock_ratios_pct": {"연암": 70, "경암": 30}, + "material_surcharge_pct": {"sand": 20}, + "material_sheets": {"units": {"x": 1}}, + "topsoil_thickness_m": 0.3, + }, + ) + ps.save_section(tmp_path, "estimation", {"rate_dataset": "keep"}) + _write(tmp_path / "B08_Quantity" / "quantities" / "hydro_inputs.json", {"stations": {"0": {}}}) + milestone = tmp_path / "B08_Quantity" / "milestone" + _write( + milestone / "milestone.json", + { + "site": {"mid_station_m": 120, "mid_road": "비포장"}, + "bases": [{"id": "b1", "name": "기준점"}], + "routes": [ + {"id": "r1", "name": "그림 업체", "to": "b1", "rows": [{"item": "골재"}]}, + {"id": "r2", "name": "그림 없는 업체"}, + ], + }, + ) + (milestone / "r1.png").write_bytes(b"png") + (milestone / "auto").mkdir() + (milestone / "auto" / "r2.png").write_bytes(b"png") + return tmp_path + + +def test_b08_reset(monkeypatch, tmp_path): + from B08_Quantity import B08_Quantity_Router_Milestone as milestone + from B08_Quantity import B08_Quantity_Router_Options as options + + root = _b08_root(tmp_path) + calls = _mark_calls(monkeypatch, options, root) + + out = asyncio.run(options.quantity_reset(uuid4())) + assert out["rock_ratios_pct"] == {"연암": 90.0, "경암": 10.0} + assert out["material_surcharge_pct"] == options.panel_surcharge(None) + assert out["cleared_hydro"] is True and out["kept_routes"] == 1 + settings = ps.load_settings(root) + assert settings["quantity"] == ps.default_settings()["quantity"] + assert settings["estimation"]["rate_dataset"] == "keep" # 남의 구획 + assert not (root / "B08_Quantity" / "quantities" / "hydro_inputs.json").exists() + data = milestone.read_data(root) + assert data["bases"] == [] and data["site"]["mid_station_m"] is None + assert [(r["id"], r["to"], r["name"]) for r in data["routes"]] == [("r1", "site", "그림 업체")] + assert calls == [(5, "save")] + + # C4 — 다음 [저장](그 GET 꼴 그대로)이 올린 그림을 안 지움 + milestone.save_data(root, milestone.Milestone.model_validate(data)) + assert (root / "B08_Quantity" / "milestone" / "r1.png").read_bytes() == b"png" + + again = asyncio.run(options.quantity_reset(uuid4())) + assert again["kept_routes"] == 1 and again["cleared_hydro"] is False + assert milestone.read_data(root) == data + assert ps.load_settings(root) == settings + assert calls == [(5, "save"), (5, "save")] + + +def test_b08_reset_no_files(monkeypatch, tmp_path): + """한 번도 저장 안 한 프로젝트 = 파일 안 만듦.""" + from B08_Quantity import B08_Quantity_Router_Options as options + + _mark_calls(monkeypatch, options, tmp_path) + out = asyncio.run(options.quantity_reset(uuid4())) + assert out["kept_routes"] == 0 and out["cleared_hydro"] is False + assert list(tmp_path.iterdir()) == [] + + +# ── B09 ───────────────────────────────────────────── + + +def test_b09_reset(monkeypatch, tmp_path): + from B09_Estimation import B09_Estimation_Router as b09 + from B09_Estimation import B09_Estimation_Store as store + + root = tmp_path + v1 = store.folder(root) + _write(v1 / store.USER, {"estimate": {"duration_days": 90}, "bill": {"v": 2, "rows": []}}) + _write(v1 / store.BOOK, {"made": "t", "rows": {}}) + quote = store.quote_dir(root, "MT-1") / "견적.pdf" + quote.parent.mkdir(parents=True) + quote.write_bytes(b"pdf") + copy = v1 / "library" / "combo@system@0@abc" + copy.mkdir(parents=True) + ps.save_section(root, "estimation", {"rate_dataset": "old"}) + ps.save_section(root, "quantity", {"topsoil_thickness_m": 0.3}) + calls = _mark_calls(monkeypatch, b09, root) + + async def payload(_pid, _root, draft=None): + return {"user": store.read_user(_root)} + + monkeypatch.setattr(b09, "payload", payload) + + out = asyncio.run(b09.estimation_reset(uuid4())) + assert out["user"]["estimate"] == {} and "bill" not in out["user"] + assert out["user"]["materials"] == {"MT-1": {"files": ["견적.pdf"]}} # 견적서 남음 + assert not (v1 / store.USER).exists() and not (v1 / store.BOOK).exists() + assert quote.read_bytes() == b"pdf" + assert not copy.exists() + settings = ps.load_settings(root) + assert settings["estimation"] == ps.default_settings()["estimation"] + assert settings["quantity"]["topsoil_thickness_m"] == 0.3 # 남의 구획 + assert calls == [(6, "save")] + + again = asyncio.run(b09.estimation_reset(uuid4())) + assert again == out and ps.load_settings(root) == settings + assert calls == [(6, "save"), (6, "save")] diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 8683f8af3..6fa959d27 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -93,7 +93,9 @@ def test_두_페이지가_저장_한_줄기를_탄다(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert shared.count("await saveDesign(") == 1, "저장 한 줄기를 한 자리에서만 부름" - assert "saveSharedDesign(shared, true)" in shared, "[확정]이 한 줄기를 안 탐" + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert "saveSharedDesign(shared, true, route)" in shared, "확정(B06 [>])이 한 줄기를 안 탐" + assert "confirmAndGo(shared, route)" in page, "B06 [>] · 단계 판 B07~B09 가 확정을 안 탐(496-1)" assert 'await shared.ensureView("cross")' in shared, "종단 보기 [확정]이 횡단 자료를 못 얻음" for name, text in (("종단 보기", actions), ("횡단 보기", persist), ("한 벌", shared)): for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index f1518bd4f..e13be7c44 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -87,18 +87,19 @@ def test_보기마다_제_칸만_보인다(results): assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"] -def test_아래_단추는_세_개_보기_전환_없음(results): - """[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2)""" +def test_아래_단추는_둘_보기_전환_없음(results): + """[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2) · [확정] 지움 = B06 [>](496-1)""" views = results["views"] for name in ("profile", "cross", "back"): - assert views[name]["dockActions"] == ["reset", "save", "confirm"] - assert views[name]["dockLabels"] == ["초기화", "저장", "확정"] - # [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음). - assert views[name]["dockDisabled"] == [False, False, False] + assert views[name]["dockActions"] == ["save", "reset"] + assert views[name]["dockLabels"] == ["저장", "초기화"] + assert views[name]["dockDisabled"] == [False, False] + # [초기화] = 테두리 · 글만 빨강(danger-ghost) · [저장] = ghost + assert views[name]["dockClasses"] == ["ui-btn ui-btn--ghost", "ui-btn ui-btn--danger-ghost"] def test_단추는_한_벌_동작을_부름(results): - names = ["reset", "save", "confirm"] + names = ["save", "reset"] assert results["calls"] == [ *(f"profile:{n}" for n in names), *(f"cross:{n}" for n in names), diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 6ed57ae36..6911020ba 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -23,7 +23,7 @@ export { el } from "./ui_template_elements_base"; * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav * -------------------------------------------------------------------------- */ -export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass"; +export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass"; export type ButtonSize = "md" | "sm"; export interface ButtonOptions { diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index ec674b701..7ac8d386d 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -67,6 +67,17 @@ const BASE_CSS = ` color: var(--color-canvas); } +/* [초기화] 꼴 — 테두리 · 글만 빨강 · 올리면 채움 */ +.ui-btn--danger-ghost { + background-color: transparent; + border-color: var(--color-danger); + color: var(--color-danger); +} +.ui-btn--danger-ghost:hover:not(:disabled) { + background-color: var(--color-danger); + color: var(--color-canvas); +} + /* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ .ui-btn--glass { border-color: color-mix(in srgb, var(--color-border) 65%, transparent); diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index c7ca4d3f6..c8d7f13ec 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -173,7 +173,7 @@ export function createHelpButton(): HTMLButtonElement { return button; } -function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement { +function createStepNavButton(step: -1 | 1): HTMLButtonElement { const button = createButton({ label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next, variant: "ghost", @@ -183,18 +183,33 @@ function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement button.classList.add("ui-step-nav-btn"); button.dataset.stepNav = String(step); button.title = button.ariaLabel = step < 0 ? STEP_TEXT.prevTip : STEP_TEXT.nextTip; - button.disabled = disabled; return button; } +/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */ +function stepNavIndex(): number { + const route = location.hash.replace(/^#\/?/, "").split("?")[0]; + return (STEP_NAV_ROUTES as readonly string[]).indexOf(route); +} + /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; const row = actions.querySelector("[data-help-row]") ?? actions; - const route = location.hash.replace(/^#\/?/, "").split("?")[0]; - const index = (STEP_NAV_ROUTES as readonly string[]).indexOf(route); - if (index < 0) return void row.prepend(createHelpButton()); - // B09 = 마지막 단계 — [>] 흐림 - const last = index === STEP_NAV_ROUTES.length - 1; - row.prepend(createHelpButton(), createStepNavButton(-1, false), createStepNavButton(1, last)); + if (stepNavIndex() < 0) return void row.prepend(createHelpButton()); + const prev = createStepNavButton(-1); + const next = createStepNavButton(1); + // 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤 + const sync = (): void => { + const index = stepNavIndex(); + prev.disabled = index <= 0; + next.disabled = index === STEP_NAV_ROUTES.length - 1; + }; + const onHash = (): void => { + if (prev.isConnected) sync(); + else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂 + }; + sync(); + window.addEventListener("hashchange", onHash); + row.prepend(createHelpButton(), prev, next); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index fbeddc50c..6756de0d4 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -191,14 +191,12 @@ export const ui_locales_b2 = { B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], - /* 2026-08-08 재정의 — 확정은 종+횡 통합(stage 2·3 함께 완료), 종단 이동은 저장 없이 B05로. */ - B06_Profile_Btn_Confirm: ["확정", "Confirm"], B06_Profile_Btn_GoProfile: ["종단 이동", "Go to Profile"], /* 저장 — 확정과 같은 내용을 저장하되 경로 상태·워크플로 단계는 건드리지 않는다. */ B06_Profile_Btn_Save: ["저장", "Save"], B06_Profile_Btn_Save_Tip: [ - "지금까지 편집한 내용을 확정하지 않고 저장합니다. 페이지는 그대로 있습니다.", - "Saves the current edits without confirming. You stay on this page.", + "지금까지 편집한 내용을 저장합니다. 페이지는 그대로 있습니다 — 확정은 B06 [>] 로 B07 에 넘어갈 때.", + "Saves the current edits. You stay on this page — B06 [>] confirms and moves to B07.", ], B06_Profile_Save_Success: ["편집 내용을 저장했습니다.", "Changes saved."], B06_Profile_Save_Failed: ["저장에 실패했습니다.", "Failed to save."], @@ -405,8 +403,8 @@ export const ui_locales_b2 = { B06_Profile_View_ZoomOut: ["축소", "Zoom out"], B06_Profile_View_ZoomFit: ["원래 크기", "Fit"], B06_MassHaul_Balloon_Reset_Tip: [ - "끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.", - "Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.", + "끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. B06 [>] 로 B07 에 넘어가면(확정) 저장된 위치도 함께 지워집니다.", + "Restores dragged mass-haul labels to automatic placement. Moving to B07 with B06 [>] (confirm) also clears the stored positions.", ], /* --- B06 측점 표준횡단 설계 지정 --- */ @@ -1056,4 +1054,15 @@ export const ui_locales_b2 = { "Calculation unstable — inputs and results did not settle in three rounds", ], Design_Busy: ["계산 중", "Calculating"], + /* B06 [>] = 확정 뒤 B07(496-1) */ + Design_Next_NotReady: [ + "자료를 불러오는 중입니다 — 다 뜬 뒤 다시 누르세요.", + "Still loading — press again when the page is ready.", + ], + Design_Route_Moved: [ + "노선이 바뀌었음 — 새 노선과 고친 값은 저장됨. 새 노선을 보고 머물거나, 새 노선으로 확정한 뒤 B07 로 넘어감.", + "The route changed — the new route and edits are saved. Stay to review it, or confirm with the new route and go to B07.", + ], + Design_Route_Moved_Go: ["B07 로", "Go to B07"], + Design_Route_Moved_Stay: ["머묾", "Stay"], } as const satisfies Record; diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index a8bab71f7..21506dae8 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -27,7 +27,8 @@ export interface WorkflowLayoutOptions { leftPanel?: HTMLElement; mainContent: HTMLElement; stages?: WorkflowStage[]; - currentStage?: number; + /** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */ + currentStage?: number | readonly number[]; routes?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; } @@ -39,11 +40,13 @@ export interface WorkflowLayoutHandle { /** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */ setTitle: (title: string) => void; setActiveStep: (stepIndex: number) => void; + /** [저장] · [>] 뒤 서버 단계 상태로 색 · 켬을 다시 맞춘다(496-1). */ + setStages: (stages: WorkflowStage[]) => void; } export interface StepBarOptions { stages?: WorkflowStage[]; - currentStage?: number; + currentStage?: number | readonly number[]; routes?: readonly string[]; compact?: boolean; orientation?: "horizontal" | "vertical"; @@ -53,6 +56,8 @@ export interface StepBarOptions { homeButton?: boolean; } +type StepBarElement = HTMLElement & { repaintSteps?: (stages: WorkflowStage[]) => void }; + export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "∑", "🏗️", "📄"] as const; export function createStepBar( @@ -62,6 +67,9 @@ export function createStepBar( ): HTMLElement { const bar = document.createElement("div"); bar.className = "ui-workflow-layout__steps"; + // 단계 단추 다시 칠하기(`setStages`) — 단추 차례대로 + const painters: Array<(stages: WorkflowStage[]) => void> = []; + (bar as StepBarElement).repaintSteps = (stages) => painters.forEach((paint) => paint(stages)); if (options?.compact) bar.classList.add("is-compact"); if (options?.orientation === "vertical") bar.classList.add("is-vertical"); // 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자 @@ -101,59 +109,74 @@ export function createStepBar( button.textContent = step; } - const stage = - options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index]; - const hasStages = Boolean(options?.stages?.length); - const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE"; - // ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시). - // B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못 - // 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴). - // ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고, - // 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다. - // ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다. - const devBypass = import.meta.env.DEV; - const isEnabled = - devBypass || !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage; - button.classList.toggle("is-enabled", isEnabled); - button.disabled = !isEnabled; + const currents = ([] as number[]).concat(options?.currentStage ?? []); + let adminOnly = false; // B04 = 시스템 관리자만 + const paint = (stages: WorkflowStage[] | undefined): void => { + const stage = stages?.find((item) => item.stage_no === index) ?? stages?.[index]; + const hasStages = Boolean(stages?.length); + // 막힘 = 시작 전만 — STALE(앞 단계 바뀜)은 색만 「다시 확인」 · 막지 않음(요구 300 B1) + const isBlockedState = stage?.state === "NOT_STARTED"; + // ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시). + // B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못 + // 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴). + // ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고, + // 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다. + // ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다. + const devBypass = import.meta.env.DEV; + const isEnabled = + !adminOnly && + (devBypass || !hasStages || !isBlockedState || currents.includes(stage?.stage_no ?? -1)); + button.classList.toggle("is-enabled", isEnabled); + button.disabled = !isEnabled; + button.title = ""; + + // state 기반 스타일링 (표시 전용) + button.className + .split(/\s+/) + .filter((name) => name.startsWith("state-")) + .forEach((name) => button.classList.remove(name)); + button.classList.remove("is-dev-bypass"); + if (stage) { + const state = stage.state; + button.classList.add(`state-${state.toLowerCase()}`); + if (state === "STALE") { + button.title = t("WF_State_Stale"); + } else if (state === "FAILED") { + button.title = t("WF_State_Failed"); + } else if (state === "COMPLETE") { + button.title = t("WF_State_Complete"); + } else if (state === "IN_PROGRESS") { + button.title = t("WF_State_InProgress"); + } else { + button.title = t("WF_State_NotStarted"); + } + } + + // 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이 + // 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻). + if (devBypass && isBlockedState && !adminOnly) { + button.classList.add("is-dev-bypass"); + button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`; + } + if (adminOnly) button.title = "시스템 관리자 전용 단계입니다."; + }; + let painted = options?.stages; + paint(painted); + painters.push((stages) => paint((painted = stages))); if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) { void getDashboardRole() .then((role) => { if (role !== "SYSTEM_ADMIN") { - button.classList.remove("is-enabled"); - button.disabled = true; - button.title = "시스템 관리자 전용 단계입니다."; + adminOnly = true; + paint(painted); } }) .catch(() => undefined); } - // state 기반 스타일링 (표시 전용) - if (stage) { - const state = stage.state; - button.classList.add(`state-${state.toLowerCase()}`); - if (state === "STALE") { - button.title = t("WF_State_Stale"); - } else if (state === "FAILED") { - button.title = t("WF_State_Failed"); - } else if (state === "COMPLETE") { - button.title = t("WF_State_Complete"); - } else if (state === "IN_PROGRESS") { - button.title = t("WF_State_InProgress"); - } else { - button.title = t("WF_State_NotStarted"); - } - } - - // 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이 - // 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻). - if (devBypass && isBlockedState) { - button.classList.add("is-dev-bypass"); - button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`; - } - - if (isEnabled && options?.routes?.[index]) { + // 꺼진 단추는 누름이 안 감 — 켬 · 끔이 뒤에 바뀌어도 듣개 하나 + if (options?.routes?.[index]) { button.addEventListener("click", () => { options.onStepClick?.(index, options.routes![index]); }); @@ -225,5 +248,6 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }, setActiveStep: (stepIndex) => steps.forEach((step, index) => step.classList.toggle("is-active", index === stepIndex)), + setStages: (stages) => (progressContent as StepBarElement).repaintSteps?.(stages), }; }