feat(B05): 구조물 타입 레지스트리·정본 CRUD 백엔드 추가
임도 구조물 전체(구조물군 B~G)를 수동 배치할 수 있도록 백엔드 기반을 만든다. 타입 정의를 코드가 아닌 데이터(JSON 레지스트리)로 두어, 구조물 목록이 바뀌어도 코드를 고치지 않고 화면 폼까지 따라오게 했다. - B05_Profile_Structure_Types.json: 타입 레지스트리 정본 (B~G 32종 + 호환 4종). 배치형태(point/interval/site)·옵션 스키마·마크 스타일·drawing_views 메타 포함. - B05_Profile_Structures_Schema.py: 타입·인스턴스 Pydantic 모델. 배치형태별 위치 필드 배타 검증, side·offset_m·placement_source·status·revision. - B05_Profile_Structures_Repository.py: structures.json 단일 정본 읽기·쓰기. atomic_write_json 재사용, base_revision 불일치 시 충돌 예외, 설계 영향 변경만 후속 단계를 무효화하도록 판정하는 지문 비교. - B05_Profile_Structures_Migration.py: 기존 비정규 측점 이관. 배관은 pipe_points.json 정본이라 제외, 기성막이→기슭막이, 대피로→대피소 통합, 나머지는 이름을 살려 기타. 같은 입력이면 같은 결과. - B05_Profile_Structures_Router.py: GET /structure-types, 구조물 목록 조회·저장. 판번호 충돌 409, 미등록·타 정본 관리 타입 400, 저장 후 B06 이후 STALE 전파. 테스트 45건(tmp/tests) 통과. 기존 배관·배수유역·종단 편집 코드는 건드리지 않았다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트(2026-08-16)와 resources/knowledge/technical_info/01_임도 참조. enabled 기본 true(2026-08-16 사용자 확정 — 제외 표기 항목만 false). 배관은 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않는다.",
|
||||
"types": [
|
||||
{ "type_id": "pipe", "group": "A", "name": "배관(횡단배수관)", "placement": "point", "managed_by": "pipe_points", "style": { "color": "#4f8ef7", "abbr": "관" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [] },
|
||||
|
||||
{ "type_id": "ditch_side", "group": "B", "name": "측구(옆도랑)", "placement": "interval", "style": { "color": "#2eaadc", "abbr": "측" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["일반형(제형)", "L형", "U형", "J형"], "default": "일반형(제형)" },
|
||||
{ "key": "depth_cm", "label": "깊이", "input": "number", "unit": "cm", "default": 30 } ] },
|
||||
{ "type_id": "ditch_ridge", "group": "B", "name": "산마루측구", "placement": "interval", "style": { "color": "#2e8bdc", "abbr": "산" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "material", "label": "재료", "input": "select", "choices": ["콘크리트", "돌", "떼"], "default": "콘크리트" } ] },
|
||||
{ "type_id": "ditch_berm", "group": "B", "name": "소단측구", "placement": "interval", "style": { "color": "#2ec5dc", "abbr": "소" }, "drawing_views": ["cross_section", "quantity"], "options": [] },
|
||||
{ "type_id": "chute", "group": "B", "name": "도수로·산비탈수로", "placement": "interval", "style": { "color": "#1f9bb8", "abbr": "도" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "material", "label": "재료", "input": "select", "choices": ["떼", "돌", "콘크리트", "블록판", "벤치플륨관", "반원관"], "default": "콘크리트" },
|
||||
{ "key": "section", "label": "단면형", "input": "select", "choices": ["반달꼴", "U자형", "사다리꼴"], "default": "U자형" } ] },
|
||||
{ "type_id": "slope_drain", "group": "B", "name": "절토사면 배수로", "placement": "interval", "style": { "color": "#28b1a1", "abbr": "사" }, "drawing_views": ["cross_section", "quantity"], "options": [
|
||||
{ "key": "material", "label": "재료", "input": "select", "choices": ["떼", "돌"], "default": "돌" } ] },
|
||||
{ "type_id": "underdrain", "group": "B", "name": "맹암거", "placement": "interval", "style": { "color": "#1f7fb8", "abbr": "맹" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "note", "label": "규격", "input": "text", "default": "" } ] },
|
||||
|
||||
{ "type_id": "retaining_wall", "group": "C", "name": "옹벽(철근콘크리트)", "placement": "interval", "style": { "color": "#e07a2e", "abbr": "옹" }, "drawing_views": ["profile", "cross_section", "detail", "quantity"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"], "default": "반중력식" },
|
||||
{ "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 2.0 } ] },
|
||||
{ "type_id": "masonry_wet", "group": "C", "name": "돌쌓기(찰)", "placement": "interval", "style": { "color": "#d0692a", "abbr": "찰" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "height_m", "label": "높이(≤3.0)", "input": "number", "unit": "m", "default": 2.0 },
|
||||
{ "key": "back_len_cm", "label": "뒷길이", "input": "select", "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": "45" } ] },
|
||||
{ "type_id": "masonry_dry", "group": "C", "name": "돌쌓기(메)", "placement": "interval", "style": { "color": "#c25d2c", "abbr": "메" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "height_m", "label": "높이(≤2.0)", "input": "number", "unit": "m", "default": 1.5 },
|
||||
{ "key": "back_len_cm", "label": "뒷길이", "input": "select", "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": "45" } ] },
|
||||
{ "type_id": "soil_guard", "group": "C", "name": "흙막이", "placement": "interval", "style": { "color": "#b8742e", "abbr": "흙" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"], "default": "콘크리트" },
|
||||
{ "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 } ] },
|
||||
{ "type_id": "boulder_masonry", "group": "C", "name": "큰돌쌓기", "placement": "interval", "style": { "color": "#a86a2a", "abbr": "큰" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 },
|
||||
{ "key": "stone_cm", "label": "돌규격", "input": "select", "choices": ["40~60", "60~80", "80~100"], "default": "60~80" } ] },
|
||||
|
||||
{ "type_id": "erosion_check", "group": "D", "name": "골막이", "placement": "point", "style": { "color": "#7a52c7", "abbr": "골" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["돌", "돌망태", "콘크리트", "통나무", "바자", "흙"], "default": "돌" },
|
||||
{ "key": "length_m", "label": "길이", "input": "number", "unit": "m", "default": 4.5 },
|
||||
{ "key": "height_m", "label": "높이(≤2.0)", "input": "number", "unit": "m", "default": 2.0 } ] },
|
||||
{ "type_id": "check_dam_small", "group": "D", "name": "소형사방댐(복합형)", "placement": "point", "style": { "color": "#6b46b8", "abbr": "댐" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [
|
||||
{ "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 2.0 } ] },
|
||||
{ "type_id": "bed_sill", "group": "D", "name": "바닥막이", "placement": "point", "style": { "color": "#8a5cd0", "abbr": "바" }, "drawing_views": ["profile", "detail", "quantity"], "options": [
|
||||
{ "key": "height_m", "label": "높이(≤3.0)", "input": "number", "unit": "m", "default": 1.5 },
|
||||
{ "key": "apron", "label": "물받이", "input": "select", "choices": ["있음", "없음"], "default": "있음" } ] },
|
||||
{ "type_id": "revetment", "group": "D", "name": "기슭막이", "placement": "interval", "style": { "color": "#9b6bdc", "abbr": "기" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["돌쌓기(찰)", "돌쌓기(메)", "돌붙임", "콘크리트", "돌망태", "통나무·목재틀", "바자"], "default": "돌쌓기(찰)" },
|
||||
{ "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 } ] },
|
||||
|
||||
{ "type_id": "refuge", "group": "E", "name": "대피소", "placement": "interval", "style": { "color": "#2fa84f", "abbr": "대" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [
|
||||
{ "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 5.0 },
|
||||
{ "key": "length_m", "label": "유효길이", "input": "number", "unit": "m", "default": 15.0 } ] },
|
||||
{ "type_id": "work_yard", "group": "E", "name": "정차·작업장", "placement": "interval", "style": { "color": "#268f43", "abbr": "작" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [
|
||||
{ "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 7.0 },
|
||||
{ "key": "length_m", "label": "유효길이", "input": "number", "unit": "m", "default": 20.0 } ] },
|
||||
{ "type_id": "turnaround", "group": "E", "name": "차돌림곳", "placement": "site", "style": { "color": "#37b95a", "abbr": "차" }, "drawing_views": ["plan", "quantity"], "options": [
|
||||
{ "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 10.0 } ] },
|
||||
{ "type_id": "mirror", "group": "E", "name": "반사경", "placement": "point", "style": { "color": "#43c76b", "abbr": "반" }, "drawing_views": ["plan"], "options": [
|
||||
{ "key": "facing", "label": "방향", "input": "select", "choices": ["좌", "우", "양면"], "default": "양면" } ] },
|
||||
{ "type_id": "guardrail", "group": "E", "name": "가드레일·경계석·위험표지", "placement": "interval", "style": { "color": "#1f9e46", "abbr": "가" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [
|
||||
{ "key": "kind", "label": "종류", "input": "select", "choices": ["가드레일", "경계석", "위험표지"], "default": "가드레일" } ] },
|
||||
{ "type_id": "position_sign", "group": "E", "name": "국가지점번호판", "placement": "point", "style": { "color": "#53a832", "abbr": "번" }, "drawing_views": ["plan", "detail"], "options": [] },
|
||||
{ "type_id": "barrier_gate", "group": "E", "name": "차단기", "placement": "point", "style": { "color": "#3f9e2e", "abbr": "단" }, "drawing_views": ["plan", "detail"], "options": [] },
|
||||
{ "type_id": "completion_sign", "group": "E", "name": "준공표지판", "placement": "point", "style": { "color": "#66b344", "abbr": "준" }, "drawing_views": ["plan"], "options": [] },
|
||||
{ "type_id": "spoil_bank", "group": "E", "name": "사토장", "placement": "site", "style": { "color": "#8c9a2e", "abbr": "사" }, "drawing_views": ["plan", "quantity"], "options": [
|
||||
{ "key": "area_m2", "label": "면적", "input": "number", "unit": "㎡", "default": 500 },
|
||||
{ "key": "capacity_m3", "label": "용량", "input": "number", "unit": "㎥", "default": 1000 } ] },
|
||||
{ "type_id": "borrow_pit", "group": "E", "name": "토사채취장(토취장)", "placement": "site", "style": { "color": "#7d8a2a", "abbr": "토" }, "drawing_views": ["plan", "quantity"], "options": [
|
||||
{ "key": "area_m2", "label": "면적", "input": "number", "unit": "㎡", "default": 500 },
|
||||
{ "key": "capacity_m3", "label": "용량", "input": "number", "unit": "㎥", "default": 1000 } ] },
|
||||
|
||||
{ "type_id": "wildlife_path", "group": "F", "name": "야생동물 이동통로", "placement": "point", "style": { "color": "#3aa88f", "abbr": "야" }, "drawing_views": ["plan", "cross_section"], "options": [
|
||||
{ "key": "form", "label": "형식", "input": "select", "choices": ["경사로", "자연형계단"], "default": "경사로" } ] },
|
||||
{ "type_id": "eco_pond", "group": "F", "name": "생태연못", "placement": "site", "style": { "color": "#2f9e86", "abbr": "연" }, "drawing_views": ["plan", "detail", "quantity"], "options": [
|
||||
{ "key": "area_m2", "label": "면적(≥100)", "input": "number", "unit": "㎡", "default": 100 } ] },
|
||||
{ "type_id": "revegetation", "group": "F", "name": "비탈면 녹화", "placement": "interval", "style": { "color": "#4cb84c", "abbr": "녹" }, "drawing_views": ["cross_section", "quantity"], "options": [
|
||||
{ "key": "method", "label": "공법", "input": "select", "choices": ["씨뿌리기(줄파종)", "초류종자 살포", "새심기", "선떼붙이기", "줄떼", "평떼", "비탈덮기", "녹생토 뿜어붙이기", "조공", "편책", "나무심기"], "default": "초류종자 살포" } ] },
|
||||
|
||||
{ "type_id": "pavement_concrete", "group": "G", "name": "콘크리트 포장", "placement": "interval", "style": { "color": "#8a8f98", "abbr": "포" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [
|
||||
{ "key": "thickness_cm", "label": "두께", "input": "number", "unit": "cm", "default": 20 } ] },
|
||||
{ "type_id": "gravel_surfacing", "group": "G", "name": "쇄석(자갈) 포설", "placement": "interval", "style": { "color": "#9aa0a8", "abbr": "쇄" }, "drawing_views": ["cross_section", "quantity"], "options": [
|
||||
{ "key": "thickness_cm", "label": "두께", "input": "number", "unit": "cm", "default": 7 } ] },
|
||||
|
||||
{ "type_id": "etc", "group": "호환", "name": "기타", "placement": "point", "style": { "color": "#98a2b3", "abbr": "기" }, "drawing_views": ["profile"], "options": [
|
||||
{ "key": "name", "label": "이름", "input": "text", "default": "기타 구조물" } ] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""기존 비정규 측점 → 구조물 정본 마이그레이션.
|
||||
|
||||
기존 화면은 구조물을 4종(배관·기성막이·대피로·기타)으로 받아 왔고, 서버에는 `chainage_m` +
|
||||
표시 문자열만 남겼다. 신규 레지스트리 타입으로 옮기면서 종류별 의미를 살린다.
|
||||
|
||||
- 배관: 옮기지 않는다 — `pipe_points.json`이 정본이다.
|
||||
- 기성막이 → 기슭막이(`revetment`). 기존 이름이 오기였다 (2026-08-16 사용자 확정).
|
||||
- 대피로 → 대피소(`refuge`)로 통합. 폭 값은 대피소 너비 옵션으로 옮긴다 (동 확정).
|
||||
- 그 밖: 이름을 살려 `기타`.
|
||||
|
||||
구간형으로 바뀌는 타입(기슭막이·대피소)은 기존 데이터에 종점이 없다. 기점만 알고 있으므로
|
||||
최소 구간을 임시로 주고, 사용자가 화면에서 종점을 조정한다 — 값을 지어내는 것보다 낫다.
|
||||
"""
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
|
||||
PIPE_STRUCTURE_NAME = "배관"
|
||||
# 기존 타입 이름 → 신규 type_id.
|
||||
LEGACY_TYPE_MAP = {
|
||||
"기성막이": "revetment",
|
||||
"대피로": "refuge",
|
||||
"기타": "etc",
|
||||
}
|
||||
# 종점을 모르는 구간형 항목에 주는 임시 길이(m). 화면에서 조정하라는 표시값이다.
|
||||
DEFAULT_INTERVAL_LENGTH_M = 15.0
|
||||
|
||||
|
||||
def _is_pipe(entry: dict[str, Any]) -> bool:
|
||||
return (
|
||||
entry.get("origin") == "pipe"
|
||||
or entry.get("structureType") == PIPE_STRUCTURE_NAME
|
||||
or str(entry.get("structure", "")).strip() == PIPE_STRUCTURE_NAME
|
||||
)
|
||||
|
||||
|
||||
def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[StructureInstance]:
|
||||
"""기존 비정규 측점 목록을 구조물 인스턴스로 옮긴다(같은 입력이면 같은 결과)."""
|
||||
types = structure_type_map()
|
||||
migrated: list[StructureInstance] = []
|
||||
seen: set[tuple[str, float]] = set()
|
||||
|
||||
for entry in entries:
|
||||
if _is_pipe(entry):
|
||||
continue
|
||||
|
||||
chainage = float(entry.get("chainage_m", 0.0))
|
||||
legacy_type = entry.get("structureType")
|
||||
type_id = LEGACY_TYPE_MAP.get(legacy_type, "etc")
|
||||
definition = types[type_id]
|
||||
|
||||
key = (type_id, round(chainage, 3))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"type_id": type_id,
|
||||
"placement": definition.placement,
|
||||
"side": "center",
|
||||
"options": _options_for(type_id, entry),
|
||||
"placement_source": "manual",
|
||||
}
|
||||
if definition.placement == "interval":
|
||||
data["start_m"] = chainage
|
||||
data["end_m"] = chainage + DEFAULT_INTERVAL_LENGTH_M
|
||||
data["memo"] = "구 비정규 측점 이관 — 종점 확인 필요"
|
||||
else:
|
||||
data["chainage_m"] = chainage
|
||||
migrated.append(StructureInstance.model_validate(data))
|
||||
|
||||
return migrated
|
||||
|
||||
|
||||
def _options_for(type_id: str, entry: dict[str, Any]) -> dict[str, Any]:
|
||||
if type_id == "refuge":
|
||||
width = entry.get("escapeWidthM")
|
||||
return {"width_m": float(width)} if width is not None else {}
|
||||
if type_id == "etc":
|
||||
name = entry.get("customName") or entry.get("structure") or "기타 구조물"
|
||||
return {"name": str(name).strip()}
|
||||
return {}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""B05 구조물 정본(`B05_Profile/route/structures.json`) 읽기·쓰기.
|
||||
|
||||
정본은 이 파일 **하나뿐**이다. DB에는 참조 메타(개수·revision)만 남긴다 — 같은 값을 두 곳에
|
||||
두면 한쪽 저장이 실패했을 때 어느 쪽이 진짜인지 알 수 없다(관 매설 지점의 "복원 유령" 전례).
|
||||
|
||||
저장은 임시 파일에 쓰고 교체하는 방식이라, 쓰는 도중 죽어도 반쪽짜리 파일이 남지 않는다.
|
||||
동시에 두 화면이 저장하면 `base_revision`이 어긋나 뒤엣것이 거절된다(앞의 편집을 덮지 않게).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
STRUCTURES_FILE_NAME = "structures.json"
|
||||
_STAGE_DIR = os.path.join("B05_Profile", "route")
|
||||
|
||||
|
||||
class StructureRevisionConflict(RuntimeError):
|
||||
"""다른 저장이 먼저 반영되어 판번호가 어긋났다."""
|
||||
|
||||
def __init__(self, expected: int, actual: int) -> None:
|
||||
super().__init__(
|
||||
f"구조물 정본이 이미 갱신되었습니다 (요청 판번호 {expected}, 현재 {actual}). "
|
||||
"최신 내용을 다시 불러온 뒤 저장해 주세요."
|
||||
)
|
||||
self.expected = expected
|
||||
self.actual = actual
|
||||
|
||||
|
||||
def structures_file_path(project_root: str) -> str:
|
||||
"""프로젝트 저장소 안의 구조물 정본 경로."""
|
||||
return os.path.join(project_root, _STAGE_DIR, STRUCTURES_FILE_NAME)
|
||||
|
||||
|
||||
def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
|
||||
"""정본을 읽어 (판번호, 구조물 목록)을 돌려준다.
|
||||
|
||||
파일이 없거나 깨졌으면 빈 정본(판번호 0)으로 본다 — 화면이 못 열리는 것보다 낫고,
|
||||
다음 저장이 온전한 파일로 덮어쓴다.
|
||||
"""
|
||||
path = structures_file_path(project_root)
|
||||
if not os.path.exists(path):
|
||||
return 0, []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
revision = int(payload.get("revision", 0))
|
||||
structures = [
|
||||
StructureInstance.model_validate(item) for item in payload.get("structures", [])
|
||||
]
|
||||
except (OSError, ValueError, TypeError):
|
||||
return 0, []
|
||||
return revision, structures
|
||||
|
||||
|
||||
def save_structures(
|
||||
project_root: str,
|
||||
structures: Iterable[StructureInstance],
|
||||
*,
|
||||
base_revision: int,
|
||||
) -> int:
|
||||
"""구조물 목록을 정본에 덮어쓰고 새 판번호를 돌려준다."""
|
||||
items = list(structures)
|
||||
_validate_types(items)
|
||||
|
||||
current_revision, _ = load_structures(project_root)
|
||||
if current_revision != base_revision:
|
||||
raise StructureRevisionConflict(base_revision, current_revision)
|
||||
|
||||
for item in items:
|
||||
if not item.structure_id:
|
||||
item.structure_id = uuid.uuid4().hex
|
||||
|
||||
revision = current_revision + 1
|
||||
payload = {
|
||||
"revision": revision,
|
||||
"structures": [item.model_dump(mode="json") for item in items],
|
||||
}
|
||||
atomic_write_json(structures_file_path(project_root), payload)
|
||||
return revision
|
||||
|
||||
|
||||
def requires_downstream_invalidation(
|
||||
previous: Iterable[StructureInstance], current: Iterable[StructureInstance]
|
||||
) -> bool:
|
||||
"""구조물 변경이 B06 이후 결과를 못 쓰게 만드는지 판정한다.
|
||||
|
||||
메모나 표시용 값만 바뀐 경우까지 후속 단계를 깨면, 사용자가 메모 한 줄 고칠 때마다
|
||||
횡단·수량을 다시 돌려야 한다. 그래서 설계에 실제로 영향을 주는 값
|
||||
(타입·위치·범위·측·오프셋·제원)만 비교한다. 목록 순서는 설계와 무관하므로 무시한다.
|
||||
"""
|
||||
return _design_fingerprint(previous) != _design_fingerprint(current)
|
||||
|
||||
|
||||
def _design_fingerprint(items: Iterable[StructureInstance]) -> set[str]:
|
||||
return {
|
||||
json.dumps(
|
||||
[
|
||||
item.structure_id,
|
||||
item.type_id,
|
||||
item.placement,
|
||||
item.chainage_m,
|
||||
item.start_m,
|
||||
item.end_m,
|
||||
item.side,
|
||||
item.offset_m,
|
||||
item.options,
|
||||
item.geometry,
|
||||
],
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def _validate_types(items: list[StructureInstance]) -> None:
|
||||
"""레지스트리에 없는 타입, 다른 정본이 관리하는 타입(배관)을 걸러낸다."""
|
||||
types = structure_type_map()
|
||||
for item in items:
|
||||
definition = types.get(item.type_id)
|
||||
if definition is None:
|
||||
raise ValueError(f"등록되지 않은 구조물 타입입니다: {item.type_id}")
|
||||
if definition.managed_by:
|
||||
raise ValueError(
|
||||
f"{definition.name}은(는) {definition.managed_by} 정본이 관리합니다 — "
|
||||
"구조물 목록에 저장할 수 없습니다."
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""B05 구조물 타입 레지스트리 조회·구조물 정본 CRUD 라우터.
|
||||
|
||||
타입 목록은 프론트가 정적으로 들고 있지 않고 여기서 받아 간다 — 레지스트리 파일 하나만
|
||||
고치면 화면 폼까지 따라오게 하기 위함이다.
|
||||
|
||||
구조물 정본은 `B05_Profile/route/structures.json` 하나이며, 저장은 목록 전체 덮어쓰기다.
|
||||
화면이 읽어간 판번호를 함께 보내고, 그 사이 다른 창이 저장했으면 409로 거절한다 — 뒤에 누른
|
||||
쪽이 앞의 편집을 조용히 지우지 않게.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Structures_Repository import (
|
||||
StructureRevisionConflict,
|
||||
load_structures,
|
||||
requires_downstream_invalidation,
|
||||
save_structures,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureListResponse,
|
||||
StructureSaveRequest,
|
||||
StructureSaveResponse,
|
||||
StructureTypesResponse,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Structures"])
|
||||
|
||||
# B05(노선 설계)는 워크플로 2단계다 — 구조물이 바뀌면 그 뒤 단계를 다시 돌려야 한다.
|
||||
ROUTE_STAGE_NO = 2
|
||||
|
||||
_PROJECT_PATH_MISSING = {
|
||||
"status": "error",
|
||||
"message": "프로젝트 저장 경로를 찾을 수 없습니다.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/structure-types", response_model=StructureTypesResponse)
|
||||
async def read_structure_types() -> StructureTypesResponse:
|
||||
"""구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용)."""
|
||||
return StructureTypesResponse(
|
||||
schema_version=registry_schema_version(),
|
||||
types=list(load_structure_types()),
|
||||
)
|
||||
|
||||
|
||||
async def _project_root(project_id: UUID) -> str | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored_path:
|
||||
return None
|
||||
return str(Path(resolve_stored_project_path(stored_path)))
|
||||
|
||||
|
||||
async def _invalidate_downstream(project_id: UUID) -> None:
|
||||
"""구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다.
|
||||
|
||||
실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이
|
||||
어긋난다. 로그만 남기고 넘어간다.
|
||||
"""
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE project_workflow_stages
|
||||
SET state = 'STALE'
|
||||
WHERE project_id = %s AND stage_no > %s AND state = 'COMPLETE'
|
||||
""",
|
||||
(str(project_id), ROUTE_STAGE_NO),
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/structures", response_model=StructureListResponse)
|
||||
async def read_structures(project_id: UUID) -> StructureListResponse | JSONResponse:
|
||||
"""배치된 구조물 목록과 현재 판번호를 반환한다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
if root is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
revision, structures = load_structures(root)
|
||||
return StructureListResponse(
|
||||
project_id=str(project_id), revision=revision, structures=structures
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/structures", response_model=StructureSaveResponse)
|
||||
async def write_structures(
|
||||
project_id: UUID, payload: StructureSaveRequest
|
||||
) -> StructureSaveResponse | JSONResponse:
|
||||
"""구조물 목록을 정본에 덮어쓴다(판번호 불일치 시 409, 타입 오류 시 400)."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
if root is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
_, previous = load_structures(root)
|
||||
revision = save_structures(root, payload.structures, base_revision=payload.base_revision)
|
||||
# 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도
|
||||
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다.
|
||||
invalidated = requires_downstream_invalidation(previous, payload.structures)
|
||||
if invalidated:
|
||||
await _invalidate_downstream(project_id)
|
||||
return StructureSaveResponse(
|
||||
project_id=str(project_id),
|
||||
revision=revision,
|
||||
count=len(payload.structures),
|
||||
invalidated_downstream=invalidated,
|
||||
)
|
||||
except StructureRevisionConflict as conflict:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"status": "error", "message": str(conflict), "revision": conflict.actual},
|
||||
)
|
||||
except ValueError as error:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물 저장 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""B05 구조물 타입 레지스트리·인스턴스 검증 모델.
|
||||
|
||||
구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가
|
||||
바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다.
|
||||
|
||||
인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정).
|
||||
DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의
|
||||
"복원 유령" 전례).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
|
||||
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
||||
STRUCTURE_SIDES = ("left", "right", "center", "cross")
|
||||
|
||||
_REGISTRY_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json"
|
||||
)
|
||||
|
||||
Placement = Literal["point", "interval", "site"]
|
||||
|
||||
|
||||
class StructureOptionField(BaseModel):
|
||||
"""타입별 옵션 입력 한 칸의 정의 — 프론트가 이 스키마로 폼을 그린다."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
label: str
|
||||
input: Literal["select", "number", "text"]
|
||||
choices: list[str] = Field(default_factory=list)
|
||||
unit: str | None = None
|
||||
default: Any = None
|
||||
|
||||
|
||||
class StructureType(BaseModel):
|
||||
"""구조물 타입 정의(레지스트리 1행)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str
|
||||
group: str
|
||||
name: str
|
||||
placement: Placement
|
||||
options: list[StructureOptionField] = Field(default_factory=list)
|
||||
style: dict[str, str] = Field(default_factory=dict)
|
||||
# B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다.
|
||||
drawing_views: list[str] = Field(default_factory=list)
|
||||
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
|
||||
managed_by: str | None = None
|
||||
# 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다.
|
||||
reference_only: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_structure_types() -> tuple[StructureType, ...]:
|
||||
"""레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드)."""
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
return tuple(StructureType.model_validate(item) for item in payload["types"])
|
||||
|
||||
|
||||
def structure_type_map() -> dict[str, StructureType]:
|
||||
return {item.type_id: item for item in load_structure_types()}
|
||||
|
||||
|
||||
def registry_schema_version() -> int:
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
return int(json.load(handle).get("schema_version", 1))
|
||||
|
||||
|
||||
class StructureInstance(BaseModel):
|
||||
"""배치된 구조물 1건.
|
||||
|
||||
배치형태에 따라 위치 필드가 갈린다 — 점형·부지형은 `chainage_m`, 구간형은 `start_m`~`end_m`.
|
||||
쓰지 않는 쪽 필드를 함께 받으면 어느 값이 진짜인지 알 수 없으므로 거절한다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
structure_id: str | None = None
|
||||
type_id: str
|
||||
placement: Placement
|
||||
chainage_m: float | None = Field(default=None, ge=0)
|
||||
start_m: float | None = Field(default=None, ge=0)
|
||||
end_m: float | None = Field(default=None, ge=0)
|
||||
side: Literal["left", "right", "center", "cross"] = "center"
|
||||
offset_m: float = 0.0
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
memo: str = ""
|
||||
placement_source: Literal["manual", "suggested", "automatic"] = "manual"
|
||||
status: Literal["draft", "confirmed"] = "draft"
|
||||
revision: int = Field(default=0, ge=0)
|
||||
# 부지형 polygon 예약 — 1차 UI는 측점+면적 속성만 쓴다.
|
||||
geometry: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_placement_fields(self) -> "StructureInstance":
|
||||
if self.placement == "interval":
|
||||
if self.start_m is None or self.end_m is None:
|
||||
raise ValueError("구간형 구조물은 start_m과 end_m이 모두 필요합니다.")
|
||||
if self.end_m <= self.start_m:
|
||||
raise ValueError("구간형 구조물의 end_m은 start_m보다 커야 합니다.")
|
||||
if self.chainage_m is not None:
|
||||
raise ValueError("구간형 구조물에는 chainage_m을 함께 보낼 수 없습니다.")
|
||||
else:
|
||||
if self.chainage_m is None:
|
||||
raise ValueError("점형·부지형 구조물은 chainage_m이 필요합니다.")
|
||||
if self.start_m is not None or self.end_m is not None:
|
||||
raise ValueError("점형·부지형 구조물에는 start_m·end_m을 보낼 수 없습니다.")
|
||||
return self
|
||||
|
||||
def anchor_m(self) -> float:
|
||||
"""종단도 서클마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정)."""
|
||||
return self.start_m if self.placement == "interval" else self.chainage_m # type: ignore[return-value]
|
||||
|
||||
|
||||
class StructureTypesResponse(BaseModel):
|
||||
"""`GET /structure-types` 응답."""
|
||||
|
||||
status: str = "success"
|
||||
schema_version: int
|
||||
types: list[StructureType]
|
||||
|
||||
|
||||
class StructureSaveRequest(BaseModel):
|
||||
"""구조물 목록 저장 요청 — 목록 전체를 정본으로 덮어쓴다.
|
||||
|
||||
`base_revision`은 화면이 읽어간 정본의 판번호다. 그 사이 다른 창이 저장했으면
|
||||
번호가 어긋나므로 409로 거절하고, 사용자가 최신본을 받아 다시 편집하게 한다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
base_revision: int = Field(ge=0)
|
||||
structures: list[StructureInstance] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StructureSaveResponse(BaseModel):
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
revision: int
|
||||
count: int
|
||||
# 설계 영향 변경이라 B06 이후 완료 단계를 STALE로 되돌렸는가.
|
||||
invalidated_downstream: bool = False
|
||||
|
||||
|
||||
class StructureListResponse(BaseModel):
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
revision: int
|
||||
structures: list[StructureInstance] = Field(default_factory=list)
|
||||
@@ -40,6 +40,7 @@ from B04_PreProcess.B04_PreProcess_Router_GIS import tiles_router
|
||||
from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_router
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router
|
||||
from B05_Profile.B05_Profile_Router import router as b05_route_router
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router
|
||||
from B06_Section.B06_Section_Router import router as b06_section_router
|
||||
from B06_Section.B06_Section_Router_Confirm import (
|
||||
router as b06_section_confirm_router,
|
||||
@@ -366,6 +367,7 @@ app.include_router(b04_inflow_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_basins_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_structures_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_quantity_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -55,3 +55,5 @@ tzdata==2026.2
|
||||
uvicorn==0.24.0
|
||||
wheel==0.47.0
|
||||
whitebox==2.3.6
|
||||
pytest>=8
|
||||
httpx<0.28
|
||||
|
||||
Reference in New Issue
Block a user