"""B08 구조물도(표준도) 라우터 — 장 목록 조회와 **제원 입력**. ⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」). 옛 자리 `B07_DesignDetail_Router_Standard.py` · 옛 주소 `/standard-sheets`. 표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` 칸(돌 종류·조달·뒷길이· 전면 기울기)을 그리는 화면이 없어(2026-09-09 실측) 그 자리를 여기가 맡는다. **장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸린다. ⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고 표·그림은 다음 조회에서 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장). """ import asyncio import logging from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) async def _project_root(project_id: UUID) -> str | None: """프로젝트 저장 폴더(절대경로). 못 찾으면 `None`.""" try: stored_path = await run_with_connection(get_project_storage_relative_path, project_id) return str(Path(resolve_stored_project_path(stored_path)).resolve()) except Exception: logger.exception("B08 구조물도 — 저장 폴더 조회 실패: project_id=%s", project_id) return None def project_structure_sheets( project_root: str, section_modes: dict[float, str] | None, ground_types: dict[float, str] | None = None, ) -> dict[str, Any]: """구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다. ⚠ 기초잡석 두께를 안 넘기면 산출 조건에서 바꿔도 **구조물도만 옛 두께(0.2)** 로 선다 (2026-09-13 이관 때 잡음 — 옛 B07 창구가 두께·지반 갈래를 안 넘겼음). ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. """ from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Router_Material import _collect_structures from common_util.common_util_project_settings import quantity_settings structures, names, skipped = _collect_structures(project_root) unit_table = build_unit_table( structures, names, section_modes, ground_types, quantity_settings(project_root).get("rubble_base_thickness_m"), ) payload = build_standard_sheets(unit_table, section_modes) # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. payload["skipped_structures"] = skipped return payload async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]: """조회·저장이 **같은 장 목록**을 보게 하는 한 문. 단면유형·지반 갈래는 원단위 탭 창구(`B08_Quantity_Router_Material`)를 그대로 씀. """ from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes modes = await _section_modes(project_id) ground = await _ground_types(project_id) return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground) def _not_found() -> JSONResponse: return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, ) class StandardSheetSpecRequest(BaseModel): """표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다.""" model_config = ConfigDict(extra="forbid") sheet_key: str base_revision: int = Field(ge=0) stone_kind: str | None = None stone_supply: str | None = None back_len_cm: int | str | None = None face_slope_ratio: float | str | None = None foundation: str | None = None stone_coeff_basis: str | None = None fill_concrete_mpa: str | None = None thickness_top_m: float | str | None = None thickness_bottom_m: float | str | None = None blinding_concrete: str | None = None @router.put("/{project_id}/quantity/structure-sheets/spec") async def put_structure_sheet_spec( project_id: UUID, payload: StandardSheetSpecRequest ) -> JSONResponse: """장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다. ⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고, 표·그림은 다음 조회에서 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장). """ from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( apply_spec, clean_spec, drop_unregistered, ) project_root = await _project_root(project_id) if project_root is None: return _not_found() try: sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] except Exception: logger.exception("B08 구조물도 제원 저장 실패(장 목록): project_id=%s", project_id) sheets = [] picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None) if picked is None: return JSONResponse( status_code=404, content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."}, ) member_ids = { str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id") } spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"})) # ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다. type_id = str(picked.get("type_id") or "") definition = structure_type_map().get(type_id) allowed = {field.key for field in definition.options} if definition else set() spec, missing = drop_unregistered(type_id, spec, allowed) notes.extend(missing) try: revision, stored = await asyncio.to_thread(load_structures, project_root) updated, changed = apply_spec(stored, member_ids, spec) new_revision = await asyncio.to_thread( save_structures, project_root, updated, base_revision=payload.base_revision ) except Exception as exc: logger.exception("B08 구조물도 제원 저장 실패: project_id=%s", project_id) return JSONResponse( status_code=409, content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"}, ) return JSONResponse( content={ "status": "success", "project_id": str(project_id), "revision": new_revision, "previous_revision": revision, "changed": changed, # 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재). "notes": notes, } ) @router.get("/{project_id}/quantity/structure-sheets") async def get_structure_sheets(project_id: UUID) -> JSONResponse: """구조물도(표준도) **장 목록 + 원단위 수량표**. ⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장). """ project_root = await _project_root(project_id) if project_root is None: return _not_found() try: payload = await _sheets_of(project_id, project_root) except Exception: logger.exception("B08 구조물도 전개 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, ) payload["status"] = "success" payload["project_id"] = str(project_id) return JSONResponse(content=payload)