add_cow_data_batch_insert
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
# ==============================================
|
# ==============================================
|
||||||
|
|
||||||
# DATABASE
|
# DATABASE
|
||||||
POSTGRES_HOST=192.168.11.46
|
# POSTGRES_HOST=192.168.11.46
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_USER=genome
|
POSTGRES_USER=genome
|
||||||
POSTGRES_PASSWORD=genome1@3
|
POSTGRES_PASSWORD=genome1@3
|
||||||
POSTGRES_DB=genome_db
|
POSTGRES_DB=genome_db
|
||||||
|
|||||||
117
backend/src/admin/admin.controller.ts
Normal file
117
backend/src/admin/admin.controller.ts
Normal file
@@ -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} 파일 업로드 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/src/admin/admin.module.ts
Normal file
27
backend/src/admin/admin.module.ts
Normal file
@@ -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 {}
|
||||||
847
backend/src/admin/admin.service.ts
Normal file
847
backend/src/admin/admin.service.ts
Normal file
@@ -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<GenomeTraitDetailModel>,
|
||||||
|
|
||||||
|
// 유전체 분석 의뢰 Repository
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
|
|
||||||
|
// 소 개체 Repository
|
||||||
|
@InjectRepository(CowModel)
|
||||||
|
private readonly cowRepository: Repository<CowModel>,
|
||||||
|
|
||||||
|
// 혈액화학검사 결과 Repository
|
||||||
|
@InjectRepository(MptModel)
|
||||||
|
private readonly mptRepository: Repository<MptModel>,
|
||||||
|
|
||||||
|
// 유전자 상세 정보 Repository
|
||||||
|
@InjectRepository(GeneDetailModel)
|
||||||
|
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||||
|
|
||||||
|
@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<string, {
|
||||||
|
name: string;
|
||||||
|
valIndex: number | null;
|
||||||
|
ebvIndex: number | null;
|
||||||
|
percentileIndex: number | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 헤더 분석 (인덱스 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<string, number | null>();
|
||||||
|
|
||||||
|
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<Partial<MptDto> & {
|
||||||
|
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<string, number | null>();
|
||||||
|
|
||||||
|
if (uniqueCowIds.length > 0) {
|
||||||
|
try {
|
||||||
|
// 모든 cowId를 한 번에 조회 (IN 쿼리)
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: {
|
||||||
|
cowId: In(uniqueCowIds),
|
||||||
|
delDt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 조회된 결과를 Map으로 변환
|
||||||
|
const foundCowIdSet = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { GenomeModule } from './genome/genome.module';
|
|||||||
import { MptModule } from './mpt/mpt.module';
|
import { MptModule } from './mpt/mpt.module';
|
||||||
import { GeneModule } from './gene/gene.module';
|
import { GeneModule } from './gene/gene.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
import { ExcelModule } from './common/excel/excel.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,8 +55,12 @@ import { SystemModule } from './system/system.module';
|
|||||||
GeneModule,
|
GeneModule,
|
||||||
MptModule,
|
MptModule,
|
||||||
|
|
||||||
|
// 관리자 모듈
|
||||||
|
AdminModule,
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
SystemModule,
|
SystemModule,
|
||||||
|
ExcelModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, JwtStrategy],
|
providers: [AppService, JwtStrategy],
|
||||||
|
|||||||
37
backend/src/common/dto/base.result.dto.ts
Normal file
37
backend/src/common/dto/base.result.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// common/dto/base-result.dto.ts
|
||||||
|
export class BaseResultDto<T = any> {
|
||||||
|
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<T>(
|
||||||
|
data?: T,
|
||||||
|
message = 'SUCCESS',
|
||||||
|
code = 'OK',
|
||||||
|
): BaseResultDto<T> {
|
||||||
|
return new BaseResultDto<T>(true, code, message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fail(
|
||||||
|
message: string,
|
||||||
|
code = 'FAIL',
|
||||||
|
): BaseResultDto<null> {
|
||||||
|
return new BaseResultDto<null>(false, code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
14
backend/src/common/excel/excel.module.ts
Normal file
14
backend/src/common/excel/excel.module.ts
Normal file
@@ -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 {}
|
||||||
70
backend/src/common/excel/excel.util.ts
Normal file
70
backend/src/common/excel/excel.util.ts
Normal file
@@ -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] 처리 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/src/common/utils.ts
Normal file
75
backend/src/common/utils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
29
backend/src/mpt/dto/mpt.dto.ts
Normal file
29
backend/src/mpt/dto/mpt.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ interface UploadState {
|
|||||||
status: "idle" | "uploading" | "success" | "error"
|
status: "idle" | "uploading" | "success" | "error"
|
||||||
message: string
|
message: string
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
|
fileType: FileType
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileUploadCard({ fileType }: { fileType: FileType }) {
|
function FileUploadCard({ fileType }: { fileType: FileType }) {
|
||||||
@@ -81,6 +82,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
fileType: fileType,
|
||||||
})
|
})
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
const files = e.dataTransfer.files
|
const files = e.dataTransfer.files
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
if (isValidExcelFile(file)) {
|
if (isValidFile(file)) {
|
||||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
@@ -125,7 +127,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
if (isValidExcelFile(file)) {
|
if (isValidFile(file)) {
|
||||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
@@ -137,18 +139,21 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidExcelFile = (file: File): boolean => {
|
const isValidFile = (file: File): boolean => {
|
||||||
const validExtensions = [
|
const validExtensions = [
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"application/vnd.ms-excel",
|
"application/vnd.ms-excel",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
"application/csv",
|
"application/csv",
|
||||||
|
"application/haansoftxls",
|
||||||
|
"text/plain",
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
validExtensions.includes(file.type) ||
|
validExtensions.includes(file.type) ||
|
||||||
file.name.endsWith(".xlsx") ||
|
file.name.endsWith(".xlsx") ||
|
||||||
file.name.endsWith(".xls") ||
|
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 {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", state.file)
|
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",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${useAuthStore.getState().accessToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("파일 업로드에 실패했습니다.")
|
throw new Error("파일 업로드에 실패했습니다.")
|
||||||
}
|
}
|
||||||
@@ -195,6 +212,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
fileType: fileType,
|
||||||
})
|
})
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
@@ -218,6 +236,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
fileType: fileType,
|
||||||
})
|
})
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
|
|||||||
@@ -525,9 +525,9 @@ export default function DashboardPage() {
|
|||||||
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
|
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p className="text-base max-sm:text-sm font-semibold text-slate-700">번식능력검사 현황</p>
|
<p className="text-base max-sm:text-sm font-semibold text-slate-700">번식능력검사 현황</p>
|
||||||
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-pink-50 text-pink-700 font-medium">
|
{/* <span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-pink-50 text-pink-700 font-medium">
|
||||||
검사 {mptStats.totalMptCows}두
|
검사 {mptStats.totalMptCows}두
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-sm:gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-sm:gap-3">
|
||||||
{/* 에너지 균형 */}
|
{/* 에너지 균형 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user