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/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")]