Files
Aislo/B05_Profile/B05_Profile_Api_Structures.ts
T
eomsangdonandClaude Fable 5 84eb027348 fix(B05): 크로스체크 2차 4건 반영 — 우클릭 추가 실패·미협의 기본값·STALE 통지
교차검증 2차에서 재현된 실제 실패를 수정한다.

1. 우클릭 구조물 추가가 대부분 저장 거절되던 문제
   - 메뉴가 레지스트리 앞 12개를 그대로 노출해 배관(별도 정본)과 필수 제원이
     있는 C군이 섞였고, addAt()이 값 없이 즉시 저장해 서버가 거절했다.
     실패 항목은 structure_id 없이 화면에 남아 수정·삭제도 막혔다.
   - 메뉴는 managed_by 없고 required 옵션 없는 타입만 노출한다.
   - 필수 입력 타입은 저장 대신 측점을 채운 사이드 폼을 열고 첫 필수 칸에
     포커스를 준다.
   - 저장이 실패하거나 충돌하면 서버 정본을 다시 받아 화면을 되돌린다.

2. 미협의 선택값이 기본값으로 자동 저장되던 문제
   - 재료·형식 선택형 18건의 default를 없애고 required로 바꿨다.
     화면 select에는 "선택하세요" 빈 항목을 두고, defaultOptions()가 첫
     선택지를 대신 채우던 동작을 없앴다.
   - 남긴 default는 법정 단일값, 사용자 확정값(골막이), 표시용 문자열뿐이다.

3. STALE 갱신 실패를 사용자가 알 수 없던 문제
   - 응답에 needs_downstream_invalidation을 추가해 "되돌려야 했는가"와
     "되돌렸는가"를 구분한다. 어긋나면 화면이 B06 재실행을 안내한다.

4. 회귀 방지: 레지스트리 정책 테스트 신설(우클릭 목록 구성·기본값 원칙).

pytest 49건 통과 · tsc 0 · ruff 통과 · npm run build 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:13:19 +09:00

175 lines
6.1 KiB
TypeScript

/* =============================================================================
* 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";
/** 노선 기준 설치 측. 횡단(B06) 연계에 쓴다. */
export type StructureSide = "left" | "right" | "center" | "cross";
export interface StructureOptionField {
key: string;
label: string;
input: "select" | "number" | "text";
choices: string[];
unit: string | null;
default: string | number | null;
/** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */
required?: boolean;
}
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;
side: StructureSide;
offset_m: number;
options: Record<string, string | number>;
memo: string;
placement_source: "manual" | "suggested" | "automatic";
status: "draft" | "confirmed";
revision: number;
geometry: Record<string, unknown> | 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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<StructureType[]> | null = null;
export function fetchStructureTypes(): Promise<StructureType[]> {
if (!typesCache) {
typesCache = requestJson<StructureTypesResponse>("/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<StructureListResponse> {
return requestJson<StructureListResponse>(`/projects/${projectId}/route/structures`, {
method: "GET",
});
}
export async function saveStructures(
projectId: string,
baseRevision: number,
structures: StructureInstance[],
): Promise<StructureSaveResponse> {
return requestJson<StructureSaveResponse>(`/projects/${projectId}/route/structures`, {
method: "PUT",
body: JSON.stringify({ base_revision: baseRevision, structures }),
});
}
/** 종단도 마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정). */
export function structureAnchorM(structure: StructureInstance): number {
return structure.placement === "interval"
? (structure.start_m ?? 0)
: (structure.chainage_m ?? 0);
}
/** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */
export function defaultOptions(type: StructureType): Record<string, string | number> {
const options: Record<string, string | number> = {};
type.options.forEach((field) => {
// 기본값이 있는 항목만 채운다. 필수 선택지의 첫 항목을 대신 넣어 주면 사용자가
// 고르지도 않은 재료·형식이 확정값으로 저장된다(2026-08-16 크로스체크 지적 2).
if (field.default !== null && field.default !== undefined) options[field.key] = field.default;
});
return options;
}