451 lines
14 KiB
TypeScript
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,
|
|
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
|
|
"준설결과 저장",
|
|
);
|
|
}
|
|
};
|