import { DREDGING_EXTERNAL_MODE } from "../model/disclosure"; /** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */ import { deleteData, getRawJson, postRawJson, putData, } from "@/shared/api/sewerage-api"; import { getBlobData } from "@/shared/api/file-api"; import { requestGridExcel } from "@/shared/api/grid-export"; import { isDredgingBackendEnabled } from "./backend-mode"; import { toDredgingApiError } from "./dredging-error"; import { toDredgingRecordPage, toDredgingPhotos, toNearestPipeMatches, type DredgingRecordListResponse, } from "./response-adapters"; import type { DredgingRecordUpdate, DredgingPhoto, GeoPoint, PipeMatch, } from "../model/types"; import type { AttachmentReference, DredgingRegistrationRequest, } from "../model/registration-request"; export type { AttachmentReference } from "../model/registration-request"; /** 그리드는 0 기반 서버 페이지 하나만 요청한다. 지도·전체 다운로드는 별도 계약이다. */ export type DredgingGridQuery = { viewType: string; fromDate: string; toDate: string; ctpv?: string; sgg?: string; page: number; size: number; sort?: string; }; export const fetchDredgingGridPage = async ( query: DredgingGridQuery, ): Promise<{ content: Array & { recordIds: string[] }>; totalElements: number; number: number; size: number; }> => { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== "") params.set(key, String(value)); }); const body = await getRawJson(`v1/dredging/grid?${params}`); return body?.data ?? body; }; /** 적용 조건 전체의 서버 XLSX를 받는다. page/size는 다운로드에 전달하지 않는다. */ export const downloadDredgingGridExcel = ( query: Omit, ): Promise => { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== "") params.set(key, String(value)); }); return requestGridExcel( `v1/dredging/grid/exportJobs?${params}`, {}, `준설관리_${query.viewType}_${query.fromDate}_${query.toDate}.xlsx`, ); }; type LocationRequest = GeoPoint & { locationKey: string; }; /** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */ export const fetchDredgingRecords = async (params: { fromDate: string; toDate: string; ctpv?: string; sgg?: string; page?: number; size?: number; }): Promise => { 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 toDredgingRecordPage(body); } catch (error) { throw toDredgingApiError( error, "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", "준설현황 조회", ); } }; /** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ export const fetchDredgingPhotos = async ( dredgingJobSn: string | number, pipeFid: string | null, targetSn?: number, ): Promise => { if (!isDredgingBackendEnabled) return []; try { const body = await getRawJson( `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?${targetSn ? `targetSn=${targetSn}` : `fid=${encodeURIComponent(pipeFid ?? "")}`}`, ); return toDredgingPhotos(body); } catch (error) { throw toDredgingApiError( error, "등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", "준설사진 목록 조회", ); } }; /** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */ export const updateDredgingRecord = async ( dredgingJobSn: string | number, update: DredgingRecordUpdate, attachments: Map, ) => { if (!isDredgingBackendEnabled) return true; try { await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, { dredgingDate: update.dredgingDate, targetSn: update.targetSn, registrationMode: update.registrationMode, location: { source: update.locationSource, longitude: update.location.longitude, latitude: update.location.latitude, }, expectedPipe: update.pipe && !DREDGING_EXTERNAL_MODE ? { fid: update.pipe.fid, mngInstCd: update.pipe.mngInstCd, fcltMngNo: update.pipe.fcltMngNo, } : null, 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> => { if (DREDGING_EXTERNAL_MODE || 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, "최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.", "최근접 관로 조회", ); } return toNearestPipeMatches(body); }; /** UI 세션이 확정한 요청을 전송하며 transport 내부에서는 ID를 재생성하지 않는다. */ export const saveDredgingRegistrations = async ( request: DredgingRegistrationRequest, ) => { if (!isDredgingBackendEnabled) { return { mock: true, registeredCount: 1, clientRequestId: request.clientRequestId, }; } try { return await postRawJson("v1/dredging/registrations", request); } catch (error) { throw toDredgingApiError( error, "준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.", "준설결과 저장", ); } };