Files
Aislo/B06_Section/B06_Section_Api_Fetch.ts
T
eomsangdonandClaude Opus 5.5 63f994972c feat(472-6 6-A 브라우저 한 본문): 노선 안 바뀐 [저장] · [확정] = 차례 조각을 모아 /design/save 한 요청
- design_save: 노선 초안이 없으면 saveOneBody — 차례마다 조각(본문 · 저장 뒤 정리) · 서버가 다 저장한 뒤에만 정리(초안 비움 · 화면 맞춤) · 실패 = 차례 이름째 「아무것도 저장 안 함」 · 되돌림 실패 = 「복구 확인 필요」 · 기다림 알림
- 조각 함수: 페이지 설정 · 관 · 구조물(보기 목록) · 추가 측점 · 계획고(한 벌 종단도) · 관 옵션 · 상단측 · 횡단(횡단 보기) — 옛 flush 함수는 노선 바뀐 저장 길에 그대로(조각을 써서 같은 본문)
- 보기 몫 steps · cross → parts · crossPart · 노선 바뀐 저장 길은 옛 차례 그대로(6-B)
- 옛 9 차례를 단언하던 시험 10 개를 한 본문 꼴로(옛 PUT 줄 → SAVE 줄 · 조각 정리 차례) · 위키 [저장] 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:41:58 +09:00

334 lines
13 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 { 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<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);
}
}
/** 종횡단 설정값 한 번 묻기(캐시 없음) — 세션 캐시 창구는 `B06_Section_Api_Context.ts`(472-2). */
export function requestSectionContext(projectId: string): Promise<SectionContextResponse> {
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
method: "GET",
});
}
/** 경로의 종단면 요약을 조회한다. */
export async function getSections(
projectId: string,
routeId: number,
): Promise<SectionSummaryResponse> {
return requestJson<SectionSummaryResponse>(`/projects/${projectId}/sections/${routeId}`, {
method: "GET",
});
}
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
/**
* 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음
* ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2).
*/
export async function previewStructureSections(
projectId: string,
chainages: number[],
): Promise<CrossSection[]> {
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<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 confirmSections(
projectId: string,
routeId: number,
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
/** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
return warnUnrecomputed(
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
}),
);
}
/** 서버 정본 셈이 실패했으면 경고 한 줄(388-2) — 저장은 남음 · 면적 · 관 길이는 옛 서버 값.
* 서버가 버린 고침(행 없는 측점 · 기본 설계 못 냄 · 측점 맞춤 실패)도 한 줄(445 M5). */
async function warnUnrecomputed(
request: Promise<SectionConfirmResponse>,
): Promise<SectionConfirmResponse> {
const result = await request;
warnSectionSave(result);
return result;
}
/** 횡단 저장 본문(저장 · 확정 · 한 본문 [저장] 조각 공통). */
export function sectionConfirmBody(
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
massHaul?: Record<string, unknown>,
): Record<string, unknown> {
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 body;
}
/** 횡단 저장 응답의 경고 — 재계산 실패 · 서버가 버린 고침 · 측점 맞춤 실패(한 본문 [저장]도 부름). */
export function warnSectionSave(result: Partial<SectionConfirmResponse>): void {
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);
});
}
/**
* 편집 중인 종·횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장).
* 저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않아 페이지 이동도 없다.
*/
export async function saveSections(
projectId: string,
routeId: number,
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
return warnUnrecomputed(
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>;
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
berms?: Record<
string,
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number }
>;
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
cutSlopeRatios?: Record<string, number>;
/** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */
designChoices?: Record<string, CrossDesignChoice>;
},
): 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,
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<string, number | null>;
/** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */
text: Record<string, string>;
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<CrossQuantitySheet> {
return requestJson<CrossQuantitySheet>(
`/projects/${projectId}/sections/${routeId}/cross-quantity-table`,
{ method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) },
);
}