From 261bc4f91f8b27e36ae0a597e078b152062df21c Mon Sep 17 00:00:00 2001 From: NYD Date: Tue, 6 Jan 2026 17:23:53 +0900 Subject: [PATCH] add_cow_data_batch_insert --- backend/.env | 3 +- backend/src/admin/admin.controller.ts | 117 +++ backend/src/admin/admin.module.ts | 27 + backend/src/admin/admin.service.ts | 847 ++++++++++++++++++++++ backend/src/app.module.ts | 6 + backend/src/common/dto/base.result.dto.ts | 37 + backend/src/common/excel/excel.module.ts | 14 + backend/src/common/excel/excel.util.ts | 70 ++ backend/src/common/utils.ts | 75 ++ backend/src/mpt/dto/mpt.dto.ts | 29 + frontend/src/app/admin/upload/page.tsx | 33 +- frontend/src/app/dashboard/page.tsx | 4 +- 12 files changed, 1252 insertions(+), 10 deletions(-) create mode 100644 backend/src/admin/admin.controller.ts create mode 100644 backend/src/admin/admin.module.ts create mode 100644 backend/src/admin/admin.service.ts create mode 100644 backend/src/common/dto/base.result.dto.ts create mode 100644 backend/src/common/excel/excel.module.ts create mode 100644 backend/src/common/excel/excel.util.ts create mode 100644 backend/src/common/utils.ts create mode 100644 backend/src/mpt/dto/mpt.dto.ts diff --git a/backend/.env b/backend/.env index 2838882..9daf959 100644 --- a/backend/.env +++ b/backend/.env @@ -3,7 +3,8 @@ # ============================================== # DATABASE -POSTGRES_HOST=192.168.11.46 +# POSTGRES_HOST=192.168.11.46 +POSTGRES_HOST=localhost POSTGRES_USER=genome POSTGRES_PASSWORD=genome1@3 POSTGRES_DB=genome_db diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..cb09960 --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,117 @@ +import { BadRequestException, Body, Controller, Get, Post, UploadedFile, UseInterceptors, Logger } from "@nestjs/common"; +import { AdminService } from "./admin.service"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { basename, extname, join } from "path"; +import { BaseResultDto } from "src/common/dto/base.result.dto"; +import { diskStorage } from "multer"; + +import * as fs from 'fs/promises'; +import { randomUUID } from "crypto"; +import { tmpdir } from "os"; + + +/** + ※업로드 관련 기능 추후 공통화 처리 필요. + **/ +const ALLOWED_EXTENSIONS = ['.xlsx', '.txt', '.csv', '.xls']; // 파일 업로드 허용 확장자 +const ALLOWED_MIME_TYPES = [ // 파일 업로드 허용 MIME 타입 + 'text/plain', + // XLSX + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + // XLS (구형 + CSV도 섞여 나옴) + 'application/vnd.ms-excel', + // CSV 계열 + 'text/csv', + 'application/csv', + 'text/plain', + // 한컴 + 'application/haansoftxls', +]; + +@Controller('admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + private readonly logger = new Logger(AdminController.name); + + @Get('dashboard') + getDashboard() { + return null; + } + + + + + @Post('batchUpload') + @UseInterceptors(FileInterceptor('file', { + storage: diskStorage({ + destination: async (req, file, callback) => { + // 환경 변수가 없으면 시스템 임시 디렉터리 사용 + const uploadDir = process.env.UPLOAD_DESTINATION + ? join(process.env.UPLOAD_DESTINATION, 'tmp') + : join(tmpdir(), 'genome2025-uploads'); + + try { + // 디렉터리가 없으면 생성 + await fs.mkdir(uploadDir, { recursive: true }); + callback(null, uploadDir); + } catch (error) { + callback(error, null); + } + }, + filename: (req, file, callback) => { + const ext = extname(file.originalname).toLowerCase(); + callback(null, `${randomUUID()}-${Date.now()}${ext}`); + }, + }), + fileFilter: (req, file, callback) => { // 파일 업로드 필터링 + const ext = extname(file.originalname).toLowerCase(); + const mime = file.mimetype; + + if (!ALLOWED_EXTENSIONS.includes(ext)) { // 허용되지 않은 확장자 필터링 + return callback( + new BadRequestException(`허용되지 않은 확장자: ${ext}`), + false, + ); + } + + if (!ALLOWED_MIME_TYPES.includes(mime)) { // 허용되지 않은 MIME 타입 필터링 + return callback( + new BadRequestException(`허용되지 않은 MIME 타입: ${mime}`), + false, + ); + } + + callback(null, true); + } + })) + async batchUpload(@UploadedFile() file: Express.Multer.File, @Body('div') div: string) { + let divName = ''; + try { + if (!file?.path){ + throw new BadRequestException('파일 업로드 실패') + }; + + if (div === 'genome-result') { // 유전체 분석 결과(DGV) + divName = '유전체 분석 결과(DGV)'; + await this.adminService.batchInsertGenomeResult(file); + }else if (div === 'snp-typing') { // 개체별 SNP 타이핑 결과(유전자 타이핑 결과) + divName = '개체별 SNP 타이핑 결과(유전자 타이핑 결과)'; + await this.adminService.batchInsertSnpTyping(file); + }else if (div === 'mpt-result') { // MPT 분석결과(종합혈액화학검사결과서) + divName = 'MPT 분석결과(종합혈액화학검사결과서)'; + await this.adminService.batchInsertMptResult(file); + } + // else if (div === 'animal-info') { // 소 정보 입력은 어디서 처리? + // divName = '소 정보 입력'; + // return this.adminService.batchUploadAnimalInfo(file); + // } + return BaseResultDto.ok(`${divName} 파일 업로드 성공.\n데이터 입력 중...`, 'SUCCESS', 'OK'); + } catch (error) { + return BaseResultDto.fail(`${divName} 파일 업로드 실패.\n${error.message}`, 'FAIL'); + } finally { + await fs.unlink(file.path).catch(() => {}); // 파일 삭제 + this.logger.log(`[batchUpload] ${divName} 파일 업로드 완료`); + } + } +} \ No newline at end of file diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..9fb3cdc --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,27 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AdminController } from "./admin.controller"; +import { AdminService } from "./admin.service"; +import { GenomeRequestModel } from "../genome/entities/genome-request.entity"; +import { CowModel } from "../cow/entities/cow.entity"; +import { GenomeTraitDetailModel } from "../genome/entities/genome-trait-detail.entity"; +import { MptModel } from "src/mpt/entities/mpt.entity"; +import { FarmModel } from "src/farm/entities/farm.entity"; +import { GeneDetailModel } from "src/gene/entities/gene-detail.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + GenomeRequestModel, + CowModel, + GenomeTraitDetailModel, + MptModel, + FarmModel, + GeneDetailModel, + ]), + ], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} \ No newline at end of file diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..cb33253 --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,847 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { GenomeTraitDetailModel } from "src/genome/entities/genome-trait-detail.entity"; +import { GenomeRequestModel } from "src/genome/entities/genome-request.entity"; +import { CowModel } from "src/cow/entities/cow.entity"; +import { Repository, IsNull, In } from "typeorm"; +import { MptModel } from "src/mpt/entities/mpt.entity"; +import { MptDto } from "src/mpt/dto/mpt.dto"; +import { parseNumber, parseDate } from "src/common/utils"; +import { ExcelUtil } from "src/common/excel/excel.util"; +import { createReadStream } from "fs"; +import * as readline from 'readline'; +import { GeneDetailModel } from "src/gene/entities/gene-detail.entity"; + + + + + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor( + // 유전체 분석 결과 Repository + @InjectRepository(GenomeTraitDetailModel) + private readonly genomeTraitDetailRepository: Repository, + + // 유전체 분석 의뢰 Repository + @InjectRepository(GenomeRequestModel) + private readonly genomeRequestRepository: Repository, + + // 소 개체 Repository + @InjectRepository(CowModel) + private readonly cowRepository: Repository, + + // 혈액화학검사 결과 Repository + @InjectRepository(MptModel) + private readonly mptRepository: Repository, + + // 유전자 상세 정보 Repository + @InjectRepository(GeneDetailModel) + private readonly geneDetailRepository: Repository, + + @Inject(ExcelUtil) + private readonly excelUtil: ExcelUtil, + ) {} + + + async batchInsertGenomeResult(file: Express.Multer.File) { + this.logger.log(`[배치업로드] 유전체 분석 결과 파일 처리 시작: ${file.originalname}`); + + try { + // 엑셀파일 로드 + const rawData = this.excelUtil.parseExcelData(file); + + const headerRow = rawData[0]; + const dataRows = rawData.slice(1); + + // 헤더에서 형질명 추출 및 인덱스 매핑 + // 컬럼 구조: A(samplename), B, C, D~DD(형질 데이터) + // 형질 패턴: "XXXXX", "XXXXX_표준화육종가", "XXXXX_백분율" + + const traitMap = new Map(); + + // 헤더 분석 (인덱스 3부터 시작, D열부터) + for (let colIdx = 3; colIdx < headerRow.length && colIdx <= 107; colIdx++) { + const headerValue = headerRow[colIdx]; + if (!headerValue || typeof headerValue !== 'string') { + continue; + } + + const trimmedHeader = headerValue.trim(); + + // 형질명 추출 (접미사 제거) + if (trimmedHeader.endsWith('_표준화육종가')) { + const traitName = trimmedHeader.replace('_표준화육종가', ''); + if (!traitMap.has(traitName)) { + traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null }); + } + traitMap.get(traitName)!.ebvIndex = colIdx; + } else if (trimmedHeader.endsWith('_백분율')) { + const traitName = trimmedHeader.replace('_백분율', ''); + if (!traitMap.has(traitName)) { + traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null }); + } + traitMap.get(traitName)!.percentileIndex = colIdx; + } else { + // 형질명 단독 (trait_val) + // 이미 존재하는 형질인지 확인 (접미사가 있는 경우 이미 추가됨) + if (!traitMap.has(trimmedHeader)) { + traitMap.set(trimmedHeader, { name: trimmedHeader, valIndex: null, ebvIndex: null, percentileIndex: null }); + } + traitMap.get(trimmedHeader)!.valIndex = colIdx; + } + } + + this.logger.log(`[배치업로드] 형질 개수: ${traitMap.size}개`); + + // 데이터 행 처리 + const traitDataArray: Array<{ + cowId: string; + traitName: string; + traitVal: number | null; + traitEbv: number | null; + traitPercentile: number | null; + }> = []; + + for (let rowIdx = 0; rowIdx < dataRows.length; rowIdx++) { + const row = dataRows[rowIdx]; + + // A열(인덱스 0): cowId (samplename) + const cowId = row[0]; + if (!cowId || typeof cowId !== 'string' || !cowId.trim()) { + this.logger.warn(`[배치업로드] ${rowIdx + 2}행: cowId가 없어 건너뜀`); + continue; + } + + const trimmedCowId = cowId.trim(); + + // 각 형질별로 데이터 추출 + for (const [traitName, indices] of traitMap.entries()) { + const traitVal = indices.valIndex !== null && row[indices.valIndex] !== null && row[indices.valIndex] !== undefined + ? parseNumber(row[indices.valIndex]) + : null; + + const traitEbv = indices.ebvIndex !== null && row[indices.ebvIndex] !== null && row[indices.ebvIndex] !== undefined + ? parseNumber(row[indices.ebvIndex]) + : null; + + const traitPercentile = indices.percentileIndex !== null && row[indices.percentileIndex] !== null && row[indices.percentileIndex] !== undefined + ? parseNumber(row[indices.percentileIndex]) + : null; + + // 값이 하나라도 있으면 추가 + if (traitVal !== null || traitEbv !== null || traitPercentile !== null) { + traitDataArray.push({ + cowId: trimmedCowId, + traitName: traitName, + traitVal: traitVal, + traitEbv: traitEbv, + traitPercentile: traitPercentile, + }); + } + } + } + + this.logger.log(`[배치업로드] 파싱 완료: ${traitDataArray.length}개 형질 데이터`); + + // ============================================ + // 3단계: Json Array 각 객체 fk_request_no 추가 + // - fk_request_no 추가 방법 : 소 식별번호(cowId)로 조회 후 fk_request_no 추가 + // ============================================ + this.logger.log('[배치업로드] 3단계: fk_request_no 조회 중...'); + + // 고유한 cowId 목록 추출 + const uniqueCowIds = [...new Set(traitDataArray.map(item => item.cowId))]; + this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}개`); + + // cowId별로 Cow와 GenomeRequest 조회 + const cowIdToRequestNoMap = new Map(); + + for (const cowId of uniqueCowIds) { + try { + // Step 1: cowId로 개체 조회 + const cow = await this.cowRepository.findOne({ + where: { cowId: cowId, delDt: IsNull() }, + }); + + if (!cow) { + this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`); + cowIdToRequestNoMap.set(cowId, null); + continue; + } + + // Step 2: 해당 개체의 최신 분석 의뢰 조회 + const request = await this.genomeRequestRepository.findOne({ + where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, + order: { requestDt: 'DESC', regDt: 'DESC' }, + }); + + if (!request) { + this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 유전체 분석 의뢰를 찾을 수 없습니다.`); + cowIdToRequestNoMap.set(cowId, null); + continue; + } + + cowIdToRequestNoMap.set(cowId, request.pkRequestNo); + } catch (error) { + this.logger.error(`[배치업로드] cowId "${cowId}" 조회 중 오류: ${error.message}`); + cowIdToRequestNoMap.set(cowId, null); + } + } + + // fk_request_no 추가 + const traitDataWithRequestNo = traitDataArray.map(item => { + const fkRequestNo = cowIdToRequestNoMap.get(item.cowId); + return { + ...item, + fkRequestNo: fkRequestNo, + }; + }); + + // fk_request_no가 없는 데이터 필터링 + const validTraitData = traitDataWithRequestNo.filter(item => item.fkRequestNo !== null); + const invalidCount = traitDataWithRequestNo.length - validTraitData.length; + + if (invalidCount > 0) { + this.logger.warn(`[배치업로드] fk_request_no가 없는 데이터 ${invalidCount}건 제외`); + } + + this.logger.log(`[배치업로드] 유효한 형질 데이터: ${validTraitData.length}건`); + + // ============================================ + // 4단계: 데이터 DB Insert : 비동기 batchInsert(upsert) + // ============================================ + this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...'); + + let successCount = 0; + let errorCount = 0; + + // 배치 크기 설정 (한 번에 처리할 데이터 수) + const BATCH_SIZE = 100; + + for (let i = 0; i < validTraitData.length; i += BATCH_SIZE) { + const batch = validTraitData.slice(i, i + BATCH_SIZE); + + try { + // 각 배치를 upsert 처리 + const insertPromises = batch.map(async (item) => { + try { + // 기존 데이터 조회 (cowId와 traitName 기준) + const existing = await this.genomeTraitDetailRepository.findOne({ + where: { + cowId: item.cowId, + traitName: item.traitName, + delDt: IsNull(), + }, + }); + + if (existing) { + // 업데이트 + existing.fkRequestNo = item.fkRequestNo!; + existing.traitVal = item.traitVal; + existing.traitEbv = item.traitEbv; + existing.traitPercentile = item.traitPercentile; + await this.genomeTraitDetailRepository.save(existing); + } else { + // 삽입 + const newTraitDetail = this.genomeTraitDetailRepository.create({ + fkRequestNo: item.fkRequestNo!, + cowId: item.cowId, + traitName: item.traitName, + traitVal: item.traitVal, + traitEbv: item.traitEbv, + traitPercentile: item.traitPercentile, + }); + await this.genomeTraitDetailRepository.save(newTraitDetail); + } + return true; + } catch (error) { + this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, traitName: ${item.traitName}): ${error.message}`); + return false; + } + }); + + const results = await Promise.all(insertPromises); + successCount += results.filter(r => r === true).length; + errorCount += results.filter(r => r === false).length; + + this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validTraitData.length)}/${validTraitData.length}`); + } catch (error) { + this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`); + errorCount += batch.length; + } + } + + // ============================================ + // 5단계: 결과 로깅 + // ============================================ + this.logger.log(`[배치업로드] 처리 완료`); + this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}건`); + + return { + success: true, + total: traitDataArray.length, + valid: validTraitData.length, + successCount: successCount, + errorCount: errorCount, + excludedCount: invalidCount, + }; + } catch (error) { + this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`); + this.logger.error(error.stack); + throw error; + } + } + + + async batchInsertSnpTyping(file: Express.Multer.File) { + this.logger.log(`[배치업로드] SNP 타이핑 결과 파일 처리 시작: ${file.originalname}`); + + try { + // ============================================ + // 1단계: 텍스트 파일 스트림 읽기 + // ============================================ + this.logger.log('[배치업로드] 1단계: 텍스트 파일 스트림 읽기 중...'); + + const stream = createReadStream(file.path, { + encoding: 'utf-8', + highWaterMark: 64 * 1024, // 64KB 버퍼 + }); + + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let section: 'NONE' | 'HEADER' | 'DATA_WAIT_HEADER' | 'DATA' = 'NONE'; + let dataHeader: string[] | null = null; + + // 컬럼 인덱스 매핑 + const COLUMN_MAPPING = { + SNP_NAME: 'SNP Name', + SAMPLE_ID: 'Sample ID', + ALLELE1: 'Allele1 - Top', + ALLELE2: 'Allele2 - Top', + CHR: 'Chr', + POSITION: 'Position', + SNP: 'SNP', + }; + + // 컬럼 인덱스 캐시 (헤더 읽은 후 한 번만 계산) + let columnIndices: { + snpNameIdx: number; + sampleIdIdx: number; + allele1Idx: number; + allele2Idx: number; + chrIdx: number; + positionIdx: number; + snpIdx: number; + } | null = null; + + // ============================================ + // 2단계: 스트림 방식으로 파일 파싱 및 배치 단위 DB 저장 + // ============================================ + this.logger.log('[배치업로드] 2단계: 스트림 방식 파일 파싱 및 배치 저장 중...'); + + // 배치 버퍼 (메모리에 일정 크기만 유지) + const BATCH_SIZE = 5000; // 배치 크기 (메모리 사용량 제어) + const batchBuffer: Array<{ + cowId: string; + snpName: string; + chromosome: string | null; + position: string | null; + snpType: string | null; + allele1: string | null; + allele2: string | null; + }> = []; + + let totalRows = 0; + let successCount = 0; + let errorCount = 0; + let skippedCount = 0; + + // 배치 저장 함수 + const flushBatch = async () => { + if (batchBuffer.length === 0) return; + + try { + // 배치 단위로 upsert 처리 + const insertPromises = batchBuffer.map(async (item) => { + try { + // 기존 데이터 조회 (cowId와 snpName 기준) + const existing = await this.geneDetailRepository.findOne({ + where: { + cowId: item.cowId, + snpName: item.snpName, + delDt: IsNull(), + }, + }); + + if (existing) { + // 업데이트 + existing.fkRequestNo = null; // cowId 조회 제거로 null 처리 + existing.chromosome = item.chromosome; + existing.position = item.position; + existing.snpType = item.snpType; + existing.allele1 = item.allele1; + existing.allele2 = item.allele2; + await this.geneDetailRepository.save(existing); + } else { + // 삽입 + const newGeneDetail = this.geneDetailRepository.create({ + cowId: item.cowId, + snpName: item.snpName, + fkRequestNo: null, // cowId 조회 제거로 null 처리 + chromosome: item.chromosome, + position: item.position, + snpType: item.snpType, + allele1: item.allele1, + allele2: item.allele2, + }); + await this.geneDetailRepository.save(newGeneDetail); + } + return true; + } catch (error) { + this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, snpName: ${item.snpName}): ${error.message}`); + return false; + } + }); + + const results = await Promise.all(insertPromises); + const batchSuccess = results.filter(r => r === true).length; + const batchError = results.filter(r => r === false).length; + + successCount += batchSuccess; + errorCount += batchError; + + this.logger.log(`[배치업로드] 배치 저장 완료: ${batchSuccess}건 성공, ${batchError}건 실패 (총 처리: ${totalRows}건)`); + } catch (error) { + this.logger.error(`[배치업로드] 배치 저장 중 오류: ${error.message}`); + errorCount += batchBuffer.length; + } finally { + // 배치 버퍼 초기화 (메모리 해제) + batchBuffer.length = 0; + } + }; + + // 파일 라인별 처리 + for await (const rawLine of rl) { + const line = rawLine.trim(); + if (!line) continue; + + // 섹션 전환 + if (line === '[Header]') { + section = 'HEADER'; + continue; + } + if (line === '[Data]') { + section = 'DATA_WAIT_HEADER'; + continue; + } + + // [Header] 섹션은 무시 + if (section === 'HEADER') { + continue; + } + + // [Data] 다음 줄 = 컬럼 헤더 + if (section === 'DATA_WAIT_HEADER') { + dataHeader = rawLine.split('\t').map(s => s.trim()); + if (dataHeader.length < 2) { + throw new Error('[Data] 헤더 라인이 비정상입니다. (탭 구분 여부 확인 필요)'); + } + + // 컬럼 인덱스 계산 및 캐시 + columnIndices = { + snpNameIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP_NAME), + sampleIdIdx: dataHeader.indexOf(COLUMN_MAPPING.SAMPLE_ID), + allele1Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE1), + allele2Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE2), + chrIdx: dataHeader.indexOf(COLUMN_MAPPING.CHR), + positionIdx: dataHeader.indexOf(COLUMN_MAPPING.POSITION), + snpIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP), + }; + + if (columnIndices.snpNameIdx === -1 || columnIndices.sampleIdIdx === -1) { + throw new Error(`필수 컬럼이 없습니다. (SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx})`); + } + + this.logger.log(`[배치업로드] Data 헤더 컬럼 수: ${dataHeader.length}`); + this.logger.log(`[배치업로드] 컬럼 인덱스 - SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx}, Chr: ${columnIndices.chrIdx}, Position: ${columnIndices.positionIdx}, SNP: ${columnIndices.snpIdx}`); + + section = 'DATA'; + continue; + } + + // 데이터 라인 처리 + if (section === 'DATA') { + if (!dataHeader || !columnIndices) { + throw new Error('dataHeader 또는 columnIndices가 초기화되지 않았습니다.'); + } + + const values = rawLine.split('\t'); + + // 컬럼 수 불일치 시 스킵 + if (values.length !== dataHeader.length) { + this.logger.warn( + `[배치업로드] 컬럼 수 불일치: header=${dataHeader.length}, values=${values.length} / line=${rawLine.slice(0, 120)}...`, + ); + skippedCount++; + continue; + } + + // 필수 필드 검증 + const cowId = values[columnIndices.sampleIdIdx]?.trim(); + const snpName = values[columnIndices.snpNameIdx]?.trim(); + + if (!cowId || !snpName) { + this.logger.warn(`[배치업로드] 필수 필드 누락: cowId=${cowId}, snpName=${snpName}`); + skippedCount++; + continue; + } + + // 배치 버퍼에 추가 + batchBuffer.push({ + cowId, + snpName, + chromosome: columnIndices.chrIdx !== -1 ? (values[columnIndices.chrIdx]?.trim() || null) : null, + position: columnIndices.positionIdx !== -1 ? (values[columnIndices.positionIdx]?.trim() || null) : null, + snpType: columnIndices.snpIdx !== -1 ? (values[columnIndices.snpIdx]?.trim() || null) : null, + allele1: columnIndices.allele1Idx !== -1 ? (values[columnIndices.allele1Idx]?.trim() || null) : null, + allele2: columnIndices.allele2Idx !== -1 ? (values[columnIndices.allele2Idx]?.trim() || null) : null, + }); + + totalRows++; + + // 배치 크기에 도달하면 즉시 DB에 저장 + if (batchBuffer.length >= BATCH_SIZE) { + await flushBatch(); + } + } + } + + // 마지막 남은 배치 처리 + await flushBatch(); + + // ============================================ + // 3단계: 결과 로깅 + // ============================================ + this.logger.log(`[배치업로드] 처리 완료`); + this.logger.log(`[배치업로드] 총 처리: ${totalRows}건, 성공: ${successCount}건, 실패: ${errorCount}건, 스킵: ${skippedCount}건`); + + return { + success: true, + total: totalRows, + successCount: successCount, + errorCount: errorCount, + skippedCount: skippedCount, + }; + } catch (error) { + this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`); + this.logger.error(error.stack); + throw error; + } + } + + + + + /** + * 혈액화학검사 결과 배치 삽입 + * @param file - 파일 + * @returns 성공 여부 + */ + async batchInsertMptResult(file: Express.Multer.File) { + this.logger.log(`[배치업로드] 혈액화학검사 결과 파일 처리 시작: ${file.originalname}`); + + try { + // ============================================ + // 1단계: 엑셀파일 로드 + // ============================================ + this.logger.log('[배치업로드] 1단계: 엑셀 파일 로드 중...'); + const rawData = this.excelUtil.parseExcelData(file); + + if (rawData.length < 6) { + throw new Error('엑셀 파일에 데이터가 부족합니다. (최소 6행 필요: 헤더 5행 + 데이터 1행)'); + } + + // ============================================ + // 2단계: 데이터 파싱 및 검증 + // ============================================ + this.logger.log('[배치업로드] 2단계: 데이터 파싱 중...'); + + // MptDto를 기반으로 하되, 파싱 단계에서는 null 허용 + const mptDataArray: Array & { + cowId: string; + fkFarmNo: number | null; + }> = []; + + // 5행부터 데이터 시작 (인덱스 5) + for (let rowIdx = 5; rowIdx < rawData.length; rowIdx++) { + const row = rawData[rowIdx]; + + // cowId 검증 (A열, 인덱스 0) + const cowId = row[0]; + if (!cowId || typeof cowId !== 'string' || !cowId.trim()) { + this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId가 없어 건너뜀`); + continue; + } + + const trimmedCowId = cowId.trim(); + + // cowShortNo 추출 (길이 검증) + let cowShortNo: string; + if (trimmedCowId.length >= 11) { + cowShortNo = trimmedCowId.slice(7, 11); + } else { + this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId 길이가 부족하여 cowShortNo 추출 실패 (cowId: ${trimmedCowId})`); + cowShortNo = trimmedCowId.slice(-4) || trimmedCowId; // 최소한 뒤 4자리 또는 전체 + } + + // 날짜 파싱 (C열, 인덱스 2) + const testDt = parseDate(row[2]); + + // 숫자 필드 파싱 + const monthAge = parseNumber(row[3]); + const milkYield = parseNumber(row[4]); + const parity = parseNumber(row[5]); + const glucose = parseNumber(row[6]); + const cholesterol = parseNumber(row[7]); + const nefa = parseNumber(row[8]); + const bcs = parseNumber(row[9]); + const totalProtein = parseNumber(row[10]); + const albumin = parseNumber(row[11]); + const globulin = parseNumber(row[12]); + const agRatio = parseNumber(row[13]); + const bun = parseNumber(row[14]); + const ast = parseNumber(row[15]); + const ggt = parseNumber(row[16]); + const fattyLiverIdx = parseNumber(row[17]); + const calcium = parseNumber(row[18]); + const phosphorus = parseNumber(row[19]); + const caPRatio = parseNumber(row[20]); + const magnesium = parseNumber(row[21]); + const creatine = parseNumber(row[22]); + + mptDataArray.push({ + cowId: `KOR${trimmedCowId}`, + cowShortNo, + fkFarmNo: null, // 3단계에서 채움 + testDt, + monthAge, + milkYield, + parity, + glucose, + cholesterol, + nefa, + bcs, + totalProtein, + albumin, + globulin, + agRatio, + bun, + ast, + ggt, + fattyLiverIdx, + calcium, + phosphorus, + caPRatio, + magnesium, + creatine, + }); + } + + this.logger.log(`[배치업로드] 파싱 완료: ${mptDataArray.length}건`); + + // ============================================ + // 3단계: cowId별 fkFarmNo 조회 + // ============================================ + this.logger.log('[배치업로드] 3단계: fkFarmNo 조회 중...'); + + // 고유한 cowId 목록 추출 + const uniqueCowIds = [...new Set(mptDataArray.map(item => item.cowId))]; + this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}개`); + + // cowId별 fkFarmNo 매핑 생성 (일괄 조회로 성능 최적화) + const cowIdToFarmNoMap = new Map(); + + if (uniqueCowIds.length > 0) { + try { + // 모든 cowId를 한 번에 조회 (IN 쿼리) + const cows = await this.cowRepository.find({ + where: { + cowId: In(uniqueCowIds), + delDt: IsNull(), + }, + }); + + // 조회된 결과를 Map으로 변환 + const foundCowIdSet = new Set(); + for (const cow of cows) { + if (cow.cowId) { + cowIdToFarmNoMap.set(cow.cowId, cow.fkFarmNo || null); + foundCowIdSet.add(cow.cowId); + } + } + + // 조회되지 않은 cowId는 null로 설정 + for (const cowId of uniqueCowIds) { + if (!foundCowIdSet.has(cowId)) { + this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`); + cowIdToFarmNoMap.set(cowId, null); + } + } + + this.logger.log(`[배치업로드] 조회 성공: ${cows.length}/${uniqueCowIds.length}개`); + } catch (error) { + this.logger.error(`[배치업로드] cowId 일괄 조회 중 오류: ${error.message}`); + // 에러 발생 시 모든 cowId를 null로 설정 + for (const cowId of uniqueCowIds) { + cowIdToFarmNoMap.set(cowId, null); + } + } + } + + // fkFarmNo 추가 + const mptDataWithFarmNo = mptDataArray.map(item => ({ + ...item, + fkFarmNo: cowIdToFarmNoMap.get(item.cowId) || null, + })); + + // fkFarmNo가 있는 유효한 데이터만 필터링 + const validMptData = mptDataWithFarmNo.filter(item => item.fkFarmNo !== null); + const invalidCount = mptDataWithFarmNo.length - validMptData.length; + + if (invalidCount > 0) { + this.logger.warn(`[배치업로드] fkFarmNo가 없는 데이터 ${invalidCount}건 제외`); + } + + this.logger.log(`[배치업로드] 유효한 MPT 데이터: ${validMptData.length}건`); + + // ============================================ + // 4단계: 데이터 DB Insert (Upsert) + // ============================================ + this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...'); + + let successCount = 0; + let errorCount = 0; + + // 배치 크기 설정 (한 번에 처리할 데이터 수) + const BATCH_SIZE = 100; + + for (let i = 0; i < validMptData.length; i += BATCH_SIZE) { + const batch = validMptData.slice(i, i + BATCH_SIZE); + + try { + // 각 배치를 upsert 처리 + const insertPromises = batch.map(async (item) => { + try { + // 기존 데이터 조회 (cowId와 testDt 기준) + const existing = await this.mptRepository.findOne({ + where: { + cowId: item.cowId, + testDt: item.testDt, + delDt: IsNull(), + }, + }); + + if (existing) { + // 업데이트 + existing.fkFarmNo = item.fkFarmNo!; + existing.cowShortNo = item.cowShortNo; + existing.monthAge = item.monthAge; + existing.milkYield = item.milkYield; + existing.parity = item.parity; + existing.glucose = item.glucose; + existing.cholesterol = item.cholesterol; + existing.nefa = item.nefa; + existing.bcs = item.bcs; + existing.totalProtein = item.totalProtein; + existing.albumin = item.albumin; + existing.globulin = item.globulin; + existing.agRatio = item.agRatio; + existing.bun = item.bun; + existing.ast = item.ast; + existing.ggt = item.ggt; + existing.fattyLiverIdx = item.fattyLiverIdx; + existing.calcium = item.calcium; + existing.phosphorus = item.phosphorus; + existing.caPRatio = item.caPRatio; + existing.magnesium = item.magnesium; + existing.creatine = item.creatine; + await this.mptRepository.save(existing); + } else { + // 삽입 + const newMpt = this.mptRepository.create({ + cowId: item.cowId, + cowShortNo: item.cowShortNo, + fkFarmNo: item.fkFarmNo!, + testDt: item.testDt, + monthAge: item.monthAge, + milkYield: item.milkYield, + parity: item.parity, + glucose: item.glucose, + cholesterol: item.cholesterol, + nefa: item.nefa, + bcs: item.bcs, + totalProtein: item.totalProtein, + albumin: item.albumin, + globulin: item.globulin, + agRatio: item.agRatio, + bun: item.bun, + ast: item.ast, + ggt: item.ggt, + fattyLiverIdx: item.fattyLiverIdx, + calcium: item.calcium, + phosphorus: item.phosphorus, + caPRatio: item.caPRatio, + magnesium: item.magnesium, + creatine: item.creatine, + }); + await this.mptRepository.save(newMpt); + } + return true; + } catch (error) { + this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, testDt: ${item.testDt}): ${error.message}`); + return false; + } + }); + + const results = await Promise.all(insertPromises); + successCount += results.filter(r => r === true).length; + errorCount += results.filter(r => r === false).length; + + this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validMptData.length)}/${validMptData.length}`); + } catch (error) { + this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`); + errorCount += batch.length; + } + } + + // ============================================ + // 5단계: 결과 로깅 + // ============================================ + this.logger.log(`[배치업로드] 처리 완료`); + this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}건`); + + return { + success: true, + total: mptDataArray.length, + valid: validMptData.length, + successCount: successCount, + errorCount: errorCount, + excludedCount: invalidCount, + }; + } catch (error) { + this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`); + this.logger.error(error.stack); + throw error; + } + } + + +} \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2bebcc6..0067747 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,8 @@ import { GenomeModule } from './genome/genome.module'; import { MptModule } from './mpt/mpt.module'; import { GeneModule } from './gene/gene.module'; import { SystemModule } from './system/system.module'; +import { AdminModule } from './admin/admin.module'; +import { ExcelModule } from './common/excel/excel.module'; @Module({ imports: [ @@ -53,8 +55,12 @@ import { SystemModule } from './system/system.module'; GeneModule, MptModule, + // 관리자 모듈 + AdminModule, + // 기타 SystemModule, + ExcelModule, ], controllers: [AppController], providers: [AppService, JwtStrategy], diff --git a/backend/src/common/dto/base.result.dto.ts b/backend/src/common/dto/base.result.dto.ts new file mode 100644 index 0000000..4e2706d --- /dev/null +++ b/backend/src/common/dto/base.result.dto.ts @@ -0,0 +1,37 @@ +// common/dto/base-result.dto.ts +export class BaseResultDto { + success: boolean; + code: string; + message: string; + data?: T; + timestamp: string; + + constructor( + success: boolean, + code: string, + message: string, + data?: T, + ) { + this.success = success; + this.code = code; + this.message = message; + this.data = data; + this.timestamp = new Date().toISOString(); + } + + static ok( + data?: T, + message = 'SUCCESS', + code = 'OK', + ): BaseResultDto { + return new BaseResultDto(true, code, message, data); + } + + static fail( + message: string, + code = 'FAIL', + ): BaseResultDto { + return new BaseResultDto(false, code, message); + } + } + \ No newline at end of file diff --git a/backend/src/common/excel/excel.module.ts b/backend/src/common/excel/excel.module.ts new file mode 100644 index 0000000..1d533f2 --- /dev/null +++ b/backend/src/common/excel/excel.module.ts @@ -0,0 +1,14 @@ +import { ExcelUtil } from "./excel.util"; +import { Global, Module } from "@nestjs/common"; + +/** + * Excel 모듈 + * Excel 파일 유틸리티를 제공하는 모듈입니다. + * 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성 + */ +@Global() +@Module({ + providers: [ExcelUtil], + exports: [ExcelUtil], +}) +export class ExcelModule {} \ No newline at end of file diff --git a/backend/src/common/excel/excel.util.ts b/backend/src/common/excel/excel.util.ts new file mode 100644 index 0000000..f3bf5c1 --- /dev/null +++ b/backend/src/common/excel/excel.util.ts @@ -0,0 +1,70 @@ +import * as XLSX from "xlsx"; +import { Injectable, Logger } from "@nestjs/common"; + + +/** + * Excel 파일 유틸리티 + * Lib활용 등 서비스에 가까운 공통 유틸이므로, 서비스 클래스 형태로 생성 + * 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성 + */ +@Injectable() +export class ExcelUtil { + private readonly logger = new Logger(ExcelUtil.name); + + /** + * file 파일 데이터를 파싱하여 json 배열로 변환 - 추후 공통으로 처리 가능하면 정리해서 처리 + * @param file - 파싱할 데이터 + * @returns json 배열 + */ + parseExcelData(file: Express.Multer.File): any[] { + try { + // ============================================ + // 1단계: 엑셀파일 로드 + // ============================================ + this.logger.log('[parseExcelData] 1단계: 엑셀 파일 로드 중...'); + + let workbook: XLSX.WorkBook; + + if(file?.buffer) { + workbook = XLSX.read(file.buffer, { type: 'buffer', cellDates: true }); + }else if (file?.path){ + workbook = XLSX.readFile(file.path, { cellDates: true }); + }else{ + throw new Error('file.buffer/file.path가 모두 없습니다.'); + } + + + if (!workbook.SheetNames || workbook.SheetNames.length === 0) { + throw new Error('엑셀 파일에 시트가 없습니다.'); + } + + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + + if (!worksheet) { + throw new Error(`시트 "${sheetName}"를 읽을 수 없습니다.`); + } + + // 시트를 JSON 배열로 변환 (헤더 포함) + const rawData = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: null, + raw: false + }) as any[][]; + + if (rawData.length < 2) { + throw new Error('엑셀 파일에 데이터가 없습니다. (헤더 포함 최소 2행 필요)'); + } + + this.logger.log(`[parseExcelData] 엑셀 파일 로드 완료: ${rawData.length}행`); + + return rawData; + } catch (error) { + this.logger.error(`[parseExcelData] 처리 중 오류 발생: ${error.message}`); + this.logger.error(error.stack); + throw error; + } finally { + this.logger.log(`[parseExcelData] 처리 완료`); + } + } +} \ No newline at end of file diff --git a/backend/src/common/utils.ts b/backend/src/common/utils.ts new file mode 100644 index 0000000..97ba41d --- /dev/null +++ b/backend/src/common/utils.ts @@ -0,0 +1,75 @@ +/** + * 날짜 문자열을 Date 객체로 변환 + * @param value - 변환할 값 + * @returns Date 객체 또는 null + */ +export function parseDate(value: any): Date | null { + if (value === null || value === undefined || value === '') { + return null; + } + + // 이미 Date 객체인 경우 + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + + // 숫자인 경우 (엑셀 날짜 시리얼 번호) + if (typeof value === 'number') { + // Excel 날짜는 1900-01-01부터의 일수 + // 하지만 XLSX 라이브러리가 이미 변환해줄 수 있으므로 일반 Date로 처리 + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; + } + + // 문자열인 경우 + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + + // 다양한 날짜 형식 시도 + // YYYY-MM-DD, YYYY/MM/DD, YYYYMMDD 등 + const date = new Date(trimmed); + if (!isNaN(date.getTime())) { + return date; + } + + // YYYYMMDD 형식 처리 + if (/^\d{8}$/.test(trimmed)) { + const year = parseInt(trimmed.substring(0, 4), 10); + const month = parseInt(trimmed.substring(4, 6), 10) - 1; // 월은 0부터 시작 + const day = parseInt(trimmed.substring(6, 8), 10); + const date = new Date(year, month, day); + return isNaN(date.getTime()) ? null : date; + } +} + +return null; +} + +/** + * 숫자 문자열을 숫자로 변환 + * @param value - 변환할 값 + * @returns 숫자 또는 null + */ +export function parseNumber(value: any): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + + if (typeof value === 'number') { + return isNaN(value) ? null : value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + const parsed = parseFloat(trimmed); + return isNaN(parsed) ? null : parsed; + } + + return null; +} \ No newline at end of file diff --git a/backend/src/mpt/dto/mpt.dto.ts b/backend/src/mpt/dto/mpt.dto.ts new file mode 100644 index 0000000..c69af0a --- /dev/null +++ b/backend/src/mpt/dto/mpt.dto.ts @@ -0,0 +1,29 @@ +/** + * MPT 결과 DTO + */ +export interface MptDto { + cowId: string; + cowShortNo: string; + fkFarmNo: number; + testDt: Date; + monthAge: number; + milkYield: number; + parity: number; + glucose: number; + cholesterol: number; + nefa: number; + bcs: number; + totalProtein: number; + albumin: number; + globulin: number; + agRatio: number; + bun: number; + ast: number; + ggt: number; + fattyLiverIdx: number; + calcium: number; + phosphorus: number; + caPRatio: number; + magnesium: number; + creatine: number; +} \ No newline at end of file diff --git a/frontend/src/app/admin/upload/page.tsx b/frontend/src/app/admin/upload/page.tsx index d987031..3296c25 100644 --- a/frontend/src/app/admin/upload/page.tsx +++ b/frontend/src/app/admin/upload/page.tsx @@ -73,6 +73,7 @@ interface UploadState { status: "idle" | "uploading" | "success" | "error" message: string isDragging: boolean + fileType: FileType } function FileUploadCard({ fileType }: { fileType: FileType }) { @@ -81,6 +82,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { status: "idle", message: "", isDragging: false, + fileType: fileType, }) const fileInputRef = React.useRef(null) @@ -109,7 +111,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { const files = e.dataTransfer.files if (files && files.length > 0) { const file = files[0] - if (isValidExcelFile(file)) { + if (isValidFile(file)) { setState(prev => ({ ...prev, file, status: "idle", message: "" })) } else { setState(prev => ({ @@ -125,7 +127,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { const files = e.target.files if (files && files.length > 0) { const file = files[0] - if (isValidExcelFile(file)) { + if (isValidFile(file)) { setState(prev => ({ ...prev, file, status: "idle", message: "" })) } else { setState(prev => ({ @@ -137,18 +139,21 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { } } - const isValidExcelFile = (file: File): boolean => { + const isValidFile = (file: File): boolean => { const validExtensions = [ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel", "text/csv", "application/csv", + "application/haansoftxls", + "text/plain", ] return ( validExtensions.includes(file.type) || file.name.endsWith(".xlsx") || file.name.endsWith(".xls") || - file.name.endsWith(".csv") + file.name.endsWith(".csv") || + file.name.endsWith(".txt") ) } @@ -167,16 +172,28 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { try { const formData = new FormData() formData.append("file", state.file) - formData.append("fileType", fileType.fileType) + formData.append("div", state.fileType.id) - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, { + + // const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, { + // method: "POST", + // body: formData, + // headers: { + // 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + // }, + // }) + + // 배치 파일 업로드 테스트 + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/admin/batchUpload`, { method: "POST", body: formData, headers: { - 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + 'Authorization': `Bearer ${useAuthStore.getState().accessToken}`, }, }) + + if (!response.ok) { throw new Error("파일 업로드에 실패했습니다.") } @@ -195,6 +212,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { status: "idle", message: "", isDragging: false, + fileType: fileType, }) if (fileInputRef.current) { fileInputRef.current.value = "" @@ -218,6 +236,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) { status: "idle", message: "", isDragging: false, + fileType: fileType, }) if (fileInputRef.current) { fileInputRef.current.value = "" diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2ab21d7..e7d1e6a 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -525,9 +525,9 @@ export default function DashboardPage() {

번식능력검사 현황

- + {/* 검사 {mptStats.totalMptCows}두 - + */}
{/* 에너지 균형 */}