/** * ============================================================ * 개체(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'; /** * 낮을수록 좋은 형질 목록 (부호 반전 필요) * - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질 * - 선발지수 계산 시 EBV 부호를 반전하여 적용 */ const NEGATIVE_TRAITS = ['등지방두께']; /** * 개체(소) 관리 서비스 * * 담당 기능: * - 개체 CRUD 작업 * - 유전체 기반 랭킹 계산 * - 필터링 및 정렬 */ @Injectable() export class CowService { constructor( // 개체(소) 테이블 Repository @InjectRepository(CowModel) private readonly cowRepository: Repository, // 유전체 분석 의뢰 Repository (형질 데이터 접근용) @InjectRepository(GenomeRequestModel) private readonly genomeRequestRepository: Repository, // 유전체 형질 상세 Repository (EBV 값 접근용) @InjectRepository(GenomeTraitDetailModel) private readonly genomeTraitDetailRepository: Repository, // 동적 필터링 서비스 (검색, 정렬, 페이지네이션) private readonly filterEngineService: FilterEngineService, ) { } // ============================================================ // 기본 조회 메서드 // ============================================================ /** * 전체 개체 목록 조회 * * @returns 삭제되지 않은 모든 개체 목록 * - farm 관계 데이터 포함 * - 등록일(regDt) 기준 내림차순 정렬 (최신순) */ async findAll(): Promise { return this.cowRepository.find({ where: { delDt: IsNull() }, // 삭제되지 않은 데이터만 relations: ['farm'], // 농장 정보 JOIN order: { regDt: 'DESC' }, // 최신순 정렬 }); } /** * 농장별 개체 목록 조회 * * @param farmNo - 농장 PK 번호 * @returns 해당 농장의 모든 개체 목록 (최신순) */ async findByFarmId(farmNo: number): Promise { 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 { 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 { 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 { // 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 { // 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 순위가 적용된 개체 목록 / 리스트에 전달 / 농가/보은군 차트 (farmBreedVal, regionBreedVal) * @example * traitConditions = [ * { traitNm: '도체중', weight: 8 }, * { traitNm: '근내지방도', weight: 10 } * ] */ private async applyGenomeRanking( cows: CowModel[], inputTraitConditions: TraitRankingCondition[], ): Promise { // 35개 전체 형질 (기본값) const ALL_TRAITS = [ '12개월령체중', '도체중', '등심단면적', '등지방두께', '근내지방도', '체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위', '안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight', '안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate', ]; // traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일) const traitConditions = inputTraitConditions && inputTraitConditions.length > 0 ? inputTraitConditions : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 각 개체별로 점수 계산 const cowsWithScore = await Promise.all( cows.map(async (cow) => { // Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용) const latestRequest = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, order: { requestDt: 'DESC', regDt: 'DESC' }, }); // Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가 if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) { // 분석불가 사유 결정 let unavailableReason = '분석불가'; if (latestRequest) { if (latestRequest.chipSireName !== '일치') { unavailableReason = '부 불일치'; } else if (latestRequest.chipDamName === '불일치') { unavailableReason = '모 불일치'; } else if (latestRequest.chipDamName === '이력제부재') { unavailableReason = '모 이력제부재'; } } return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] }; } // Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값) const traitDetails = await this.genomeTraitDetailRepository.find({ where: { cowId: cow.cowId, delDt: IsNull() }, }); // 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음) if (traitDetails.length === 0) { return { entity: { ...cow, unavailableReason: '형질정보없음' }, 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); // 등지방두께 등 낮을수록 좋은 형질은 부호 반전 const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm); const adjustedEbv = isNegativeTrait ? -ebv : ebv; weightedSum += adjustedEbv * weight; // EBV × 가중치 totalWeight += weight; // 가중치 누적 // 상세 내역 저장 (응답용) details.push({ code: condition.traitNm, // 형질명 value: adjustedEbv, // EBV 값 (부호 반전 적용) weight, // 적용된 가중치 }); } else { // 형질이 없으면 플래그 설정 hasAllTraits = false; } } // Step 6: 최종 점수 계산 (가중 합계) // 모든 선택 형질이 있어야만 점수 계산 const sortValue = (hasAllTraits && totalWeight > 0) ? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식) : 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): Promise { 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): Promise { 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 { const cow = await this.findOne(id); // 존재 여부 확인 await this.cowRepository.softRemove(cow); } }