/* ============================================================================= * B06_Section_Api_Fetch.ts * 3차 워크플로우(종·횡단 생성) API 클라이언트 * * 백엔드 계약 (B06_Section_Router.py): * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 * POST /api/projects/{project_id}/sections/{route_id}/regenerate → 횡단 반폭 재생성 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * * 규칙: * - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지). * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * - **타입 선언은 `B06_Section_Api_Types.ts`에 있다**(700줄 제한, 2026-09-02). * 기존 호출 코드가 깨지지 않도록 여기서 그대로 다시 내보낸다. * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { t as L } from "@ui/ui_template_locale"; import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import type { CrossDesign, CrossSection, CrossSectionPatch, SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, SectionSummaryResponse, StandardCrossSection, } from "./B06_Section_Api_Types"; // 타입 정본은 `B06_Section_Api_Types.ts`. 기존 호출부가 여기서 가져오던 것을 // 그대로 쓰도록 다시 내보낸다 — 이동은 파일만 나눈 것이고 계약은 그대로다. export type * from "./B06_Section_Api_Types"; /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers ?? {}), }, signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; if (!response.ok) { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; } finally { window.clearTimeout(timeoutId); } } /** 종횡단 설정값 한 번 묻기(캐시 없음) — 세션 캐시 창구는 `B06_Section_Api_Context.ts`(472-2). */ export function requestSectionContext(projectId: string): Promise { return requestJson(`/projects/${projectId}/sections/context`, { method: "GET", }); } /** 경로의 종단면 요약을 조회한다. */ export async function getSections( projectId: string, routeId: number, ): Promise { return requestJson(`/projects/${projectId}/sections/${routeId}`, { method: "GET", }); } /** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */ /** * 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음 * ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2). */ export async function previewStructureSections( projectId: string, chainages: number[], ): Promise { const result = await requestJson<{ cross_sections?: CrossSection[] }>( `/projects/${projectId}/section/preview-stations`, { method: "POST", body: JSON.stringify({ chainages }) }, ); return result.cross_sections ?? []; } export async function fetchSectionDetail( projectId: string, routeId: number, ): Promise { return requestJson(`/projects/${projectId}/sections/${routeId}/detail`, { method: "GET", }); } /** 횡단 반폭을 반영해 종횡단을 재생성·저장하고 갱신된 상세를 반환한다. */ export async function regenerateSections( projectId: string, routeId: number, crossHalfWidthM: number, ): Promise { return requestJson( `/projects/${projectId}/sections/${routeId}/regenerate`, { method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) }, ); } /** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */ export async function confirmSections( projectId: string, routeId: number, standardCrossSection?: StandardCrossSection, crossPatches?: CrossSectionPatch[], /** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */ massHaul?: Record, ): Promise { const body: Record = {}; if (standardCrossSection) body.standard_cross_section = standardCrossSection; if (crossPatches?.length) body.cross_patches = crossPatches; if (massHaul) body.mass_haul = massHaul; return warnUnrecomputed( requestJson(`/projects/${projectId}/sections/${routeId}/confirm`, { method: "POST", body: JSON.stringify(body), }), ); } /** 서버 정본 셈이 실패했으면 경고 한 줄(388-2) — 저장은 남음 · 면적 · 관 길이는 옛 서버 값. * 서버가 버린 고침(행 없는 측점 · 기본 설계 못 냄 · 측점 맞춤 실패)도 한 줄(445 M5). */ async function warnUnrecomputed( request: Promise, ): Promise { const result = await request; const dropped = [ ...new Set([ ...(result.dropped_patch_chainages ?? []), ...(result.default_failed_chainages ?? []), ]), ]; const where = dropped .slice(0, 5) .map((at) => `${at.toFixed(2)}m`) .join(" · "); const lost = [ ...(dropped.length ? [ `횡단 고침 ${dropped.length}곳을 못 남겼습니다 — 측점 행이 없음: ${where}` + (dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""), ] : []), ...(result.stations_synced === false ? ["구조물 측점 맞춤 실패 — 옛 측점이 남았습니다."] : []), ]; if (result.recomputed === false || lost.length) void import("@ui/ui_template_elements").then(({ showToast }) => { if (result.recomputed === false) showToast(L("B06_Recompute_Failed"), "warning", 8000); if (lost.length) showToast(lost.join(" "), "warning", 8000); }); return result; } /** * 편집 중인 종·횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장). * 저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않아 페이지 이동도 없다. */ export async function saveSections( projectId: string, routeId: number, standardCrossSection?: StandardCrossSection, crossPatches?: CrossSectionPatch[], massHaul?: Record, ): Promise { const body: Record = {}; if (standardCrossSection) body.standard_cross_section = standardCrossSection; if (crossPatches?.length) body.cross_patches = crossPatches; if (massHaul) body.mass_haul = massHaul; return warnUnrecomputed( requestJson(`/projects/${projectId}/sections/${routeId}/save`, { method: "POST", body: JSON.stringify(body), }), ); } /** 같은 회사에서 표준횡단 설계값을 불러올 수 있는 프로젝트 항목. */ export interface CompanyStandardProject { project_id: string; name: string; } export interface CompanyStandardListResponse { status: string; projects: CompanyStandardProject[]; } export interface CompanyStandardResponse { status: string; project_id: string; standard_cross_section: StandardCrossSection; } /** 같은 회사에서 설계값을 불러올 수 있는 프로젝트 목록을 조회한다(회사 스코프). */ export async function listCompanyStandards( projectId: string, ): Promise { return requestJson( `/projects/${projectId}/sections/company-standards`, { method: "GET" }, ); } /** 특정 프로젝트의 표준횡단 설계값을 미리보기용으로 조회한다(적용 전, 현재 값 불변). */ export async function getCompanyStandard( projectId: string, sourceProjectId: string, ): Promise { return requestJson( `/projects/${projectId}/sections/company-standards/${sourceProjectId}`, { method: "GET" }, ); } /** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */ export interface ProfileAlignmentEdits { station_offsets: Record; curve_radii: Record; } /** * 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다. * 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB). */ export interface CrossDesignPreviewResponse { status: string; /** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */ designs: Array<{ chainage_m: number; design: Partial }>; } /** * 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음). * * B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다. * 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의 * 임시저장·확정이 맡는다. */ export async function previewCrossDesigns( projectId: string, routeId: number, edits: ProfileAlignmentEdits, standardCrossSection?: StandardCrossSection, options?: { /** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */ fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ berms?: Record< string, { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } >; /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ cutSlopeRatios?: Record; /** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */ designChoices?: Record; }, ): Promise { return requestJson( `/projects/${projectId}/sections/${routeId}/cross-design/preview`, { method: "POST", body: JSON.stringify({ ...edits, standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, berms: options?.berms ?? null, cut_slope_ratios: options?.cutSlopeRatios ?? null, design_choices: options?.designChoices ?? null, }), }, ); } /** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */ export interface CrossQuantitySheet { quantity_table: Record; /** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */ text: Record; layout: { header: Array<[string, string]>; left: Array<[string | null, string | null, string | null]>; /** 더한 홀로 선 줄(179-4)은 하위 이름 없음 */ middle: Array<[string | null, string | null, string]>; right: Array<[string | null, string | null]>; /** 본문 줄 id(양식 b0~ · 더한 행 r…) · 더한 열(키 = 본문 줄마다) — 179-4 */ rows: string[]; columns: Array<{ id: string; label: string; keys: string[] }>; }; /** 길이 출처가 설계선을 벗어난 칸 — 값 비움 · 「다시 고름」(179-4). */ repick: string[]; } /** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ export async function fetchCrossQuantityTable( projectId: string, routeId: number, chainageM: number, design: CrossDesign | null | undefined, ): Promise { return requestJson( `/projects/${projectId}/sections/${routeId}/cross-quantity-table`, { method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) }, ); }