feat(564-1 · 564-3): 체인 단계 상태 = mark_stab_step(565-1) · E02 떠날 때 바뀐 값 저장(beforeLeave 560-4)

- 체인 단계마다 run → done · skip · error · cancel 을 대시보드 단계 행에 적음 · 지반 = 시작 때 done · 적기 실패는 체인을 막지 않음
- E02 beforeLeave — 고칠 수 있고 바뀐 값이 있으면 PUT /stab/ground 뒤 이동 · 틀린 칸 · 실패 = 머묾
- 시험 test_564_1 — 단계 상태 적기 차례 · 적기 실패 격리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 21:24:21 +09:00
co-authored by Claude Opus 5.5
parent 4860e11794
commit 75155c50b0
3 changed files with 74 additions and 11 deletions
+15 -4
View File
@@ -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
+37 -7
View File
@@ -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<void> {
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<boolean> {
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<void> {
if (!view?.editable || running || !valid()) return;
showLoadingOverlay();
try {
const body = await request<GroundResponse & RunProgress>(`/projects/${projectId}/stab/run`, {
+22
View File
@@ -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]