/* ============================================================================= * 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 { readState, writeState } from "../A00_Common/b_page_state"; 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; /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ enabled?: 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; /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. * 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */ design_owner: 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; /** * 구조물 타입 목록. * * `includeDisabled` 를 주면 `enabled:false` 타입(B군 종단배수·F군 생태/녹화·G군 일부)도 * 함께 준다. 레지스트리 주석(2026-08-17)이 「B05 선택지에서 빼고 **B06 개별 횡단도 * 옵션으로 재사용**」이라 적어 둔 그 자리다 — 2026-09-07 사용자 지시 「A군뿐 아니라 * 구조물 전체를 넣을 수 있어야 함」으로 B06 이 그 목록을 쓴다. B05 는 종전대로 켜진 것만. */ export function fetchStructureTypes(includeDisabled = false): Promise { if (!typesCache) { typesCache = requestJson("/projects/structure-types", { method: "GET", }) .then((payload) => payload.types) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; }); } return typesCache.then((types) => includeDisabled ? types : types.filter((type) => type.enabled), ); } 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 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ /** 관 지점을 **알약 레인용 가상 구조물**로 만든다 — 정본은 `pipe_points.json` 이라 * 저장하지 않는다. B05 종단과 B06 종단이 같은 표기를 쓰도록 한 벌만 둔다 * (2026-09-07 사용자 지시 4 「구조물 표시 통일」). */ export function pipesToStructureMarks( pipes: ReadonlyArray<{ chainage_m: number; facility: string; options?: Record | null; }>, ): StructureInstance[] { return pipes.map((pipe) => ({ structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, type_id: pipe.facility, placement: "point", chainage_m: pipe.chainage_m, start_m: null, end_m: null, options: (pipe.options ?? {}) as Record, memo: "", placement_source: "automatic", status: "draft", revision: 0, geometry: null, })) as StructureInstance[]; } 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; } /* ── 미저장 구조물 조작분(세션) ──────────────────────────────────────────── * 조작은 세션에만 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장, * 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어 * 읽기·쓰기·내보내기를 여기 한 곳에 둔다. */ /** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */ export function readPendingStructures(projectId: string): StructureInstance[] | null { return readState("structures", projectId); } export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { writeState("structures", next, projectId); } /** * 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는 * 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다. */ export async function flushPendingStructures(projectId: string): Promise { const pending = readPendingStructures(projectId); if (!pending) return; const stored = await fetchStructures(projectId); await saveStructures(projectId, stored.revision, pending); writePendingStructures(projectId, null); }