Files
Aislo/B06_Section/B06_Section_Api_Fetch.ts
T
eomsangdonandClaude Opus 5 72e50b3edd fix(B06): 저장된 표준 횡단면이 새 탭에서 화면에 안 서던 것
표준단면 편집값은 sessionStorage 에만 살아 **탭을 새로 열면** 사라졌음. 그때 브라우저는
config 기본값으로, 서버는 저장분으로 계산해 **같은 측점이 갈렸음**. 표준단면은 모든 측점의
횡단 모양을 정하므로 면적·유토곡선·수량까지 그대로 흐름.

- `sections/context` 가 저장분을 **한 칸 더** 실어 보냄(`stored_standard_cross_section`).
  기존 `standard_cross_section`(config 기본값)은 그대로 둠 — 옛 화면 안 깨짐, 마이그레이션 없음.
- 브라우저는 두 화면이 함께 지나는 한 자리(`fetchSectionContext`)에서 **세션이 비었을 때만**
  그 값으로 세움. 그 탭에서 고친 값이 있으면 안 건드림(초안 우선).

실측(용화 5601e828 · route 169):
- 고치기 전 — 저장분 암반 횡단경사 **5%** · 측구 상단폭 **0.9m** 인데
  화면이 받는 값은 **3%** · **0.69m**(config 기본값)였음. 응답에 저장분 칸 자체가 없었음.
- 고친 뒤 — 응답에 0.9·5 가 실리고, **빈 새 탭**에서 B06 을 열면 세션이
  `rock.ditch.top_width_m=0.9` · `rock.cross_slope_pct.max=5` 로 섬(검증 탭은 닫음).

시험 `test_b06_stored_standard_reaches_browser.py` 3건 — 칸이 따로 있고 기본은 None ·
라우터가 저장분을 실음 · 브라우저가 세션이 빈 경우에만 세움(두 갈래 모두).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:47:27 +09:00

271 lines
11 KiB
TypeScript

/* =============================================================================
* 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 { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import type {
CrossDesign,
CrossDesignRequest,
CrossDesignResponse,
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<T>(path: string, init: RequestInit): Promise<T> {
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);
}
}
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
const cached = readState<SectionContextResponse>("section-context", projectId);
if (cached) return seedStandardCross(projectId, cached);
const fresh = await requestJson<SectionContextResponse>(
`/projects/${projectId}/sections/context`,
{ method: "GET" },
);
writeState("section-context", fresh, projectId);
return seedStandardCross(projectId, fresh);
}
/**
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
*
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
* **0.9m**, config 기본값은 **3%**·**0.69m**).
*
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
*/
function seedStandardCross(
projectId: string,
context: SectionContextResponse,
): SectionContextResponse {
const stored = context.stored_standard_cross_section;
if (stored && readState<unknown>("std-cross", projectId) === null) {
writeState("std-cross", stored, projectId);
}
return context;
}
export function clearSectionContextCache(projectId: string): void {
clearState("section-context", projectId);
}
/** 경로의 종단면 요약을 조회한다. */
export async function getSections(
projectId: string,
routeId: number,
): Promise<SectionSummaryResponse> {
return requestJson<SectionSummaryResponse>(`/projects/${projectId}/sections/${routeId}`, {
method: "GET",
});
}
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
export async function fetchSectionDetail(
projectId: string,
routeId: number,
): Promise<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(`/projects/${projectId}/sections/${routeId}/detail`, {
method: "GET",
});
}
/** 횡단 반폭을 반영해 종횡단을 재생성·저장하고 갱신된 상세를 반환한다. */
export async function regenerateSections(
projectId: string,
routeId: number,
crossHalfWidthM: number,
): Promise<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(
`/projects/${projectId}/sections/${routeId}/regenerate`,
{ method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) },
);
}
/** 측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산·저장하고 잠정 결과를 반환한다. */
export async function computeCrossDesign(
projectId: string,
routeId: number,
request: CrossDesignRequest,
): Promise<CrossDesignResponse> {
return requestJson<CrossDesignResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design`,
{ method: "POST", body: JSON.stringify(request) },
);
}
/** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */
export async function confirmSections(
projectId: string,
routeId: number,
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
/** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
});
}
/**
* 편집 중인 종·횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장).
* 저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않아 페이지 이동도 없다.
*/
export async function saveSections(
projectId: string,
routeId: number,
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return requestJson<SectionConfirmResponse>(`/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<CompanyStandardListResponse> {
return requestJson<CompanyStandardListResponse>(
`/projects/${projectId}/sections/company-standards`,
{ method: "GET" },
);
}
/** 특정 프로젝트의 표준횡단 설계값을 미리보기용으로 조회한다(적용 전, 현재 값 불변). */
export async function getCompanyStandard(
projectId: string,
sourceProjectId: string,
): Promise<CompanyStandardResponse> {
return requestJson<CompanyStandardResponse>(
`/projects/${projectId}/sections/company-standards/${sourceProjectId}`,
{ method: "GET" },
);
}
/** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */
export interface ProfileAlignmentEdits {
station_offsets: Record<string, number>;
curve_radii: Record<string, number>;
}
/**
* 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다.
* 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB).
*/
export interface CrossDesignPreviewResponse {
status: string;
/** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */
designs: Array<{ chainage_m: number; design: Partial<CrossDesign> }>;
}
/**
* 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음).
*
* B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다.
* 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의
* 임시저장·확정이 맡는다.
*/
export async function previewCrossDesigns(
projectId: string,
routeId: number,
edits: ProfileAlignmentEdits,
standardCrossSection?: StandardCrossSection,
options?: {
/** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */
fullDesigns?: boolean;
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
`/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,
}),
},
);
}