Files
frontend/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts
T

451 lines
14 KiB
TypeScript

import {
deleteData,
getBlobData,
getRawJson,
postRawJson,
putData,
} from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import type { HttpResponse } from "tus-js-client";
import type {
DredgingSaveGroup,
DredgingRecordUpdate,
DredgingPhotoDraft,
DredgingPhoto,
DredgingRecord,
GeoPoint,
PipeMatch,
} from "./types";
import { createDredgingClientUuid } from "./uuid";
type LocationRequest = GeoPoint & {
locationKey: string;
};
export type AttachmentReference = {
atflCd: string;
atflId: string;
};
type NearestPipeResponseItem = {
locationKey: string;
failureCode?: "PIPE_NOT_FOUND" | string | null;
failureMessage?: string | null;
selected?: {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm?: number;
pipeLengthM?: number;
pipeMaterial?: string;
installationYear?: number | null;
swtrmtZoneFid?: string | null;
swtrmtZoneNm?: string | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
} | null;
};
const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
switch (item.failureCode) {
case "PIPE_NOT_FOUND":
return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.";
default:
return (
item.failureMessage?.trim() ??
"최근접 관로를 자동으로 연결하지 못했습니다."
);
}
};
export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false";
/**
* 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다.
* 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다.
*/
const toDredgingApiError = (
error: unknown,
fallbackMessage: string,
operation: string,
) => {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
console.warn(`[준설관리] ${operation} 실패`, {
status: typeof status === "number" ? status : undefined,
errorType: error instanceof Error ? error.name : typeof error,
});
return new Error(fallbackMessage);
};
type DredgingRecordListResponse = {
items: DredgingRecord[];
total: number;
page: number;
size: number;
};
/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */
export const fetchDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
page?: number;
size?: number;
}): Promise<DredgingRecordListResponse> => {
if (!isDredgingBackendEnabled) {
return { items: [], total: 0, page: 0, size: params.size ?? 2000 };
}
const search = new URLSearchParams({
fromDate: params.fromDate,
toDate: params.toDate,
page: String(params.page ?? 0),
size: String(params.size ?? 2000),
});
if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim());
if (params.sgg?.trim()) search.set("sgg", params.sgg.trim());
try {
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
} catch (error) {
throw toDredgingApiError(
error,
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설현황 조회",
);
}
};
/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */
export const fetchAllDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
}) =>
fetchAllPages({
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
getItems: (response) => response.items ?? [],
getTotal: (response) => Number(response.total ?? 0),
});
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
dredgingJobSn: string | number,
pipeFid: string,
): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return [];
try {
const body = await getRawJson(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
);
return (body?.data ?? body ?? []) as DredgingPhoto[];
} catch (error) {
throw toDredgingApiError(
error,
"등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 목록 조회",
);
}
};
/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */
export const updateDredgingRecord = async (
dredgingJobSn: string | number,
update: DredgingRecordUpdate,
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) return true;
try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid,
registrationMode: update.registrationMode,
location: {
source: update.locationSource,
longitude: update.location.longitude,
latitude: update.location.latitude,
},
expectedPipe: {
fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo,
},
retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
});
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 수정",
);
}
};
/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */
export const deleteDredgingRecord = async (dredgingJobSn: string | number) => {
if (!isDredgingBackendEnabled) return true;
try {
const response = await deleteData(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`,
);
if (response instanceof Response && !response.ok) {
throw new Error(`준설작업 삭제 응답 오류: ${response.status}`);
}
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 삭제",
);
}
};
/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */
export const fetchDredgingPhotoBlob = async (atflId: string) => {
try {
return await getBlobData(
`/attach-service/api/v1/download/${encodeURIComponent(atflId)}`,
);
} catch (error) {
throw toDredgingApiError(
error,
"사진 원본을 불러오지 못했습니다.",
"준설사진 원본 조회",
);
}
};
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
const first = locations[0];
const longitude = first?.longitude ?? 127.5;
const latitude = first?.latitude ?? 36.5;
const seed = Math.abs(
Math.round(longitude * 100_000) + Math.round(latitude * 100_000),
);
const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2));
return {
fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`,
mngInstCd: "4173000",
fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`,
pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450,
pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)),
pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트",
installationYear: 1995 + (seed % 25),
swtrmtZoneFid: "MOCK-ZONE",
swtrmtZoneNm: "모의처리구역",
distanceMeters,
snappedLongitude: longitude + 0.00001,
snappedLatitude: latitude - 0.00001,
isMock: true,
};
};
/**
* 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다.
* FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다.
*/
export const findNearestPipeMatches = async (
locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations);
return new Map(
locations.map((location) => [location.locationKey, mockPipe]),
);
}
let body: any;
try {
body = await postRawJson("v1/dredging/nearestPipes", { locations });
} catch (error) {
throw toDredgingApiError(
error,
"최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.",
"최근접 관로 조회",
);
}
const items = (body?.data ?? body) as NearestPipeResponseItem[];
const matches = new Map<string, PipeMatch>();
const failureMessages: string[] = [];
for (const item of items ?? []) {
if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item));
continue;
}
matches.set(item.locationKey, {
fid: item.selected.fid,
mngInstCd: item.selected.mngInstCd,
fcltMngNo: item.selected.fcltMngNo,
pipeDiameterMm: item.selected.pipeDiameterMm ?? 0,
pipeLengthM: item.selected.pipeLengthM ?? 0,
pipeMaterial: item.selected.pipeMaterial ?? "",
installationYear: item.selected.installationYear ?? null,
swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null,
swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "",
distanceMeters: item.selected.distanceMeters,
snappedLongitude: item.selected.snappedLongitude,
snappedLatitude: item.selected.snappedLatitude,
isMock: false,
});
}
if (failureMessages.length > 0) {
throw new Error(Array.from(new Set(failureMessages)).join(" "));
}
return matches;
};
const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
new Promise((resolve, reject) => {
const transfer = new DataTransfer();
transfer.items.add(file);
const timeoutId = window.setTimeout(() => {
reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`));
}, 90_000);
const handleResponse = (response: HttpResponse) => {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) return;
try {
const parsed = JSON.parse(fileInfo) as Partial<AttachmentReference>;
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부 식별자가 응답에 없습니다.");
}
window.clearTimeout(timeoutId);
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
};
try {
handleUpload(transfer.files, { handleResponse });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
});
/**
* 공통 attach-service TUS 업로드를 순차 실행한다.
* 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다.
*/
export const uploadDredgingPhotos = async (
groups: DredgingSaveGroup[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
return uploadDredgingPhotoDrafts(
groups.flatMap((group) => group.photos),
existingReferences,
);
};
/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */
export const uploadDredgingPhotoDrafts = async (
photos: DredgingPhotoDraft[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
const references = new Map(existingReferences);
if (!isDredgingBackendEnabled) return references;
try {
for (const photo of photos) {
// DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다.
if (references.has(photo.key)) continue;
references.set(photo.key, await uploadOnePhoto(photo.file));
}
} catch (error) {
throw toDredgingApiError(
error,
"준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 업로드",
);
}
return references;
};
export const saveDredgingRegistrations = async (
groups: DredgingSaveGroup[],
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) {
return {
mock: true,
registeredCount: 1,
clientRequestId: createDredgingClientUuid(),
};
}
const clientRequestId = createDredgingClientUuid();
try {
return await postRawJson("v1/dredging/registrations", {
clientRequestId,
dredgingDate: groups[0]?.dredgingDate,
groups: groups.map((group, groupIndex) => ({
groupSn: groupIndex + 1,
registrationMode: group.registrationMode,
location: {
source: group.locationSource,
longitude: group.location.longitude,
latitude: group.location.latitude,
},
expectedPipe: {
fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo,
},
photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
})),
});
} catch (error) {
throw toDredgingApiError(
error,
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설결과 저장",
);
}
};