INIT
This commit is contained in:
394
backend/src/cow/cow.service.ts
Normal file
394
backend/src/cow/cow.service.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 개체(Cow) 서비스
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
*
|
||||
* 주요 기능:
|
||||
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
||||
* 2. 개체 단건 조회 (findOne, findByCowId)
|
||||
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||
* - GENOME: 35개 형질 EBV 가중 평균
|
||||
* 4. 개체 CRUD (create, update, remove)
|
||||
* ============================================================
|
||||
*/
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { CowModel } from './entities/cow.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||
import {
|
||||
RankingRequestDto,
|
||||
RankingCriteriaType,
|
||||
TraitRankingCondition,
|
||||
} from './dto/ranking-request.dto';
|
||||
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
/**
|
||||
* 개체(소) 관리 서비스
|
||||
*
|
||||
* 담당 기능:
|
||||
* - 개체 CRUD 작업
|
||||
* - 유전체 기반 랭킹 계산
|
||||
* - 필터링 및 정렬
|
||||
*/
|
||||
@Injectable()
|
||||
export class CowService {
|
||||
constructor(
|
||||
// 개체(소) 테이블 Repository
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
|
||||
// 유전체 분석 의뢰 Repository (형질 데이터 접근용)
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
// 유전체 형질 상세 Repository (EBV 값 접근용)
|
||||
@InjectRepository(GenomeTraitDetailModel)
|
||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||
|
||||
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||
private readonly filterEngineService: FilterEngineService,
|
||||
) { }
|
||||
|
||||
// ============================================================
|
||||
// 기본 조회 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 전체 개체 목록 조회
|
||||
*
|
||||
* @returns 삭제되지 않은 모든 개체 목록
|
||||
* - farm 관계 데이터 포함
|
||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||
*/
|
||||
async findAll(): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||
relations: ['farm'], // 농장 정보 JOIN
|
||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장별 개체 목록 조회
|
||||
*
|
||||
* @param farmNo - 농장 PK 번호
|
||||
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
||||
*/
|
||||
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 PK로 단건 조회
|
||||
*
|
||||
* @param id - 개체 PK 번호 (pkCowNo)
|
||||
* @returns 개체 정보 (farm 포함)
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async findOne(id: number): Promise<CowModel> {
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { pkCowNo: id, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!cow) {
|
||||
throw new NotFoundException(`Cow #${id} not found`);
|
||||
}
|
||||
return cow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체식별번호(cowId)로 단건 조회
|
||||
*
|
||||
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
||||
* @returns 개체 정보 (farm 포함)
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async findByCowId(cowId: string): Promise<CowModel> {
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { cowId: cowId, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!cow) {
|
||||
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
||||
}
|
||||
return cow;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 랭킹 적용 조회 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 랭킹 적용 개체 목록 조회 (메인 API)
|
||||
*
|
||||
* POST /cow/ranking 에서 호출
|
||||
*
|
||||
* 기능:
|
||||
* 1. 필터 조건으로 개체 목록 조회
|
||||
* 2. 랭킹 기준(GENOME/GENE)에 따라 점수 계산
|
||||
* 3. 점수 기준 정렬 후 순위 부여
|
||||
*
|
||||
* @param rankingRequest - 필터 옵션 + 랭킹 옵션
|
||||
* @returns 순위가 적용된 개체 목록
|
||||
*/
|
||||
async findAllWithRanking(rankingRequest: RankingRequestDto): Promise<any> {
|
||||
// Step 1: 요청에서 필터 옵션과 랭킹 옵션 추출
|
||||
const { filterOptions, rankingOptions } = rankingRequest;
|
||||
const { criteriaType } = rankingOptions;
|
||||
|
||||
// Step 2: 필터 조건에 맞는 개체 목록 조회
|
||||
const cows = await this.getFilteredCows(filterOptions);
|
||||
|
||||
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||
switch (criteriaType) {
|
||||
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||
case RankingCriteriaType.GENOME:
|
||||
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
|
||||
|
||||
// 기본값: 랭킹 없이 순서대로 반환
|
||||
default:
|
||||
return {
|
||||
items: cows.map((cow, index) => ({
|
||||
entity: cow,
|
||||
rank: index + 1,
|
||||
sortValue: 0,
|
||||
})),
|
||||
total: cows.length,
|
||||
criteriaType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건에 맞는 개체 목록 조회 (Private)
|
||||
*
|
||||
* @param filterOptions - 필터/정렬/페이지네이션 옵션
|
||||
* @returns 필터링된 개체 목록
|
||||
*/
|
||||
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
|
||||
// QueryBuilder로 기본 쿼리 구성
|
||||
const queryBuilder = this.cowRepository
|
||||
.createQueryBuilder('cow')
|
||||
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
|
||||
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
|
||||
|
||||
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
|
||||
if (filterOptions?.farmNo) {
|
||||
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
|
||||
farmNo: filterOptions.farmNo
|
||||
});
|
||||
}
|
||||
|
||||
// FilterEngine 사용하여 동적 필터 적용
|
||||
if (filterOptions?.filters) {
|
||||
const result = await this.filterEngineService.executeFilteredQuery(
|
||||
queryBuilder,
|
||||
filterOptions,
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// 필터 없으면 전체 조회 (최신순)
|
||||
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 유전체(GENOME) 랭킹 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 유전체 형질 기반 랭킹 적용 (Private)
|
||||
*
|
||||
* 계산 방식: 선택한 형질들의 EBV 가중 평균
|
||||
* - 각 형질에 weight(가중치) 적용 가능
|
||||
* - 모든 선택 형질이 있어야 점수 계산
|
||||
*
|
||||
* @param cows - 필터링된 개체 목록
|
||||
* @param traitConditions - 형질별 가중치 조건 배열
|
||||
* @returns 순위가 적용된 개체 목록
|
||||
* @example
|
||||
* traitConditions = [
|
||||
* { traitNm: '도체중', weight: 8 },
|
||||
* { traitNm: '근내지방도', weight: 10 }
|
||||
* ]
|
||||
*/
|
||||
private async applyGenomeRanking(
|
||||
cows: CowModel[],
|
||||
traitConditions: TraitRankingCondition[],
|
||||
): Promise<any> {
|
||||
// 각 개체별로 점수 계산
|
||||
const cowsWithScore = await Promise.all(
|
||||
cows.map(async (cow) => {
|
||||
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 형질 데이터가 없으면 점수 null
|
||||
if (traitDetails.length === 0) {
|
||||
return { entity: cow, sortValue: null, details: [] };
|
||||
}
|
||||
|
||||
// Step 2: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||||
});
|
||||
|
||||
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가
|
||||
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||
return { entity: cow, sortValue: null, details: [] };
|
||||
}
|
||||
|
||||
// Step 4: 가중 평균 계산
|
||||
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||
let totalWeight = 0; // 총 가중치
|
||||
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||
const details: any[] = []; // 계산 상세 내역
|
||||
|
||||
// 사용자가 선택한 각 형질에 대해 처리
|
||||
for (const condition of traitConditions) {
|
||||
// 형질명으로 해당 형질 데이터 찾기
|
||||
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
|
||||
const weight = condition.weight || 1; // 가중치 (기본값: 1)
|
||||
|
||||
if (trait && trait.traitEbv !== null) {
|
||||
// EBV 값이 있으면 가중치 적용하여 합산
|
||||
const ebv = Number(trait.traitEbv);
|
||||
weightedSum += ebv * weight; // EBV × 가중치
|
||||
totalWeight += weight; // 가중치 누적
|
||||
|
||||
// 상세 내역 저장 (응답용)
|
||||
details.push({
|
||||
code: condition.traitNm, // 형질명
|
||||
value: ebv, // EBV 값
|
||||
weight, // 적용된 가중치
|
||||
});
|
||||
} else {
|
||||
// 형질이 없으면 플래그 설정
|
||||
hasAllTraits = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: 최종 점수 계산 (가중 평균)
|
||||
// 모든 선택 형질이 있어야만 점수 계산
|
||||
const sortValue = (hasAllTraits && totalWeight > 0)
|
||||
? weightedSum / totalWeight // 가중 평균 = 가중합 / 총가중치
|
||||
: null;
|
||||
|
||||
// Step 7: 응답 데이터 구성
|
||||
return {
|
||||
entity: {
|
||||
...cow,
|
||||
anlysDt: latestRequest.requestDt, // 분석일자 추가
|
||||
},
|
||||
sortValue, // 계산된 종합 점수 (선발지수)
|
||||
details, // 점수 계산에 사용된 형질별 상세
|
||||
ranking: {
|
||||
requestNo: latestRequest.pkRequestNo, // 분석 의뢰 번호
|
||||
requestDt: latestRequest.requestDt, // 분석 의뢰일
|
||||
traits: traitDetails.map((d) => ({ // 전체 형질 데이터
|
||||
traitName: d.traitName, // 형질명
|
||||
traitVal: d.traitVal, // 실측값
|
||||
traitEbv: d.traitEbv, // EBV (표준화육종가)
|
||||
traitPercentile: d.traitPercentile, // 백분위
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 백엔드 응답 예시
|
||||
// ========================================
|
||||
// {
|
||||
// "items": [
|
||||
// {
|
||||
// "entity": { "cowId": "KOR123456", "cowNm": "뽀삐", ... },
|
||||
// "sortValue": 85.5, // 가중 평균 점수
|
||||
// "rank": 1, // 순위
|
||||
// "ranking": {
|
||||
// "requestNo": 100,
|
||||
// "traits": [
|
||||
// { "traitName": "도체중", "traitVal": 450, "traitEbv": 12.5 },
|
||||
// { "traitName": "등심단면적", "traitVal": 95, "traitEbv": 8.3 }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
// "total": 100,
|
||||
// "criteriaType": "GENOME"
|
||||
// }
|
||||
|
||||
// Step 8: 점수 기준 내림차순 정렬
|
||||
const sorted = cowsWithScore.sort((a, b) => {
|
||||
// null 값은 맨 뒤로
|
||||
if (a.sortValue === null && b.sortValue === null) return 0;
|
||||
if (a.sortValue === null) return 1;
|
||||
if (b.sortValue === null) return -1;
|
||||
// 점수 높은 순 (내림차순)
|
||||
return b.sortValue - a.sortValue;
|
||||
});
|
||||
|
||||
// Step 9: 순위 부여 후 반환
|
||||
return {
|
||||
items: sorted.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1, // 1부터 시작하는 순위
|
||||
})),
|
||||
total: sorted.length,
|
||||
criteriaType: RankingCriteriaType.GENOME,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRUD 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 새로운 개체 생성
|
||||
*
|
||||
* @param data - 생성할 개체 데이터
|
||||
* @returns 생성된 개체 엔티티
|
||||
*/
|
||||
async create(data: Partial<CowModel>): Promise<CowModel> {
|
||||
const cow = this.cowRepository.create(data);
|
||||
return this.cowRepository.save(cow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 정보 수정
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @param data - 수정할 데이터
|
||||
* @returns 수정된 개체 엔티티
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
||||
await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.update(id, data);
|
||||
return this.findOne(id); // 수정된 데이터 반환
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 삭제 (Soft Delete)
|
||||
*
|
||||
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const cow = await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.softRemove(cow);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user