- 장 나눔·제원 입력 엔진과 기울기 판정 대상을 B08 로 옮김 - 창구를 /quantity/structure-sheets 로 옮기고 기초잡석 두께·지반 갈래를 함께 넘김 (옛 B07 창구는 두께를 안 넘겨 원단위 탭과 값이 갈렸음) - 구조물도 탭 조각 renderStructureSheets 신설 — 탭 등록은 브레인 몫이라 안 붙임 - B07 표준도 목록은 탭 배선 날까지 B08 엔진·창구를 불러 그대로 둠 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
224 lines
9.7 KiB
Python
224 lines
9.7 KiB
Python
"""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
|
|
|
|
|
|
async def section_modes_of(route_id: int) -> dict[float, str]:
|
|
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
|
|
|
|
⚠ 이것을 안 넘기면 판정이 통째로 「가를 근거 없음」으로 떨어져 **전 구조물이 종전값
|
|
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 **안 넘긴 것**이었다.
|
|
⚠ 표를 만드는 셈은 `section_modes_from_designs` 한 벌을 쓴다 — 부르는 쪽마다 다시
|
|
짜면 B08 과 갈린다.
|
|
⚠ 과도기 — 노선 id 로 부르는 쪽은 B07 표준도 도면뿐임.
|
|
탭 배선 뒤 B07 에서 표준도를 빼면 함께 지움.
|
|
"""
|
|
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
|
|
|
|
try:
|
|
designs = await run_with_connection(get_cross_section_designs, route_id)
|
|
except Exception:
|
|
logger.exception("B08 구조물도 단면유형 조회 실패: route_id=%s", route_id)
|
|
return {}
|
|
return section_modes_from_designs(designs)
|
|
|
|
|
|
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)
|