Files
Aislo/B08_Quantity/B08_Quantity_Router_StructureSheet.py
T
eomsangdonandClaude Opus 5 2ffb913c36 fix(b08): 고친 식을 장 이름 대신 양식 + 프로젝트에 묶음 — 제원을 고쳐도 식이 따라감
- 저장 칸 structure_formula_overrides 의 키를 장 이름에서 양식 type_id 로 바꿈(브레인 판정, PLAN 10장)
- 구조물도·build_table 모두 type_id 로 찾음 · 같은 양식의 장이 모두 같은 고친 식을 받음
- 화면 안내에 「같은 양식의 장 모두에 걸림」 · 시험: 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식 남음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:02:50 +09:00

285 lines
12 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
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_StructureTemplate import apply_templates
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)
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
section_modes,
ground_types,
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
)
payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
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
class FormulaEdit(BaseModel):
"""고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**."""
model_config = ConfigDict(extra="forbid")
seq: int = Field(ge=1)
formula: str | None = Field(default=None, max_length=2000)
class StructureSheetFormulaRequest(BaseModel):
"""구조물도 장 하나에서 고친 식들(PLAN 3장 ⑤) — 저장은 그 장의 **양식 + 프로젝트** 단위."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
rows: list[FormulaEdit] = Field(max_length=200)
@router.put("/{project_id}/quantity/structure-sheets/formulas")
async def put_structure_sheet_formulas(
project_id: UUID, payload: StructureSheetFormulaRequest
) -> JSONResponse:
"""장 하나의 고친 식을 **산출 조건에 저장**하고 **서버가 같은 풀이기로 다시 푼 값**을 돌려줌.
⚠ 브라우저가 푼 값을 받아 적지 않음 — 식만 받고 값은 Node 로 다시 냄(판정 Ⓐ · 데이터 3층).
⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장).
⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임.
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
OVERRIDES_KEY,
load_template,
save_sheet_overrides,
)
from common_util.common_util_project_settings import quantity_settings, save_section
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
template = load_template(str((picked or {}).get("type_id") or "")) if picked else None
if picked is None or template is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
)
try:
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
# 장 이름이 아니라 **양식**에 묶음 — 제원을 고쳐도 식이 따라가고 같은 양식의 장이 모두 받음.
merged, changed = save_sheet_overrides(
current, str(picked["type_id"]), template, [row.model_dump() for row in payload.rows]
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
# ⚠ 통째로 갈아 끼움 — 병합이면 되돌린(지운) 식이 남음(`save_section` replace_keys).
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{OVERRIDES_KEY: merged},
replace_keys=[OVERRIDES_KEY],
)
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
fresh = next((s for s in sheets if s.get("key") == payload.sheet_key), None) or {}
rows = fresh.get("rows") or []
return JSONResponse(
content={
"status": "success",
"changed": changed,
"rows": rows,
# 막지 않고 알림 — 어느 줄이 왜 안 섰는지.
"errors": [f"{row['name']}: {row['error']}" for row in rows if row.get("error")],
}
)
@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)