/* ============================================================================= * B05_Profile_Api_Structures.ts * 구조물 타입 레지스트리·구조물 정본 API 클라이언트. * * 백엔드 계약 (B05_Profile_Structures_Router.py): * GET /api/projects/structure-types → 타입 레지스트리 * GET /api/projects/{project_id}/route/structures → 목록 + 판번호 * PUT /api/projects/{project_id}/route/structures → 목록 전체 덮어쓰기 * * 타입 정의를 화면에 박아 두지 않는다 — 레지스트리 파일 하나만 고치면 폼까지 따라오게 * 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ export type StructurePlacement = "point" | "interval" | "site"; export interface StructureOptionField { key: string; label: string; input: "select" | "number" | "text"; choices: string[]; unit: string | null; default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ phase?: "b05" | "detail"; } /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ export function isB05Option(option: StructureOptionField): boolean { return (option.phase ?? "b05") !== "detail"; } export interface StructureType { type_id: string; group: string; name: string; placement: StructurePlacement; options: StructureOptionField[]; style: { color?: string; abbr?: string }; drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; reference_only: boolean; enabled: boolean; } export interface StructureInstance { structure_id?: string | null; type_id: string; placement: StructurePlacement; chainage_m?: number | null; start_m?: number | null; end_m?: number | null; options: Record; memo: string; placement_source: "manual" | "suggested" | "automatic"; status: "draft" | "confirmed"; revision: number; geometry: Record | null; } interface StructureTypesResponse { status: string; schema_version: number; types: StructureType[]; } export interface StructureListResponse { status: string; project_id: string; revision: number; structures: StructureInstance[]; } export interface StructureSaveResponse { status: string; project_id: string; revision: number; count: number; /** 설계 영향 변경이라 B06 이후를 되돌려야 했는가. */ needs_downstream_invalidation: boolean; /** 실제로 되돌렸는가. needs와 어긋나면 화면이 사용자에게 알린다. */ invalidated_downstream: boolean; } /** 다른 창이 먼저 저장해 판번호가 어긋났다 — 화면이 최신본을 다시 받아야 한다. */ export class StructureConflictError extends Error { constructor( message: string, readonly currentRevision: number, ) { super(message); this.name = "StructureConflictError"; } } async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, signal: controller.signal, ...init, }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = (payload && typeof payload.message === "string" && payload.message) || `요청이 실패했습니다 (${response.status}).`; if (response.status === 409) { throw new StructureConflictError(message, Number(payload?.revision ?? 0)); } throw new Error(message); } return payload as T; } finally { window.clearTimeout(timer); } } /** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ let typesCache: Promise | null = null; export function fetchStructureTypes(): Promise { if (!typesCache) { typesCache = requestJson("/projects/structure-types", { method: "GET", }) .then((payload) => payload.types.filter((type) => type.enabled)) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; }); } return typesCache; } export async function fetchStructures(projectId: string): Promise { return requestJson(`/projects/${projectId}/route/structures`, { method: "GET", }); } export async function saveStructures( projectId: string, baseRevision: number, structures: StructureInstance[], ): Promise { return requestJson(`/projects/${projectId}/route/structures`, { method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }), }); } /** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점 * 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */ export async function migrateLegacyStations( projectId: string, stations: Array<{ chainage_m: number; structure: string }>, ): Promise<{ status: string; migrated: number; revision: number }> { return requestJson(`/projects/${projectId}/route/structures/migrate`, { method: "POST", body: JSON.stringify({ stations }), }); } /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } /** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */ export function defaultOptions(type: StructureType): Record { const options: Record = {}; type.options.forEach((field) => { // 기본값이 있는 항목만 채운다. 필수 선택지의 첫 항목을 대신 넣어 주면 사용자가 // 고르지도 않은 재료·형식이 확정값으로 저장된다(2026-08-16 크로스체크 지적 2). if (field.default !== null && field.default !== undefined) options[field.key] = field.default; }); return options; }