diff --git a/E02_StabGround/E02_StabGround_Chain.py b/E02_StabGround/E02_StabGround_Chain.py index 9c75c7f8c..31344ea71 100644 --- a/E02_StabGround/E02_StabGround_Chain.py +++ b/E02_StabGround/E02_StabGround_Chain.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import Any from uuid import UUID +from common_util.common_util_workflow_state import mark_stab_step + logger = logging.getLogger(__name__) #: 단계 — (열쇠, 이름, 비율 몫) · 몫 = 1.2 km 실측 시간 비(2D 1~3 분 · 3D 건기 · 우기 약 25 분) @@ -54,8 +56,14 @@ def idle() -> dict[str, Any]: } -def _mark_stage(project_id: str, key: str, state: str) -> None: - """대시보드 단계 상태 — ponytail: 지금은 안 적음 · 565-1 함수가 들어오면 여기서 부름.""" +async def _mark_stage(project_id: str, key: str, state: str, message: str | None = None) -> None: + """대시보드 단계 상태(565-1) — 적기 실패는 체인을 막지 않음.""" + if not project_id: + return + try: + await mark_stab_step(project_id, key, state, message) + except Exception: # noqa: BLE001 + logger.exception("E02 체인 단계 상태 적기 실패: %s %s", key, state) async def run_chain( @@ -70,10 +78,11 @@ async def run_chain( if stopped(): job.update(cancelled=True, message="끊음") row["state"] = "cancel" + await _mark_stage(project_id, key, "cancel") break job.update(stage=key, stage_name=name, message=name) row["state"] = "run" - _mark_stage(project_id, key, "run") + await _mark_stage(project_id, key, "run") def report(frac: float, message: str, base: float = base, weight: float = weight) -> None: frac = min(1.0, max(0.0, float(frac))) @@ -92,7 +101,8 @@ async def run_chain( errors.append(f"{name}: {getattr(error, 'detail', None) or error}") finally: row["seconds"] = round(time.perf_counter() - started, 1) - _mark_stage(project_id, key, row["state"]) + failed = errors[-1] if row["state"] == "error" else None + await _mark_stage(project_id, key, row["state"], failed) base += weight job["percent"] = min(99, int(100 * base / total)) job["error"] = " · ".join(errors) or None @@ -266,6 +276,7 @@ def start(project_id: UUID, project_root: Path) -> dict[str, Any]: async def work() -> None: try: + await _mark_stage(pid, "ground", "done") # 지반 = 길에서 저장을 마친 뒤 시작 await run_chain(job, steps, lambda: _STOPS.get(pid, False), pid) finally: job["running"] = False diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index a32bae7c5..16f0d420d 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -4,7 +4,8 @@ * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 - * 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀). + * 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = PUT …/stab/ground(beforeLeave) · + * [초기화] = POST …/reset(초기값 덮어씀). * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ @@ -138,7 +139,11 @@ export function renderE02StabGround(root: HTMLElement): void { runBox, actions, ); - mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); + mountGroupStep(root, ROUTES.E02_STAB_GROUND, { + mainContent: main, + leftPanel: panel, + beforeLeave: async () => !view?.editable || !dirty || store(), + }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); let view: E02View | null = null; @@ -257,17 +262,42 @@ export function renderE02StabGround(root: HTMLElement): void { render(); }; - /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ - async function runChain(): Promise { - if (!view?.editable || running) return; + /** 틀린 칸이 있으면 첫 까닭을 알리고 false */ + function valid(): boolean { const errors = mark(); - if (errors.length) { + if (view && errors.length) { showToast( `${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`, "error", ); - return; } + return errors.length === 0; + } + + /** 떠날 때(560-4 beforeLeave) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */ + async function store(): Promise { + if (!view || !projectId || !valid()) return false; + showLoadingOverlay(); + try { + await request(`/projects/${projectId}/stab/ground`, { + method: "PUT", + body: JSON.stringify({ ground: view.ground }), + }); + sessionStorage.removeItem(cacheKey(projectId)); + dirty = false; + showToast(t("E02_Saved"), "success"); + return true; + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return false; + } finally { + hideLoadingOverlay(); + } + } + + /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ + async function runChain(): Promise { + if (!view?.editable || running || !valid()) return; showLoadingOverlay(); try { const body = await request(`/projects/${projectId}/stab/run`, { diff --git a/resources/tester/test_564_1_e02_chain.py b/resources/tester/test_564_1_e02_chain.py index edeb43f8f..a49e6f496 100644 --- a/resources/tester/test_564_1_e02_chain.py +++ b/resources/tester/test_564_1_e02_chain.py @@ -231,3 +231,25 @@ def test_틀린_지반은_시작_안_함(tmp_path, monkeypatch): with pytest.raises(HTTPException) as bad: asyncio.run(router.run_chain(PID, router.GroundRequest(ground=ground))) assert bad.value.status_code == 422 and not chain._JOBS.get(str(PID)) + + +def test_단계_상태_적기(monkeypatch): + marks = [] + + async def fake_mark(pid, step, state, message=None): + marks.append((step, state, message)) + if step == "rock": + raise RuntimeError("DB 끊김") # 적기 실패는 체인을 막지 않음 + + monkeypatch.setattr(chain, "mark_stab_step", fake_mark) + job = _Job({**chain.idle(), "running": True}) + steps = _fake([], states={"report": "skip"}, fail={"area"}) + asyncio.run(chain.run_chain(job, steps, lambda: False, "pid")) + assert [m[:2] for m in marks if m[1] != "run"] == [ + ("2d", "done"), + ("3d", "done"), + ("rock", "done"), + ("area", "error"), + ("report", "skip"), + ] + assert marks[0] == ("2d", "run", None) and "area 망가짐" in marks[7][2]