import type { ExmnAttachmentKey, ExmnBusiness, ExmnBusinessDetail, ExmnBusinessSaveRequest, ExmnBusinessSearchParams, ExmnFileType, ExmnImportCommitResult, ExmnImportValidationResult, ExmnJobType, ExmnPage, ExmnPipeReportAvailability, ExmnSectionSummaryRow, ExmnTemplateResult, } from "../model/exmn-business-types"; import { type ExmnDefectGroup, type ExmnDetailSeverity, type ExmnPplUnitDetailRow, type ExmnSurveyDetailRow, PIPE_DETAIL_DEFECT_COLUMNS, } from "../model/exmn-detail-table"; import { getBlobData, postMutiPart } from "@/shared/api/file-api"; import { getData, getRawJson, postRawJson, putData } from "@/shared/api/sewerage-api"; /** 조사사업 API의 공통 기준 경로다. */ const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses"; /** 첨부서비스의 사업보고서 원본 파일명 조회에 필요한 최소 응답 계약이다. */ interface AttachmentMetadata { atflId?: string | null; orgnlFileNm?: string | null; } /** 공통 응답 원문 또는 data 래퍼 양쪽에서 실제 payload를 해석한다. */ const unwrapData = (response: T | { data?: T }): T => typeof response === "object" && response !== null && "data" in response ? (response as { data: T }).data : (response as T); /** Spring Page 응답이 비어 있거나 아직 미구현이어도 안전한 빈 Page로 정규화한다. */ const toPage = ( value: Partial> | null | undefined, ): ExmnPage => ({ content: Array.isArray(value?.content) ? value.content : [], totalElements: Number(value?.totalElements ?? 0), number: Number(value?.number ?? 0), size: Number(value?.size ?? 20), totalPages: Number(value?.totalPages ?? 0), }); /** * 사업보고서 첨부 코드로 원본 파일명을 조회한다. * 첨부 메타데이터 장애가 조사사업 목록 전체를 막지 않도록 실패 시 null로 대체한다. */ const getBusinessReportFileName = async ( attachmentCode: string, attachmentId: string, ): Promise => { try { const response = (await getRawJson( `/attach-service/api/v1/atfl/attachments/${encodeURIComponent(attachmentCode)}`, )) as | AttachmentMetadata[] | { data?: AttachmentMetadata[] | { content?: AttachmentMetadata[] } } | { content?: AttachmentMetadata[] } | null; let payload: | AttachmentMetadata[] | { content?: AttachmentMetadata[] } | null | undefined; if (Array.isArray(response)) { payload = response; } else if (response && "data" in response) { payload = response.data; } else { payload = response as { content?: AttachmentMetadata[] } | null; } const attachments = Array.isArray(payload) ? payload : Array.isArray(payload?.content) ? payload.content : []; const attachment = attachments.find((item) => item.atflId === attachmentId) ?? attachments[0]; return attachment?.orgnlFileNm?.trim() || null; } catch { return null; } }; /** 조사사업 목록을 조회조건·0-base 페이지·서버 정렬 계약으로 요청한다. */ export const getExmnBusinesses = async ( params: ExmnBusinessSearchParams, ): Promise> => { const query = new URLSearchParams({ page: String(params.page), size: String(params.size), }); if (params.bizCycl?.trim()) query.set("bizCycl", params.bizCycl.trim()); if (params.bizYr?.trim()) query.set("bizYr", params.bizYr.trim()); if (params.ctpvNm?.trim()) query.set("ctpvNm", params.ctpvNm.trim()); if (params.sggNm?.trim()) query.set("sggNm", params.sggNm.trim()); if (params.bizNm?.trim()) query.set("bizNm", params.bizNm.trim()); if (params.sort) query.set("sort", params.sort); const page = toPage( await getData(`${EXMN_BUSINESS_API_PATH}?${query}`), ); const content = await Promise.all( page.content.map(async (business) => { const attachmentCode = business.bizRptpAtchFileCd?.trim(); const attachmentId = business.bizRptpAtchFileId?.trim(); if (!attachmentCode || !attachmentId) return business; return { ...business, bizRptpFileName: await getBusinessReportFileName( attachmentCode, attachmentId, ), }; }), ); return { ...page, content }; }; /** 선택 사업의 처리분구별 관로·맨홀 조사 상세현황을 조회한다. */ export const getExmnSectionSummaries = async ( exmnBizId: string, jobType: ExmnJobType, ): Promise => { type SectionSummaryPayload = | ExmnSectionSummaryRow[] | Partial>; try { const response = (await getData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`, )) as SectionSummaryPayload | { data?: SectionSummaryPayload }; const payload = unwrapData(response); if (Array.isArray(payload)) return payload; return Array.isArray(payload?.content) ? payload.content : []; } catch (error) { // 상세현황 API가 아직 없거나 선택 사업에 조사결과가 없는 404는 정상 빈 목록으로 표시한다. if ((error as { status?: number } | null)?.status === 404) return []; throw error; } }; /** * 현재 조사유형의 종합집계 1개와 처리분구별 전체 상세 시트를 하나의 XLSX로 내려받는다. * 관로 본단위구간은 이 다운로드 계약에 포함하지 않는다. */ export const downloadExmnSectionSummary = async ( exmnBizId: string, jobType: ExmnJobType, ): Promise => { const query = new URLSearchParams({ jobType }); return getBlobData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections/download?${query}`, ); }; /** 선택 사업·처리분구의 관로·맨홀 전체 상세 집계 행을 조회한다. */ export const getExmnFacilityDetails = async (params: { exmnBizId: string; jobType: ExmnJobType; trtdstId: string; gisManagementNo?: string; defectType?: ExmnDefectGroup | "ALL"; }): Promise => { const query = new URLSearchParams({ exmnBizId: params.exmnBizId, jobType: params.jobType, trtdstId: params.trtdstId, }); if (params.gisManagementNo?.trim()) { query.set("gisManagementNo", params.gisManagementNo.trim()); } if (params.defectType && params.defectType !== "ALL") { query.set("defectType", params.defectType); } const response = (await getData(`v1/exmnFacilities?${query}`)) as | ExmnSurveyDetailRow[] | { data?: ExmnSurveyDetailRow[] }; const payload = unwrapData(response); return Array.isArray(payload) ? payload : []; }; interface ExmnPplUnitDetailPayload { pipeNo: string; unitSeq: number; unitLengthM: number; defectPositionM: number | null; code: string; recordType: "STATUS" | "DEFECT" | null; defectType: ExmnDefectGroup | null; severity: ExmnDetailSeverity | null; occurrencePosition: string; infiltrationBonus: number | null; continuous: boolean | null; structuralScore: number | null; operationalScore: number | null; surveyStopReason: string | null; remark: string | null; } /** 조사 상세 표의 0 기반 서버 페이지와 조건 전체 합계를 반환한다. */ export const getExmnFacilityPage = async (params: { exmnBizId: string; jobType: ExmnJobType; trtdstId: string; gisManagementNo?: string; defectType: string; defectCodes: string; page: number; size: number; sort?: string; }): Promise<{ content: ExmnSurveyDetailRow[]; totalElements: number; summary: ExmnSurveyDetailRow }> => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") query.set(key, String(value)); }); return unwrapData(await getData(`v1/exmnFacilities/page?${query}`)); }; /** 관로·단위구간 병합을 보존한 서버 그룹 페이지를 화면 표시명으로 변환한다. */ export const getExmnPplUnitPage = async (params: { exmnBizId: string; trtdstId: string; gisManagementNo?: string; page: number; size: number; }): Promise<{ content: ExmnPplUnitDetailRow[]; totalElements: number }> => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") query.set(key, String(value)); }); const result = unwrapData<{ content: ExmnPplUnitDetailPayload[]; totalElements: number }>( await getData(`v1/exmnFacilities/pplUnitSections/page?${query}`), ); return { ...result, content: result.content.map((row) => ({ ...row, defectName: getPplUnitDetailName(row) })) }; }; export const PPL_STATUS_LABELS: Record = { IS: "조사시작(순주행)", ISR: "조사시작(역주행)", IE: "조사완료(순주행)", IER: "조사완료(역주행)", IA: "조사중단", JE: "이음부(접합부)존재", WD: "이상 없음", }; const getPplUnitDetailName = (row: ExmnPplUnitDetailPayload) => { const code = row.code?.trim(); if (!code) return ""; const normalizedCode = code.toUpperCase(); if (row.recordType === "STATUS") { return `(상태)${PPL_STATUS_LABELS[normalizedCode] ?? code}`; } const defect = PIPE_DETAIL_DEFECT_COLUMNS.find( (column) => column.code.toUpperCase() === normalizedCode, ); return `(관로)${defect?.label ?? code}`; }; /** 선택 처리분구의 PPL_UNIT_V1 본단위구간 상태·결함 행을 조회한다. */ export const getExmnPplUnitDetails = async (params: { exmnBizId: string; trtdstId: string; gisManagementNo?: string; }): Promise => { const query = new URLSearchParams({ exmnBizId: params.exmnBizId, trtdstId: params.trtdstId, }); if (params.gisManagementNo?.trim()) { query.set("gisManagementNo", params.gisManagementNo.trim()); } const response = (await getData( `v1/exmnFacilities/pplUnitSections?${query}`, )) as ExmnPplUnitDetailPayload[] | { data?: ExmnPplUnitDetailPayload[] }; const payload = unwrapData(response); if (!Array.isArray(payload)) return []; return payload.map((row) => ({ ...row, defectName: getPplUnitDetailName(row), })); }; /** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */ export const getExmnBusiness = async ( exmnBizId: string, ): Promise => getData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`, ) as Promise; /** 조사사업과 선택 처리분구별 기본 요약행을 신규 저장한다. */ export const createExmnBusiness = async ( request: ExmnBusinessSaveRequest, ): Promise => unwrapData( await postRawJson(EXMN_BUSINESS_API_PATH, request), ); /** 기존 조사사업 기본정보와 연결 처리분구를 같은 저장 계약으로 갱신한다. */ export const updateExmnBusiness = async ( exmnBizId: string, request: ExmnBusinessSaveRequest, ): Promise => putData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`, request, ) as Promise; /** 저장된 사업의 처리분구별 PPL/MNHL 엑셀 시트명을 생성한다. */ export const createExmnTemplates = async ( exmnBizId: string, trtdstIds: string[], ): Promise => unwrapData( await postRawJson( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates`, { trtdstIds }, ), ); /** 생성된 조사사업 입력 양식 파일을 내려받는다. */ export const downloadExmnTemplates = async (exmnBizId: string): Promise => getBlobData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`, ); /** 하수관로·맨홀 현황조사 보고서 기준 XLSX를 내려받는다. */ export const downloadExmnReportTemplate = async (): Promise => getBlobData(`${EXMN_BUSINESS_API_PATH}/templates/report/download`); /** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */ export const downloadExmnBusinessReport = async ( attachmentId: string, ): Promise => getBlobData( `/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`, ); /** 저장된 페이지 범위를 이용해 선택 관로의 조사보고서 구간만 PDF로 조회한다. */ export const downloadExmnPipeReport = async ( exmnBizId: string, pipeNumber: string, jobType: "PPL" | "MNHL" = "PPL", ): Promise => getBlobData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}`, ); /** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */ /** 팝업 생성 전에 선택 관로번호가 등록된 관로 PDF에 포함되는지 확인한다. */ export const getExmnPipeReportAvailability = async ( exmnBizId: string, pipeNumber: string, jobType: "PPL" | "MNHL" = "PPL", ): Promise => getData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}/availability`, ) as Promise; export const updateExmnAttachments = async ( exmnBizId: string, attachments: Partial>, ): Promise>> => putData( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/attachments`, { attachments }, ) as Promise>>; /** 원본 PPL/MNHL 파일의 업무·JSON 정합성을 서버에서 검토한다. */ export const validateExmnImport = async ( exmnBizId: string, formData: FormData, ): Promise => postMutiPart( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/validate`, formData, ) as Promise; /** 검토했던 동일 파일과 첨부키를 다시 보내 시설 저장·요약 집계를 확정한다. */ export const commitExmnImport = async ( exmnBizId: string, formData: FormData, ): Promise => postMutiPart( `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/commit`, formData, ) as Promise;