This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View 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);
}
}