Files
Aislo/B05_Profile/B05_Profile_Structures_Router.py
T
eomsangdonandClaude Opus 5.5 5892232890 feat(B05·M02): 구조물 집계 항목 칸 · 저장 스냅샷 · 지정 항목 채움 (PLAN 21-3)
- summary_item 칸: StructureInstance · pipe_points 점 기록(options 밖) — 모양 검사 한 벌 common_util_summary_item(층 system·company·personal · owner null·{회사}·{회사}/{사용자} · key SS+6자리 · name) · 없으면 null(옛 파일 호환) · 관 요청 모양 틀림 400
- 저장 스냅샷 M02_Item_Snapshot: PUT /route/structures · PUT /drainage/pipe-points 뒤 지정 항목마다 templates/items/{layer}_{owner}_{key}/ 에 집계(SS 줄 + 열 정의) · 도면 · 면적 · 재료 · 자재조합 · 일위대가(UA 줄 + 담은 로직 줄) · manifest(원본 · 판 · 적용일 · 쓰는 구조물) — 있는 폴더는 다시 안 씀(원본이 바뀌어도 프로젝트 값 그대로) · 안 쓰는 폴더 지움 · 실패해도 저장 유지
- 채움 fill_table = 지정 항목 모드: 구조물이 고른 열(묶음)에만 · 미지정 · 복사본 없음은 안 셈 + 알림 · 스냅샷 열 정의 사용 · 회사 · 개인 항목은 열id@폴더 새 열 · 자동 맞춤은 fill_document 기본으로만 남음
- 관 시설 되붙이기가 summary_item 도 옮김
- [초기화]는 templates/ 를 안 건드림 · 복제 도구는 폴더 통째 복사 — 손댈 것 없음(시험으로 지킴)
- 시험: test_structures_summary_snapshot 17 · 옛 채움 시험은 자동 맞춤 길(fill_document)로 · 층 라우트 채움 시험은 새 규칙(미지정 관 = 줄 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-29 12:00:44 +09:00

317 lines
14 KiB
Python

"""B05 구조물 타입 레지스트리 조회·구조물 정본 CRUD 라우터.
타입 목록은 프론트가 정적으로 들고 있지 않고 여기서 받아 간다 — 레지스트리 파일 하나만
고치면 화면 폼까지 따라오게 하기 위함이다.
구조물 정본은 `B05_Profile/route/structures.json` 하나이며, 저장은 목록 전체 덮어쓰기다.
화면이 읽어간 판번호를 함께 보내고, 그 사이 다른 창이 저장했으면 409로 거절한다 — 뒤에 누른
쪽이 앞의 편집을 조용히 지우지 않게.
"""
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, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
load_migrated_legacy,
load_structures,
requires_downstream_invalidation,
save_structures,
)
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
StructureListResponse,
StructureSaveRequest,
StructureSaveResponse,
StructureTypesResponse,
load_structure_types,
registry_schema_version,
)
from common_util.common_util_drainage_pipes import (
PipePoint,
append_pipe_points_file,
pipe_points_path_in,
read_pipe_points_file,
)
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:
"""프로젝트 저장 경로 — 없는 프로젝트는 None (호출부가 404로 답한다).
`get_project_storage_relative_path`는 없는 프로젝트에서 LookupError를 **던진다**
(None 반환이 아님) — 잡지 않으면 500으로 샌다(2026-08-16 크로스체크 지적 5).
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
except LookupError:
return None
if not stored_path:
return None
return str(Path(resolve_stored_project_path(stored_path)))
async def _route_length(project_id: UUID) -> float | None:
"""최신 노선 총연장(m). 노선이 없거나 조회 실패면 None — 범위 검증만 생략된다."""
try:
pool = get_db_pool()
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
length = latest.get("total_length_m") if latest else None
return float(length) if length else None
except Exception:
logger.exception("B05 노선 연장 조회 실패: project_id=%s", project_id)
return None
def _migrate_pipe_facilities(
root: str, facilities: list[PipePoint], migrated_before: set[str]
) -> tuple[int, set[str]]:
"""관 지점 정본이 관리하는 이관 후보를 그 정본에 덧붙인다. (옮긴 수, 이력 표식).
관 지점 파일이 없으면(= B04 배수유역 산출물 없음) 아무것도 쓰지 않고 이력도 남기지
않는다 — 원천 비정규 측점은 그대로 있으므로, 산출물이 생긴 뒤 다시 이관된다.
표식은 구조물 쪽과 같은 "타입@위치"라 한 벌로 셈해도 부딪히지 않는다.
"""
if not facilities:
return 0, set()
def key_of(point: PipePoint) -> str:
return f"{point.facility}@{round(float(point.chainage_m), 3)}"
path = pipe_points_path_in(Path(root))
if not path.is_file():
logger.info("B05 이관: 관 지점 정본이 없어 관 시설 %d건을 미뤘습니다.", len(facilities))
return 0, set()
occupied = {key_of(point) for point in read_pipe_points_file(path)}
fresh = [
point
for point in facilities
if key_of(point) not in occupied and key_of(point) not in migrated_before
]
if fresh and append_pipe_points_file(path, fresh) is None:
return 0, set()
return len(fresh), {key_of(point) for point in facilities}
async def _invalidate_downstream(project_id: UUID) -> bool:
"""구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다.
실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이
어긋난다. 대신 성공 여부를 돌려줘 응답이 사실만 말하게 한다(성공한 척 금지 —
2026-08-16 크로스체크 지적 5).
"""
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()
return True
except Exception:
logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id)
return False
@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 LookupError:
# 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다.
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
except Exception:
logger.exception("B05 구조물 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 조회 중 오류가 발생했습니다."},
)
class StructureMigrateRequest(BaseModel):
"""구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로."""
stations: list[dict[str, Any]] = Field(default_factory=list)
@router.post("/{project_id}/route/structures/migrate", response_model=None)
async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
"""구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는
(타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다
(2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의)."""
try:
root = await _project_root(project_id)
if root is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
revision, existing = load_structures(root)
migrated_before = load_migrated_legacy(root)
plan = migrate_irregular_stations(payload.stations)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
def key_of(item: StructureInstance) -> str:
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
occupied = {key_of(item) for item in existing}
fresh = [
item
for item in plan.structures
if key_of(item) not in occupied and key_of(item) not in migrated_before
]
# 관 지점 정본이 관리하는 타입(기슭막이)은 구조물 목록에 넣을 수 없다 — 그쪽
# 정본에 덧붙인다(2026-09-01 사용자 확정: 건너뛰지 않고 옮긴다).
moved_pipes, pipe_history = _migrate_pipe_facilities(
root, plan.pipe_facilities, migrated_before
)
# 이번에 건너뛴 것(이미 있던 자리)도 이력에 남긴다 — 그 자리는 이관이 끝난 자리다.
history = {key_of(item) for item in plan.structures} | pipe_history
if not fresh:
# 새로 옮길 건 없어도 아직 이력에 없는 자리가 있으면 이력만 남긴다 — 그래야
# 다음 진입에서 그 자리가 다시 후보로 잡히지 않는다. 이력이 이미 다 있으면
# 저장하지 않는다(판번호를 괜히 올리면 다른 창의 저장이 충돌한다).
if history - migrated_before:
revision = save_structures(
root,
existing,
base_revision=revision,
max_chainage_m=await _route_length(project_id),
migrated_legacy=history,
)
return JSONResponse(
content={
"status": "success",
"migrated": moved_pipes,
"revision": revision,
"pipe_facilities": moved_pipes,
}
)
new_revision = save_structures(
root,
[*existing, *fresh],
base_revision=revision,
max_chainage_m=await _route_length(project_id),
migrated_legacy=history,
)
logger.info(
"B05 구 비정규 측점 이관: project_id=%s, 구조물 %d건 · 관 시설 %d건",
project_id,
len(fresh),
moved_pipes,
)
return JSONResponse(
content={
"status": "success",
"migrated": len(fresh) + moved_pipes,
"revision": new_revision,
"pipe_facilities": moved_pipes,
}
)
except LookupError:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
except (StructureRevisionConflict, 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": "구조물 이관 중 오류가 발생했습니다."},
)
@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,
max_chainage_m=await _route_length(project_id),
)
# 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지)
from M02_MasterTemplete.M02_Item_Snapshot import safe_sync
await asyncio.to_thread(safe_sync, root)
# 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE
# 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다).
needs_invalidation = requires_downstream_invalidation(previous, payload.structures)
invalidated = needs_invalidation and await _invalidate_downstream(project_id)
return StructureSaveResponse(
project_id=str(project_id),
revision=revision,
count=len(payload.structures),
needs_downstream_invalidation=needs_invalidation,
invalidated_downstream=invalidated,
)
except LookupError:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
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": "구조물 저장 중 오류가 발생했습니다."},
)