Files
Aislo/B05_Profile/B05_Profile_Api_Structures.ts
T
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00

276 lines
11 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 { 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";
/** **비워 두는 것이 뜻인 칸** — 비면 계산 쪽이 기준값으로 돌고 그 사실이 사유로 뜬다.
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
* 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */
export interface StructuresSectionOptions {
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
includeDetail?: boolean;
}
/** 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<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;
/**
* 구조물 타입 목록.
*
* `includeDisabled` 를 주면 `enabled:false` 타입(B군 종단배수·F군 생태/녹화·G군 일부)도
* 함께 준다. 레지스트리 주석(2026-08-17)이 「B05 선택지에서 빼고 **B06 개별 횡단도
* 옵션으로 재사용**」이라 적어 둔 그 자리다 — 2026-09-07 사용자 지시 「A군뿐 아니라
* 구조물 전체를 넣을 수 있어야 함」으로 B06 이 그 목록을 쓴다. B05 는 종전대로 켜진 것만.
*/
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
if (!typesCache) {
typesCache = requestJson<StructureTypesResponse>("/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<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 }),
});
}
/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */
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<string, unknown> | 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<string, string | number>,
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<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;
}
/* ── 미저장 구조물 조작분(세션) ────────────────────────────────────────────
* 조작은 세션에만 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장,
* 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어
* 읽기·쓰기·내보내기를 여기 한 곳에 둔다. */
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null {
return readState<StructureInstance[]>("structures", projectId);
}
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
writeState("structures", next, projectId);
}
/**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
*/
export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId);
if (!pending) return;
const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending);
writePendingStructures(projectId, null);
}