import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig'; import { CowModel } from '../cow/entities/cow.entity'; import { FarmModel } from '../farm/entities/farm.entity'; import { GenomeRequestModel } from './entities/genome-request.entity'; import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity'; import { MptModel } from '../mpt/entities/mpt.entity'; import { GeneDetailModel } from '../gene/entities/gene-detail.entity'; /** * 낮을수록 좋은 형질 목록 (부호 반전 필요) * - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질 * - 선발지수 계산 시 EBV 부호를 반전하여 적용 */ const NEGATIVE_TRAITS = ['등지방두께']; /** * 형질명 → 카테고리 매핑 상수 * - 성장: 월령별 체중 관련 형질 * - 생산: 도체(도축 후 고기) 품질 관련 형질 * - 체형: 소의 신체 구조/외형 관련 형질 * - 무게: 각 부위별 실제 무게 (단위: kg) * - 비율: 각 부위별 비율 (단위: %) */ const TRAIT_CATEGORY_MAP: Record = { // 성장 카테고리 - 월령별 체중 '12개월령체중': '성장', // 생산 카테고리 - 도체(도축 후 고기) 품질 '도체중': '생산', // 도축 후 고기 무게 '등심단면적': '생산', // 등심의 단면 크기 (넓을수록 좋음) '등지방두께': '생산', // 등 부위 지방 두께 (적당해야 좋음) '근내지방도': '생산', // 마블링 정도 (높을수록 고급육) // 체형 카테고리 - 소의 신체 구조/외형 '체고': '체형', // 어깨 높이 '십자': '체형', // 십자부(엉덩이) 높이 '체장': '체형', // 몸통 길이 '흉심': '체형', // 가슴 깊이 '흉폭': '체형', // 가슴 너비 '고장': '체형', // 엉덩이 길이 '요각폭': '체형', // 허리뼈 너비 '곤폭': '체형', // 좌골(엉덩이뼈) 너비 '좌골폭': '체형', // 좌골 너비 '흉위': '체형', // 가슴둘레 // 무게 카테고리 - 부위별 실제 무게 (kg) '안심weight': '무게', // 안심 무게 '등심weight': '무게', // 등심 무게 '채끝weight': '무게', // 채끝 무게 '목심weight': '무게', // 목심 무게 '앞다리weight': '무게', // 앞다리 무게 '우둔weight': '무게', // 우둔 무게 '설도weight': '무게', // 설도 무게 '사태weight': '무게', // 사태 무게 '양지weight': '무게', // 양지 무게 '갈비weight': '무게', // 갈비 무게 // 비율 카테고리 - 부위별 비율 (%) '안심rate': '비율', // 안심 비율 '등심rate': '비율', // 등심 비율 '채끝rate': '비율', // 채끝 비율 '목심rate': '비율', // 목심 비율 '앞다리rate': '비율', // 앞다리 비율 '우둔rate': '비율', // 우둔 비율 '설도rate': '비율', // 설도 비율 '사태rate': '비율', // 사태 비율 '양지rate': '비율', // 양지 비율 '갈비rate': '비율', // 갈비 비율 }; /** * 카테고리별 평균 EBV(추정육종가) 응답 DTO */ interface CategoryAverageDto { category: string; // 카테고리명 (성장/생산/체형/무게/비율) avgEbv: number; // 평균 EBV 값 (표준화 육종가) avgEpd: number; // 평균 EPD 값 (원래 육종가) count: number; // 해당 카테고리의 데이터 개수 } /** * 비교 평균 응답 DTO * 전국/지역/농장 3단계로 평균값 제공 */ interface ComparisonAveragesDto { nationwide: CategoryAverageDto[]; // 전국 평균 region: CategoryAverageDto[]; // 지역(군) 평균 farm: CategoryAverageDto[]; // 농장 평균 } /** * 형질별 평균 EBV 응답 DTO */ interface TraitAverageDto { traitName: string; // 형질명 category: string; // 카테고리 avgEbv: number; // 평균 EBV (표준화 육종가) avgEpd: number; // 평균 EPD (육종가 원본값) count: number; // 데이터 개수 } /** * 형질별 비교 평균 응답 DTO */ export interface TraitComparisonAveragesDto { nationwide: TraitAverageDto[]; // 전국 평균 region: TraitAverageDto[]; // 지역(군) 평균 farm: TraitAverageDto[]; // 농장 평균 } /** * 유전체 분석 서비스 * * 주요 기능: * 1. 유전체 분석 의뢰(Request) CRUD * 2. 형질(Trait) 데이터 관리 * 3. 형질 상세(TraitDetail) 데이터 관리 * 4. 전국/지역/농장별 EBV 평균 비교 분석 */ @Injectable() export class GenomeService { constructor( // 유전체 분석 의뢰 Repository @InjectRepository(GenomeRequestModel) private readonly genomeRequestRepository: Repository, // 형질 상세 정보 Repository @InjectRepository(GenomeTraitDetailModel) private readonly genomeTraitDetailRepository: Repository, // 개체(소) 정보 Repository @InjectRepository(CowModel) private readonly cowRepository: Repository, // 농장 정보 Repository @InjectRepository(FarmModel) private readonly farmRepository: Repository, // 번식능력검사 Repository @InjectRepository(MptModel) private readonly mptRepository: Repository, // 유전자검사 상세 Repository @InjectRepository(GeneDetailModel) private readonly geneDetailRepository: Repository, ) { } // ============================================ // 대시보드 통계 관련 메서드 // ============================================ /** * 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국) * - 각 형질별로 원본 EBV, 중요도(가중치), 적용 EBV * - 보은군 전체 평균, 농가 평균 비교 * * @param farmNo - 농장 번호 */ async getFarmTraitComparison(farmNo: number): Promise<{ farmName: string; regionName: string; totalFarmAnimals: number; totalRegionAnimals: number; traits: { traitName: string; category: string; // 농가 데이터 farmAvgEbv: number; farmCount: number; farmPercentile: number; // 지역(보은군) 데이터 regionAvgEbv: number; regionCount: number; // 전국 데이터 nationAvgEbv: number; nationCount: number; // 비교 diffFromRegion: number; // 지역 대비 차이 diffFromNation: number; // 전국 대비 차이 }[]; }> { // Step 1: 농장 정보 조회 const farm = await this.farmRepository.findOne({ where: { pkFarmNo: farmNo, delDt: IsNull() }, }); const regionSi = farm?.regionSi || '보은군'; const farmName = farm?.farmerName || '농장'; // Step 2: 농가의 분석 완료된 개체들의 형질 데이터 조회 const farmRequestsRaw = await this.genomeRequestRepository.find({ where: { fkFarmNo: farmNo, chipSireName: VALID_CHIP_SIRE_NAME, delDt: IsNull() }, relations: ['cow'], }); // 유효 조건 필터 적용 (chipDamName 제외 조건 + cowId 제외 목록) const farmRequests = farmRequestsRaw.filter(r => isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId) ); const farmTraitMap = new Map(); for (const request of farmRequests) { // cowId로 직접 형질 데이터 조회 const details = await this.genomeTraitDetailRepository.find({ where: { cowId: request.cow?.cowId, delDt: IsNull() }, }); if (details.length === 0) continue; for (const detail of details) { if (detail.traitEbv !== null && detail.traitName) { const traitName = detail.traitName; const category = TRAIT_CATEGORY_MAP[traitName] || '기타'; if (!farmTraitMap.has(traitName)) { farmTraitMap.set(traitName, { sum: 0, percentileSum: 0, count: 0, category }); } const t = farmTraitMap.get(traitName)!; t.sum += Number(detail.traitEbv); t.percentileSum += Number(detail.traitPercentile) || 50; t.count++; } } } // Step 3: 지역(보은군) 전체 형질 데이터 조회 const regionDetails = await this.genomeTraitDetailRepository .createQueryBuilder('detail') .innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no') .innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no') .where('detail.delDt IS NULL') .andWhere('detail.traitEbv IS NOT NULL') .andWhere('request.chip_sire_name = :match', { match: '일치' }) .andWhere('farm.region_si = :regionSi', { regionSi }) .select(['detail.traitName', 'detail.traitEbv']) .getRawMany(); const regionTraitMap = new Map(); for (const detail of regionDetails) { const traitName = detail.detail_trait_name; const ebv = parseFloat(detail.detail_trait_ebv); if (!traitName || isNaN(ebv)) continue; if (!regionTraitMap.has(traitName)) { regionTraitMap.set(traitName, { sum: 0, count: 0 }); } const t = regionTraitMap.get(traitName)!; t.sum += ebv; t.count++; } // Step 4: 전국 형질 데이터 조회 const nationDetails = await this.genomeTraitDetailRepository .createQueryBuilder('detail') .innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no') .where('detail.delDt IS NULL') .andWhere('detail.traitEbv IS NOT NULL') .andWhere('request.chip_sire_name = :match', { match: '일치' }) .select(['detail.traitName', 'detail.traitEbv']) .getRawMany(); const nationTraitMap = new Map(); for (const detail of nationDetails) { const traitName = detail.detail_trait_name; const ebv = parseFloat(detail.detail_trait_ebv); if (!traitName || isNaN(ebv)) continue; if (!nationTraitMap.has(traitName)) { nationTraitMap.set(traitName, { sum: 0, count: 0 }); } const t = nationTraitMap.get(traitName)!; t.sum += ebv; t.count++; } // Step 5: 결과 조합 (35개 전체 형질) const traits: any[] = []; const allTraits = Object.keys(TRAIT_CATEGORY_MAP); for (const traitName of allTraits) { const farmData = farmTraitMap.get(traitName); const regionData = regionTraitMap.get(traitName); const nationData = nationTraitMap.get(traitName); const farmAvgEbv = farmData ? Math.round((farmData.sum / farmData.count) * 100) / 100 : 0; const farmPercentile = farmData ? Math.round((farmData.percentileSum / farmData.count) * 100) / 100 : 50; const regionAvgEbv = regionData ? Math.round((regionData.sum / regionData.count) * 100) / 100 : 0; const nationAvgEbv = nationData ? Math.round((nationData.sum / nationData.count) * 100) / 100 : 0; traits.push({ traitName, category: TRAIT_CATEGORY_MAP[traitName] || '기타', farmAvgEbv, farmCount: farmData?.count || 0, farmPercentile, regionAvgEbv, regionCount: regionData?.count || 0, nationAvgEbv, nationCount: nationData?.count || 0, diffFromRegion: Math.round((farmAvgEbv - regionAvgEbv) * 100) / 100, diffFromNation: Math.round((farmAvgEbv - nationAvgEbv) * 100) / 100, }); } // 지역 개체 수 계산 const regionAnimalCount = await this.genomeRequestRepository .createQueryBuilder('request') .innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no') .where('request.chip_sire_name = :match', { match: '일치' }) .andWhere('request.del_dt IS NULL') .andWhere('farm.region_si = :regionSi', { regionSi }) .getCount(); return { farmName, regionName: regionSi, totalFarmAnimals: farmRequests.length, totalRegionAnimals: regionAnimalCount, traits, }; } /** * 대시보드용 농가 통계 데이터 * - 연도별 분석 현황 * - 형질별 농장 평균 EBV * - 접수 내역 목록 * * @param farmNo - 농장 번호 */ async getDashboardStats(farmNo: number): Promise<{ // 연도별 분석 현황 yearlyStats: { year: number; totalRequests: number; // 총 접수 analyzedCount: number; // 분석 완료 (친자일치) pendingCount: number; // 분석 대기/불일치 sireMatchCount: number; // 친자 일치 수 analyzeRate: number; // 분석 완료율 (%) sireMatchRate: number; // 친자 일치율 (%) }[]; // 형질별 농장 평균 (전체 개체 기준) traitAverages: { traitName: string; category: string; avgEbv: number; avgEpd: number; // 육종가(EPD) 평균 avgPercentile: number; count: number; rank: number | null; // 보은군 내 농가 순위 totalFarms: number; // 보은군 내 총 농가 수 percentile: number | null; // 상위 백분율 }[]; // 접수 내역 목록 requestHistory: { pkRequestNo: number; cowId: string; cowRemarks: string | null; requestDt: string | null; chipSireName: string | null; // 친자감별 결과 chipReportDt: string | null; status: string; // 완료/대기/불일치 }[]; // 요약 summary: { totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외) genomeCowCount: number; // 유전체 분석 개체 수 geneCowCount: number; // 유전자검사 개체 수 mptCowCount: number; // 번식능력검사 개체 수 totalRequests: number; // 유전체 의뢰 건수 (기존 호환성) analyzedCount: number; pendingCount: number; mismatchCount: number; maleCount: number; // 수컷 수 femaleCount: number; // 암컷 수 }; // 검사 종류별 현황 testTypeStats: { snp: { total: number; completed: number }; ms: { total: number; completed: number }; }; // 친자감별 결과 현황 (상호 배타적 분류) paternityStats: { analysisComplete: number; // 분석 완료 (부 일치 + 모 일치/null/정보없음) sireMismatch: number; // 부 불일치 damMismatch: number; // 모 불일치 (부 일치 + 모 불일치) damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재) pending: number; // 대기 }; // 월별 접수 현황 monthlyStats: { month: number; count: number }[]; // 칩 종류별 분포 chipTypeStats: { chipType: string; count: number }[]; // 모근량별 분포 sampleAmountStats: { sampleAmount: string; count: number }[]; // 연도별 주요 형질 평균 (차트용) yearlyTraitAverages: { year: number; traits: { traitName: string; avgEbv: number | null }[]; }[]; // 연도별 평균 표준화육종가 (농가 vs 보은군 비교) yearlyAvgEbv: { year: number; farmAvgEbv: number; // 농가 평균 regionAvgEbv: number; // 보은군 평균 traitCount: number; }[]; }> { // Step 1: 농장의 모든 분석 의뢰 조회 const requests = await this.genomeRequestRepository.find({ where: { fkFarmNo: farmNo, delDt: IsNull() }, relations: ['cow'], order: { requestDt: 'DESC', regDt: 'DESC' }, }); // Step 1.5: 모든 형질 상세 데이터를 한 번에 조회 (N+1 문제 해결) const allTraitDetails = await this.genomeTraitDetailRepository.find({ where: { delDt: IsNull() }, }); // cowId로 형질 데이터를 그룹화 (Map으로 빠른 조회) const traitDetailsByCowId = new Map(); for (const detail of allTraitDetails) { if (!detail.cowId) continue; if (!traitDetailsByCowId.has(detail.cowId)) { traitDetailsByCowId.set(detail.cowId, []); } traitDetailsByCowId.get(detail.cowId)!.push(detail); } // Step 2: 연도별 통계 계산 const yearMap = new Map(); for (const req of requests) { const year = req.requestDt ? new Date(req.requestDt).getFullYear() : new Date().getFullYear(); if (!yearMap.has(year)) { yearMap.set(year, { total: 0, analyzed: 0, pending: 0, sireMatch: 0 }); } const stat = yearMap.get(year)!; stat.total++; if (req.chipSireName === '일치') { stat.analyzed++; stat.sireMatch++; } else { stat.pending++; } } // 연도 정렬 (최신순) const yearlyStats = Array.from(yearMap.entries()) .sort((a, b) => b[0] - a[0]) .map(([year, stat]) => ({ year, totalRequests: stat.total, analyzedCount: stat.analyzed, pendingCount: stat.pending, sireMatchCount: stat.sireMatch, // 분석완료율: 분석완료 / 총접수 analyzeRate: stat.total > 0 ? Math.round((stat.analyzed / stat.total) * 100) : 0, // 친자일치율: 친자일치 / 총접수 sireMatchRate: stat.total > 0 ? Math.round((stat.sireMatch / stat.total) * 100) : 0, })); // Step 3: 분석 완료된 개체의 형질 데이터 수집 (메모리에서 처리) const validRequests = requests.filter(r => r.chipSireName === '일치'); const traitDataMap = new Map(); // 연도별 형질 평균 계산용 (전체 35개 형질) const yearlyTraitMap = new Map>(); for (const request of validRequests) { // Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) const details = traitDetailsByCowId.get(request.cow?.cowId || '') || []; if (details.length === 0) continue; // 연도 추출 const year = request.requestDt ? new Date(request.requestDt).getFullYear() : new Date().getFullYear(); for (const detail of details) { if (detail.traitEbv !== null && detail.traitName) { const traitName = detail.traitName; const category = TRAIT_CATEGORY_MAP[traitName] || '기타'; // 전체 형질 평균 if (!traitDataMap.has(traitName)) { traitDataMap.set(traitName, { sum: 0, epdSum: 0, percentileSum: 0, count: 0, category }); } const t = traitDataMap.get(traitName)!; t.sum += Number(detail.traitEbv); t.epdSum += Number(detail.traitVal) || 0; // 육종가(EPD) 합계 t.percentileSum += Number(detail.traitPercentile) || 50; t.count++; // 연도별 전체 형질 평균 (차트용) if (!yearlyTraitMap.has(year)) { yearlyTraitMap.set(year, new Map()); } const yearTraits = yearlyTraitMap.get(year)!; if (!yearTraits.has(traitName)) { yearTraits.set(traitName, { sum: 0, count: 0 }); } const yt = yearTraits.get(traitName)!; yt.sum += Number(detail.traitEbv); yt.count++; } } } // 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요) // Step: 보은군 내 모든 농가의 형질별 평균 EBV 계산 (메모리에서 처리) const allFarmsTraitMap = new Map>(); // 보은군 내 모든 분석 완료된 요청 조회 const allRegionValidRequests = await this.genomeRequestRepository .createQueryBuilder('req') .leftJoinAndSelect('req.cow', 'cow') .leftJoinAndSelect('req.farm', 'farm') .where('req.delDt IS NULL') .andWhere('req.chipSireName = :match', { match: '일치' }) .getMany(); for (const req of allRegionValidRequests) { const reqFarmNo = req.fkFarmNo; if (!reqFarmNo) continue; // Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; if (details.length === 0) continue; if (!allFarmsTraitMap.has(reqFarmNo)) { allFarmsTraitMap.set(reqFarmNo, new Map()); } const farmTraits = allFarmsTraitMap.get(reqFarmNo)!; for (const detail of details) { if (detail.traitEbv !== null && detail.traitName) { const traitName = detail.traitName; if (!farmTraits.has(traitName)) { farmTraits.set(traitName, { sum: 0, count: 0 }); } const t = farmTraits.get(traitName)!; t.sum += Number(detail.traitEbv); t.count++; } } } // 형질별로 모든 농가의 평균 EBV 계산 및 순위 정렬 const traitRankingMap = new Map(); for (const [reqFarmNo, traitsMap] of allFarmsTraitMap) { for (const [traitName, data] of traitsMap) { if (!traitRankingMap.has(traitName)) { traitRankingMap.set(traitName, []); } traitRankingMap.get(traitName)!.push({ farmNo: reqFarmNo, avgEbv: data.sum / data.count, }); } } // 각 형질별로 EBV 내림차순 정렬 (높을수록 좋음) for (const [, farms] of traitRankingMap) { farms.sort((a, b) => b.avgEbv - a.avgEbv); } // 보은군 전체 형질별 평균 EPD 계산 (모든 농가의 모든 개체 데이터 사용) const regionTraitEpdMap = new Map(); for (const req of allRegionValidRequests) { const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; for (const detail of details) { if (detail.traitVal !== null && detail.traitName) { const traitName = detail.traitName; if (!regionTraitEpdMap.has(traitName)) { regionTraitEpdMap.set(traitName, { sum: 0, count: 0 }); } const t = regionTraitEpdMap.get(traitName)!; t.sum += Number(detail.traitVal); t.count++; } } } // 형질별 평균 및 순위 계산 (표준 경쟁 순위 방식: 동률 시 같은 순위, 다음 순위 건너뜀) const traitAverages = Array.from(traitDataMap.entries()).map(([traitName, data]) => { const avgEbv = Math.round((data.sum / data.count) * 100) / 100; const avgEpd = Math.round((data.epdSum / data.count) * 100) / 100; // 농가 평균 육종가(EPD) const rankings = traitRankingMap.get(traitName) || []; const totalFarms = rankings.length; // 보은군 평균 EPD 계산 const regionData = regionTraitEpdMap.get(traitName); const regionAvgEpd = regionData && regionData.count > 0 ? Math.round((regionData.sum / regionData.count) * 100) / 100 : 0; // 표준 경쟁 순위 계산: 동률 처리 let rank: number | null = null; const farmData = rankings.find(r => r.farmNo === farmNo); if (farmData) { // 등지방두께 등 낮을수록 좋은 형질은 순위 계산 반전 const isNegativeTrait = NEGATIVE_TRAITS.includes(traitName); if (isNegativeTrait) { // 나보다 낮은 점수를 가진 농장 수 + 1 = 내 순위 (낮을수록 좋음) const lowerCount = rankings.filter(r => r.avgEbv < farmData.avgEbv).length; rank = lowerCount + 1; } else { // 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위 (높을수록 좋음) const higherCount = rankings.filter(r => r.avgEbv > farmData.avgEbv).length; rank = higherCount + 1; } } const percentile = rank !== null && totalFarms > 0 ? Math.round((rank / totalFarms) * 100) : null; return { traitName, category: data.category, avgEbv, avgEpd, // 농가 평균 육종가(EPD) regionAvgEpd, // 보은군 평균 육종가(EPD) 추가 avgPercentile: Math.round((data.percentileSum / data.count) * 100) / 100, count: data.count, rank, totalFarms, percentile, }; }); // 연도별 전체 형질 평균 계산 const yearlyTraitAverages = Array.from(yearlyTraitMap.entries()) .sort((a, b) => a[0] - b[0]) .map(([year, traitsMap]) => ({ year, traits: Array.from(traitsMap.entries()).map(([traitName, data]) => ({ traitName, avgEbv: Math.round((data.sum / data.count) * 100) / 100, })), })); // 보은군 전체 연도별 평균 계산을 위한 데이터 조회 const allRegionRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow'], }); // 보은군 연도별 형질 데이터 수집 (메모리에서 처리) const regionYearlyTraitMap = new Map>(); for (const req of allRegionRequests) { if (!isValidGenomeAnalysis(req.chipSireName, req.chipDamName, req.cow?.cowId)) continue; const year = req.requestDt ? new Date(req.requestDt).getFullYear() : new Date().getFullYear(); // Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; if (details.length === 0) continue; if (!regionYearlyTraitMap.has(year)) { regionYearlyTraitMap.set(year, new Map()); } const yearTraits = regionYearlyTraitMap.get(year)!; for (const detail of details) { if (detail.traitEbv === null) continue; const traitName = detail.traitName; if (!yearTraits.has(traitName)) { yearTraits.set(traitName, { sum: 0, count: 0 }); } const traitData = yearTraits.get(traitName)!; traitData.sum += detail.traitEbv; traitData.count++; } } // 연도별 평균 표준화육종가 (농가 vs 보은군 비교) const allYears = new Set([...yearlyTraitMap.keys(), ...regionYearlyTraitMap.keys()]); const yearlyAvgEbv = Array.from(allYears) .sort((a, b) => a - b) .map(year => { // 농가 평균 let farmTotalSum = 0; let farmTotalCount = 0; const farmTraitsMap = yearlyTraitMap.get(year); if (farmTraitsMap) { farmTraitsMap.forEach((data) => { farmTotalSum += data.sum; farmTotalCount += data.count; }); } // 보은군 평균 let regionTotalSum = 0; let regionTotalCount = 0; const regionTraitsMap = regionYearlyTraitMap.get(year); if (regionTraitsMap) { regionTraitsMap.forEach((data) => { regionTotalSum += data.sum; regionTotalCount += data.count; }); } return { year, farmAvgEbv: farmTotalCount > 0 ? Math.round((farmTotalSum / farmTotalCount) * 100) / 100 : 0, regionAvgEbv: regionTotalCount > 0 ? Math.round((regionTotalSum / regionTotalCount) * 100) / 100 : 0, traitCount: farmTraitsMap?.size || 0, }; }); // Step 4: 접수 내역 목록 생성 const requestHistory = requests.map(req => { let status = '대기'; if (req.chipSireName === '일치') { status = '완료'; } else if (req.chipSireName && req.chipSireName !== '일치') { status = '불일치'; } return { pkRequestNo: req.pkRequestNo, cowId: req.cow?.cowId || '', cowRemarks: req.cowRemarks, requestDt: req.requestDt ? req.requestDt.toString().split('T')[0] : null, chipSireName: req.chipSireName, chipReportDt: req.chipReportDt ? req.chipReportDt.toString().split('T')[0] : null, status, }; }); // Step 5: 요약 계산 const totalRequests = requests.length; const analyzedCount = requests.filter(r => r.chipSireName === '일치').length; const mismatchCount = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length; const pendingCount = totalRequests - analyzedCount - mismatchCount; // Step 5.1: 검사 유형별 개체 수 계산 (합집합, 중복 제외) // 농장 소유 개체의 cowId 목록 조회 const farmCows = await this.cowRepository.find({ where: { fkFarmNo: farmNo, delDt: IsNull() }, select: ['cowId', 'cowSex'], }); const farmCowIds = new Set(farmCows.map(c => c.cowId).filter(Boolean)); const farmCowMap = new Map(farmCows.map(c => [c.cowId, c])); // 각 검사별 cowId 조회 (병렬 처리) - genomeRequest도 포함 (리스트 페이지와 일치) const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([ // 유전체 분석 의뢰가 있는 개체 (분석불가 포함) this.genomeRequestRepository .createQueryBuilder('request') .innerJoin('request.cow', 'cow') .select('DISTINCT cow.cowId', 'cowId') .where('request.delDt IS NULL') .getRawMany() .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), // 유전체 분석 개체 (형질 데이터 보유) this.genomeTraitDetailRepository .createQueryBuilder('trait') .select('DISTINCT trait.cowId', 'cowId') .where('trait.delDt IS NULL') .getRawMany() .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), // 유전자검사 개체 this.geneDetailRepository .createQueryBuilder('gene') .select('DISTINCT gene.cowId', 'cowId') .where('gene.delDt IS NULL') .getRawMany() .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), // 번식능력검사 개체 this.mptRepository .createQueryBuilder('mpt') .select('DISTINCT mpt.cowId', 'cowId') .where('mpt.delDt IS NULL') .getRawMany() .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), ]); // 농장 소유 개체만 필터링 const farmGenomeRequestCowIds = genomeRequestCowIds.filter(id => farmCowIds.has(id)); const farmGenomeCowIds = genomeCowIds.filter(id => farmCowIds.has(id)); const farmGeneCowIds = geneCowIds.filter(id => farmCowIds.has(id)); const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id)); // 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치) const allTestedCowIds = new Set([ ...farmGenomeRequestCowIds, ...farmGenomeCowIds, ...farmGeneCowIds, ...farmMptCowIds, ]); const totalCows = allTestedCowIds.size; const genomeCowCount = farmGenomeCowIds.length; const geneCowCount = farmGeneCowIds.length; const mptCowCount = farmMptCowIds.length; // 성별 체크 - 전체 검사 개체 기준 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리) let maleCount = 0; let femaleCount = 0; for (const cowId of allTestedCowIds) { const cow = farmCowMap.get(cowId); const sex = cow?.cowSex?.toUpperCase(); if (sex === 'M' || sex === '수' || sex === '1') { maleCount++; } else { femaleCount++; } } // Step 6: 검사 종류별 현황 (SNP, MS) const testTypeStats = { snp: { total: requests.filter(r => r.snpTest === 'Y' || r.snpTest === '유').length, completed: requests.filter(r => (r.snpTest === 'Y' || r.snpTest === '유') && r.chipSireName === '일치').length, }, ms: { total: requests.filter(r => r.msTest === 'Y' || r.msTest === '유').length, completed: requests.filter(r => (r.msTest === 'Y' || r.msTest === '유') && r.msResultStatus).length, }, }; // Step 7: 친자감별 결과 현황 (상호 배타적 분류 - 총합이 totalRequests와 일치) // 1. 분석 완료: 부 일치 + 모가 불일치/이력제부재가 아닌 경우 // 2. 부 불일치: 부가 일치가 아닌 경우 // 3. 모 불일치: 부 일치 + 모 불일치 // 4. 모 이력제부재: 부 일치 + 모 이력제부재 const paternityStats = { // 분석 완료 (부 일치 + 모가 불일치/이력제부재 아님) analysisComplete: requests.filter(r => r.chipSireName === '일치' && r.chipDamName !== '불일치' && r.chipDamName !== '이력제부재' ).length, // 부 불일치 (부가 일치가 아닌 모든 경우) sireMismatch: requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length, // 모 불일치 (부 일치 + 모 불일치) damMismatch: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '불일치').length, // 모 이력제부재 (부 일치 + 모 이력제부재) damNoRecord: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '이력제부재').length, // 대기 (chipSireName이 없는 경우) pending: requests.filter(r => !r.chipSireName).length, }; // Step 8: 월별 접수 현황 (올해 기준) const currentYear = new Date().getFullYear(); const monthlyStats: { month: number; count: number }[] = []; for (let m = 1; m <= 12; m++) { const count = requests.filter(r => { if (!r.requestDt) return false; const dt = new Date(r.requestDt); return dt.getFullYear() === currentYear && dt.getMonth() + 1 === m; }).length; monthlyStats.push({ month: m, count }); } // Step 9: 칩 종류별 분포 const chipTypeMap = new Map(); for (const req of requests) { if (req.chipType) { chipTypeMap.set(req.chipType, (chipTypeMap.get(req.chipType) || 0) + 1); } } const chipTypeStats = Array.from(chipTypeMap.entries()).map(([chipType, count]) => ({ chipType, count, })); // Step 10: 모근량별 분포 const sampleAmountMap = new Map(); for (const req of requests) { if (req.sampleAmount) { sampleAmountMap.set(req.sampleAmount, (sampleAmountMap.get(req.sampleAmount) || 0) + 1); } } const sampleAmountStats = Array.from(sampleAmountMap.entries()).map(([sampleAmount, count]) => ({ sampleAmount, count, })); return { yearlyStats, traitAverages, yearlyTraitAverages, yearlyAvgEbv, requestHistory, summary: { totalCows, // 검사 받은 전체 개체 수 (합집합) genomeCowCount, // 유전체 분석 개체 수 geneCowCount, // 유전자검사 개체 수 mptCowCount, // 번식능력검사 개체 수 totalRequests, // 유전체 의뢰 건수 (기존 호환성) analyzedCount, pendingCount, mismatchCount, maleCount, femaleCount, }, testTypeStats, paternityStats, monthlyStats, chipTypeStats, sampleAmountStats, }; } // ============================================ // 유전체 분석 의뢰 (Genome Request) 관련 메서드 // ============================================ /** * 전체 유전체 분석 의뢰 목록 조회 * * @returns 삭제되지 않은 모든 분석 의뢰 목록 * - cow, farm 관계 데이터 포함 * - 등록일(regDt) 기준 내림차순 정렬 (최신순) */ async findAllRequests(): Promise { return this.genomeRequestRepository.find({ where: { delDt: IsNull() }, // 삭제되지 않은 데이터만 relations: ['cow', 'farm'], // 개체, 농장 정보 JOIN order: { regDt: 'DESC' }, // 최신순 정렬 }); } /** * 개체 PK 번호로 해당 개체의 분석 의뢰 목록 조회 * * @param cowNo - 개체 PK 번호 (pkCowNo) * @returns 해당 개체의 모든 분석 의뢰 목록 (최신순) */ async findRequestsByCowId(cowNo: number): Promise { return this.genomeRequestRepository.find({ where: { fkCowNo: cowNo, delDt: IsNull() }, relations: ['cow', 'farm'], order: { regDt: 'DESC' }, }); } /** * 개체식별번호(cowId)로 유전체 데이터 조회 * 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환 * * @param cowId - 개체식별번호 (예: KOR123456789) * @returns 유전체 분석 결과 배열 * - request: 분석 의뢰 정보 * - trait: 형질 기본 정보 * - genomeCows: 형질별 상세 데이터 (EBV, 백분위 등) * @throws NotFoundException - 개체를 찾을 수 없는 경우 */ async findByCowId(cowId: string): Promise { // Step 1: cowId(개체식별번호)로 개체 정보 조회 const cow = await this.cowRepository.findOne({ where: { cowId: cowId, delDt: IsNull() }, }); // 개체가 없으면 404 에러 if (!cow) { throw new NotFoundException(`Cow with cowId ${cowId} not found`); } // Step 2: 개체의 PK로 유전체 분석 의뢰 목록 조회 const requests = await this.genomeRequestRepository.find({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, relations: ['cow', 'farm'], order: { regDt: 'DESC' }, // 최신순 }); // 분석 의뢰가 없으면 빈 배열 반환 if (requests.length === 0) { return []; } // Step 3: cowId로 직접 형질 데이터 조회 const latestRequest = requests[0]; // 최신 의뢰 const traitDetails = await this.genomeTraitDetailRepository.find({ where: { cowId: cowId, delDt: IsNull() }, }); // 형질 데이터가 없으면 빈 배열 반환 if (traitDetails.length === 0) { return []; } // Step 4: 형질명으로 카테고리를 추정하는 내부 함수 const getCategoryFromTraitName = (traitName: string): string => { // 성장 카테고리: 월령별 체중 if (['12개월령체중', '18개월령체중', '24개월령체중'].includes(traitName)) return '성장'; // 생산 카테고리: 도체 품질 if (['도체중', '등심단면적', '등지방두께', '근내지방도'].includes(traitName)) return '생산'; // 체형 카테고리: 신체 구조 if (traitName.includes('체형') || traitName.includes('체고') || traitName.includes('십자부')) return '체형'; // 그 외 기타 return '기타'; }; // Step 5: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환 return [{ request: latestRequest, // 분석 의뢰 정보 genomeCows: traitDetails.map(detail => ({ traitVal: detail.traitVal, // 형질 측정값 breedVal: detail.traitEbv, // EBV (추정육종가) percentile: detail.traitPercentile, // 백분위 순위 traitInfo: { traitNm: detail.traitName, // 형질명 traitCtgry: getCategoryFromTraitName(detail.traitName || ''), // 카테고리 traitDesc: '', // 형질 설명 (빈값) }, })), }]; } /** * 농장 PK 번호로 해당 농장의 분석 의뢰 목록 조회 * * @param farmNo - 농장 PK 번호 (pkFarmNo) * @returns 해당 농장의 모든 분석 의뢰 목록 (최신순) */ async findRequestsByFarmId(farmNo: number): Promise { return this.genomeRequestRepository.find({ where: { fkFarmNo: farmNo, delDt: IsNull() }, relations: ['cow', 'farm'], order: { regDt: 'DESC' }, }); } /** * 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회 * * @param cowId - 개체식별번호 (예: KOR002115897818) * @returns 최신 분석 의뢰 정보 (없으면 null) */ async findRequestByCowIdentifier(cowId: string): Promise { // Step 1: cowId로 개체 조회 const cow = await this.cowRepository.findOne({ where: { cowId: cowId, delDt: IsNull() }, }); if (!cow) { return null; } // Step 2: 해당 개체의 최신 분석 의뢰 조회 const request = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, relations: ['cow', 'farm'], order: { requestDt: 'DESC', regDt: 'DESC' }, }); return request || null; } /** * =========================================================================================== * 유전체 분석 요청 관련 메서드 * =========================================================================================== * 새로운 유전체 분석 의뢰 생성 * * @param data - 생성할 분석 의뢰 데이터 (Partial: 일부 필드만 입력 가능) * @returns 생성된 분석 의뢰 엔티티 */ async createRequest(data: Partial): Promise { // 엔티티 인스턴스 생성 const request = this.genomeRequestRepository.create(data); // DB에 저장 후 반환 return this.genomeRequestRepository.save(request); } // ============================================ // 형질 상세 (Genome Trait Detail) 관련 메서드 // ============================================ /** * 분석 의뢰 PK로 해당 의뢰의 형질 상세 목록 조회 * * @param requestNo - 분석 의뢰 PK 번호 (pkRequestNo) * @returns 해당 의뢰의 모든 형질 상세 목록 */ async findTraitDetailsByRequestId(requestNo: number): Promise { return this.genomeTraitDetailRepository.find({ where: { fkRequestNo: requestNo, delDt: IsNull() }, }); } /** * cowId로 해당 개체의 형질 상세 목록 조회 * * @param cowId - 개체식별번호 (KOR...) * @returns 해당 개체의 모든 형질 상세 목록 */ async findTraitDetailsByCowId(cowId: string): Promise { return this.genomeTraitDetailRepository.find({ where: { cowId: cowId, delDt: IsNull() }, }); } /** * 새로운 형질 상세 데이터 생성 * * @param data - 생성할 형질 상세 데이터 * @returns 생성된 형질 상세 엔티티 */ async createTraitDetail(data: Partial): Promise { const detail = this.genomeTraitDetailRepository.create(data); return this.genomeTraitDetailRepository.save(detail); } // ============================================ // 비교 분석 (Comparison) 관련 메서드 // ============================================ /** * 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터 조회 * * 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균, * 같은 농장 평균과 비교하여 상대적 위치 파악 * * @param cowId - 개체식별번호 (예: KOR123456789) * @returns 전국/지역/농장별 카테고리 평균 EBV * @throws NotFoundException - 개체를 찾을 수 없는 경우 */ async getComparisonAverages(cowId: string): Promise { // Step 1: 개체식별번호로 개체 정보 조회 const cow = await this.cowRepository.findOne({ where: { cowId: cowId, delDt: IsNull() }, }); if (!cow) { throw new NotFoundException(`Cow with cowId ${cowId} not found`); } // Step 2: 개체의 최신 분석 의뢰에서 농장 정보 조회 const request = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, relations: ['farm'], order: { regDt: 'DESC' }, }); // 농장 번호 및 지역(시/군) 정보 추출 const farmNo = request?.fkFarmNo; let regionSi: string | null = null; if (farmNo) { const farm = await this.farmRepository.findOne({ where: { pkFarmNo: farmNo, delDt: IsNull() }, }); regionSi = farm?.regionSi || null; // 농장의 지역(시/군) 정보 (보은군) } // Step 3: 전국 평균 계산 (필터 없이 모든 데이터) const nationwideAvg = await this.calculateCategoryAverages(); // Step 4: 지역(시/군) 평균 계산 (보은군 전체) // - 지역 정보가 있으면 해당 지역만 필터링 // - 없으면 전국 평균과 동일하게 처리 const regionAvg = regionSi ? await this.calculateCategoryAverages({ regionSi }) : nationwideAvg; // Step 5: 농장 평균 계산 // - 농장 정보가 있으면 해당 농장만 필터링 // - 없으면 지역 평균과 동일하게 처리 const farmAvg = farmNo ? await this.calculateCategoryAverages({ farmNo }) : regionAvg; // Step 6: 결과 반환 return { nationwide: nationwideAvg, // 전국 평균 region: regionAvg, // 지역 평균 farm: farmAvg, // 농장 평균 }; } /** * 개체 기준 전국/지역/농장 형질별 평균 EBV 비교 데이터 조회 * * 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해 * 전국/지역/농장 평균을 형질 단위로 제공 * * @param cowId - 개체식별번호 (예: KOR123456789) * @returns 전국/지역/농장별 형질별 평균 EBV * @throws NotFoundException - 개체를 찾을 수 없는 경우 */ async getTraitComparisonAverages(cowId: string): Promise { // Step 1: 개체식별번호로 개체 정보 조회 const cow = await this.cowRepository.findOne({ where: { cowId: cowId, delDt: IsNull() }, }); if (!cow) { throw new NotFoundException(`Cow with cowId ${cowId} not found`); } // Step 2: 개체의 최신 분석 의뢰에서 농장 정보 조회 const request = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, relations: ['farm'], order: { regDt: 'DESC' }, }); // 농장 번호 및 지역(시/군) 정보 추출 const farmNo = request?.fkFarmNo; let regionSi: string | null = null; if (farmNo) { const farm = await this.farmRepository.findOne({ where: { pkFarmNo: farmNo, delDt: IsNull() }, }); regionSi = farm?.regionSi || null; } // Step 3: 전국 형질별 평균 계산 const nationwideAvg = await this.calculateTraitAverages(); // Step 4: 지역(시/군) 형질별 평균 계산 (보은군 전체) const regionAvg = regionSi ? await this.calculateTraitAverages({ regionSi }) : nationwideAvg; // Step 5: 농장 형질별 평균 계산 const farmAvg = farmNo ? await this.calculateTraitAverages({ farmNo }) : regionAvg; // Step 6: 결과 반환 return { nationwide: nationwideAvg, region: regionAvg, farm: farmAvg, }; } /** * 형질별 평균 EBV 계산 (Private 메서드) * * @param filter - 필터 조건 (선택사항) * @returns 형질별 평균 EBV 배열 */ private async calculateTraitAverages( filter?: { farmNo?: number; regionSi?: string } ): Promise { // QueryBuilder를 사용한 동적 쿼리 구성 const qb = this.genomeTraitDetailRepository .createQueryBuilder('detail') .innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no') .where('detail.delDt IS NULL') .andWhere('detail.traitEbv IS NOT NULL'); // 농장 필터 적용 if (filter?.farmNo) { qb.andWhere('request.fk_farm_no = :farmNo', { farmNo: filter.farmNo }); } // 지역(시/군) 필터 적용 (보은군 전체) if (filter?.regionSi) { qb.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no') .andWhere('farm.region_si = :regionSi', { regionSi: filter.regionSi }); } // 형질별 평균 계산 (GROUP BY 사용) const results = await qb .select('detail.traitName', 'traitName') .addSelect('AVG(detail.traitEbv)', 'avgEbv') .addSelect('AVG(detail.traitVal)', 'avgEpd') // 육종가(EPD) 평균 추가 .addSelect('COUNT(*)', 'count') .groupBy('detail.traitName') .getRawMany(); // 결과 변환 return results.map(row => ({ traitName: row.traitName, category: TRAIT_CATEGORY_MAP[row.traitName] || '기타', avgEbv: Math.round(parseFloat(row.avgEbv) * 100) / 100, avgEpd: Math.round(parseFloat(row.avgEpd || 0) * 100) / 100, // 육종가(EPD) 평균 count: parseInt(row.count, 10), })); } /** * 카테고리별 평균 EBV 계산 (Private 메서드) * * 형질 상세 데이터를 조회하여 카테고리별로 그룹화 후 평균 계산 * * @param filter - 필터 조건 (선택사항) * - farmNo: 특정 농장으로 필터링 * - regionSi: 특정 지역(시/군)으로 필터링 (보은군 전체) * @returns 카테고리별 평균 EBV 배열 (성장/생산/체형/무게/비율) */ private async calculateCategoryAverages( filter?: { farmNo?: number; regionSi?: string } ): Promise { // QueryBuilder를 사용한 동적 쿼리 구성 const qb = this.genomeTraitDetailRepository .createQueryBuilder('detail') // 분석 의뢰 테이블 JOIN (농장 정보 접근을 위해) - detail에서 직접 request로 연결 .innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no') // 삭제되지 않은 데이터만 .where('detail.delDt IS NULL') // EBV 값이 있는 데이터만 .andWhere('detail.traitEbv IS NOT NULL'); // 농장 필터 적용 if (filter?.farmNo) { qb.andWhere('request.fk_farm_no = :farmNo', { farmNo: filter.farmNo }); } // 지역(시/군) 필터 적용 (보은군 전체) if (filter?.regionSi) { qb.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no') .andWhere('farm.region_si = :regionSi', { regionSi: filter.regionSi }); } // 형질명, EBV, EPD 모두 SELECT하여 조회 const details = await qb .select(['detail.traitName', 'detail.traitEbv', 'detail.traitVal']) .getRawMany(); // 카테고리별 합계/개수를 저장할 Map (EBV와 EPD 각각) const categoryMap = new Map(); // 각 상세 데이터를 카테고리별로 분류 및 합산 for (const detail of details) { const traitName = detail.detail_trait_name; // Raw query 결과의 컬럼명 const ebv = parseFloat(detail.detail_trait_ebv); const epd = parseFloat(detail.detail_trait_val); // EPD (원래 육종가) // 유효하지 않은 데이터 스킵 if (!traitName || isNaN(ebv)) continue; // 형질명으로 카테고리 결정 (매핑에 없으면 '기타') const category = TRAIT_CATEGORY_MAP[traitName] || '기타'; // 해당 카테고리가 처음이면 초기화 if (!categoryMap.has(category)) { categoryMap.set(category, { ebvSum: 0, epdSum: 0, count: 0 }); } // 합계와 개수 누적 const cat = categoryMap.get(category)!; cat.ebvSum += ebv; cat.epdSum += isNaN(epd) ? 0 : epd; cat.count += 1; } // 고정된 카테고리 순서로 결과 배열 생성 const categories = ['성장', '생산', '체형', '무게', '비율']; return categories.map(category => { const data = categoryMap.get(category); return { category, // 평균 계산: 소수점 2자리까지 반올림 avgEbv: data ? Math.round((data.ebvSum / data.count) * 100) / 100 : 0, avgEpd: data ? Math.round((data.epdSum / data.count) * 100) / 100 : 0, count: data?.count || 0, }; }); } // ============================================================ // 선발지수 계산 메서드 // ============================================================ /** * 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위 * * @param cowId - 개체식별번호 (예: KOR002119144049) * @param traitConditions - 형질별 가중치 조건 배열 * @returns 선발지수 점수, 순위, 상세 내역 * * @example * traitConditions = [ * { traitNm: '도체중', weight: 8 }, * { traitNm: '근내지방도', weight: 10 } * ] */ async getSelectionIndex( cowId: string, traitConditions: { traitNm: string; weight?: number }[] ): Promise<{ score: number | null; percentile: number | null; farmRank: number | null; // 농가 순위 farmTotal: number; // 농가 전체 수 regionRank: number | null; // 지역(보은군) 순위 regionTotal: number; // 지역 전체 수 regionName: string | null; // 지역명 farmerName: string | null; // 농가명 (농장주명) farmAvgScore: number | null; // 농가 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 details: { traitNm: string; ebv: number; weight: number; contribution: number }[]; message?: string; }> { // Step 1: cowId로 개체 조회 const cow = await this.cowRepository.findOne({ where: { cowId: cowId, delDt: IsNull() }, }); if (!cow) { throw new NotFoundException(`Cow with cowId ${cowId} not found`); } // Step 2: 최신 유전체 분석 의뢰 조회 const latestRequest = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, order: { requestDt: 'DESC', regDt: 'DESC' }, }); if (!latestRequest) { return { score: null, percentile: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, regionName: null, farmerName: null, farmAvgScore: null, regionAvgScore: null, details: [], message: '유전체 분석 데이터 없음' }; } // Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값) const traitDetails = await this.genomeTraitDetailRepository.find({ where: { cowId: cowId, delDt: IsNull() }, }); if (traitDetails.length === 0) { return { score: null, percentile: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, regionName: null, farmerName: null, farmAvgScore: null, regionAvgScore: null, details: [], message: '형질 데이터 없음' }; } // Step 4: 가중 평균 계산 ================================================================================ let weightedSum = 0; // Σ(EBV × 가중치) let totalWeight = 0; // Σ(가중치) let percentileSum = 0; // 백분위 합계 (평균 계산용) let percentileCount = 0; // 백분위 개수 let hasAllTraits = true; // 모든 선택 형질 존재 여부 (리스트와 동일 로직) const details: { traitNm: string; ebv: number; weight: number; contribution: number }[] = []; 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) { const ebv = Number(trait.traitEbv); // 등지방두께 등 낮을수록 좋은 형질은 부호 반전 const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm); const adjustedEbv = isNegativeTrait ? -ebv : ebv; const contribution = adjustedEbv * weight; // EBV × 가중치 weightedSum += contribution; totalWeight += weight; // 백분위 평균 계산용 if (trait.traitPercentile !== null) { percentileSum += Number(trait.traitPercentile); percentileCount++; } details.push({ traitNm: condition.traitNm, ebv: ebv, weight: weight, contribution: contribution, }); } else { // 형질이 없거나 EBV가 null이면 플래그 설정 hasAllTraits = false; } } // Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산) ================================================================ const score = (hasAllTraits && totalWeight > 0) ? weightedSum : null; const percentile = percentileCount > 0 ? percentileSum / percentileCount : null; // Step 7: 현재 개체의 농장/지역 정보 조회 let regionName: string | null = null; let farmerName: string | null = null; let farmNo: number | null = latestRequest.fkFarmNo; if (farmNo) { const farm = await this.farmRepository.findOne({ where: { pkFarmNo: farmNo, delDt: IsNull() }, }); regionName = farm?.regionSi || null; farmerName = farm?.farmerName || null; } // Step 8: 선택 형질 누락 시 조기 반환 if (!hasAllTraits) { return { score: null, percentile: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, regionName, farmerName, farmAvgScore: null, regionAvgScore: null, details, message: '선택한 형질 중 일부 데이터가 없습니다', }; } // Step 9: 농가/지역 순위 및 평균 선발지수 계산 const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore } = await this.calculateRanks(cowId, score, traitConditions, farmNo, regionName); return { score: score !== null ? Math.round(score * 100) / 100 : null, percentile: percentile !== null ? Math.round(percentile * 100) / 100 : null, farmRank, farmTotal, regionRank, regionTotal, regionName, farmerName, farmAvgScore, regionAvgScore, details, }; } /** * 농가/지역 순위 계산 (Private) * * @param currentCowId - 현재 개체 식별번호 * @param currentScore - 현재 개체 점수 * @param traitConditions - 형질별 가중치 조건 * @param farmNo - 농가 번호 * @param regionName - 지역명 (보은군 등) */ private async calculateRanks( currentCowId: string, currentScore: number | null, traitConditions: { traitNm: string; weight?: number }[], farmNo: number | null, regionName: string | null ): Promise<{ farmRank: number | null; farmTotal: number; regionRank: number | null; regionTotal: number; farmAvgScore: number | null; // 농가 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 }> { // 점수가 없으면 순위 계산 불가 if (currentScore === null) { return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null }; } // 모든 유전체 분석 의뢰 조회 (유전체 데이터가 있는 모든 개체) const allRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow', 'farm'], }); // 각 개체별 점수 계산 const allScores: { cowId: string; score: number; farmNo: number | null; regionSi: string | null }[] = []; for (const request of allRequests) { if (!request.cow?.cowId) continue; // 친자감별 결과가 '일치'인 경우만 포함 (분석불가 개체 제외) if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue; // cowId로 직접 형질 상세 데이터 조회 const traitDetails = await this.genomeTraitDetailRepository.find({ where: { cowId: request.cow.cowId, delDt: IsNull() }, }); if (traitDetails.length === 0) continue; // 가중 평균 계산 (모든 형질 있어야 점수 계산) let weightedSum = 0; let totalWeight = 0; let hasAllTraits = true; for (const condition of traitConditions) { const trait = traitDetails.find((d) => d.traitName === condition.traitNm); const weight = condition.weight || 1; if (trait && trait.traitEbv !== null) { // 등지방두께 등 낮을수록 좋은 형질은 부호 반전 const ebv = Number(trait.traitEbv); const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm); const adjustedEbv = isNegativeTrait ? -ebv : ebv; weightedSum += adjustedEbv * weight; totalWeight += weight; } else { hasAllTraits = false; } } // 모든 선택 형질이 있는 경우만 점수에 포함 if (hasAllTraits && totalWeight > 0) { const score = weightedSum; allScores.push({ cowId: request.cow.cowId, score: Math.round(score * 100) / 100, farmNo: request.fkFarmNo, regionSi: request.farm?.regionSi || null, }); } } // 점수 기준 내림차순 정렬 allScores.sort((a, b) => b.score - a.score); console.log('[calculateRanks] 샘플 점수:', allScores.slice(0, 5).map(s => ({ cowId: s.cowId, score: s.score }))); // 농가 순위 및 평균 선발지수 계산 let farmRank: number | null = null; let farmTotal = 0; let farmAvgScore: number | null = null; if (farmNo) { const farmScores = allScores.filter(s => s.farmNo === farmNo); farmTotal = farmScores.length; const farmIndex = farmScores.findIndex(s => s.cowId === currentCowId); farmRank = farmIndex >= 0 ? farmIndex + 1 : null; // 농가 평균 선발지수 계산 if (farmScores.length > 0) { const farmScoreSum = farmScores.reduce((sum, s) => sum + s.score, 0); farmAvgScore = Math.round((farmScoreSum / farmScores.length) * 100) / 100; } } // 지역(보은군) 순위 및 평균 선발지수 계산 - 전체 유전체 데이터가 보은군이므로 필터링 없이 전체 사용 let regionRank: number | null = null; let regionTotal = allScores.length; let regionAvgScore: number | null = null; const regionIndex = allScores.findIndex(s => s.cowId === currentCowId); regionRank = regionIndex >= 0 ? regionIndex + 1 : null; // 보은군(지역) 평균 선발지수 계산 if (allScores.length > 0) { const regionScoreSum = allScores.reduce((sum, s) => sum + s.score, 0); regionAvgScore = Math.round((regionScoreSum / allScores.length) * 100) / 100; } return { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore, }; } /** * 개별 형질 기준 순위 조회 * @param cowId - 개체식별번호 (KOR...) * @param traitName - 형질명 (도체중, 근내지방도 등) */ async getTraitRank(cowId: string, traitName: string): Promise<{ traitName: string; cowEbv: number | null; cowEpd: number | null; // 개체 육종가(EPD) farmRank: number | null; farmTotal: number; regionRank: number | null; regionTotal: number; farmAvgEbv: number | null; regionAvgEbv: number | null; farmAvgEpd: number | null; // 농가 평균 육종가(EPD) regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) }> { // 1. 현재 개체의 의뢰 정보 조회 const cow = await this.cowRepository.findOne({ where: { cowId, delDt: IsNull() }, }); if (!cow) { return { traitName, cowEbv: null, cowEpd: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgEbv: null, regionAvgEbv: null, farmAvgEpd: null, regionAvgEpd: null, }; } const currentRequest = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, relations: ['farm'], }); if (!currentRequest) { return { traitName, cowEbv: null, cowEpd: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgEbv: null, regionAvgEbv: null, farmAvgEpd: null, regionAvgEpd: null, }; } const farmNo = currentRequest.fkFarmNo; // 2. 모든 유전체 분석 의뢰 조회 const allRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow', 'farm'], }); // 3. 각 개체별 해당 형질 EBV, EPD 수집 const allScores: { cowId: string; ebv: number; epd: number | null; farmNo: number | null }[] = []; for (const request of allRequests) { if (!request.cow?.cowId) continue; if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue; const traitDetail = await this.genomeTraitDetailRepository.findOne({ where: { fkRequestNo: request.pkRequestNo, traitName: traitName, delDt: IsNull() }, }); if (traitDetail && traitDetail.traitEbv !== null) { allScores.push({ cowId: request.cow.cowId, ebv: Number(traitDetail.traitEbv), epd: traitDetail.traitVal !== null ? Number(traitDetail.traitVal) : null, // 육종가(EPD) farmNo: request.fkFarmNo, }); } } // 4. EBV 기준 내림차순 정렬 allScores.sort((a, b) => b.ebv - a.ebv); // 5. 현재 개체의 EBV, EPD 찾기 const currentCowData = allScores.find(s => s.cowId === cowId); const cowEbv = currentCowData?.ebv ?? null; const cowEpd = currentCowData?.epd ?? null; // 6. 보은군 전체 순위 const regionRank = currentCowData ? allScores.findIndex(s => s.cowId === cowId) + 1 : null; const regionTotal = allScores.length; // 보은군 평균 EBV, EPD const regionAvgEbv = allScores.length > 0 ? Math.round((allScores.reduce((sum, s) => sum + s.ebv, 0) / allScores.length) * 100) / 100 : null; const regionEpdValues = allScores.filter(s => s.epd !== null).map(s => s.epd as number); const regionAvgEpd = regionEpdValues.length > 0 ? Math.round((regionEpdValues.reduce((sum, v) => sum + v, 0) / regionEpdValues.length) * 100) / 100 : null; // 7. 농가 내 순위 const farmScores = allScores.filter(s => s.farmNo === farmNo); const farmRank = currentCowData && farmNo ? farmScores.findIndex(s => s.cowId === cowId) + 1 : null; const farmTotal = farmScores.length; // 농가 평균 EBV, EPD const farmAvgEbv = farmScores.length > 0 ? Math.round((farmScores.reduce((sum, s) => sum + s.ebv, 0) / farmScores.length) * 100) / 100 : null; const farmEpdValues = farmScores.filter(s => s.epd !== null).map(s => s.epd as number); const farmAvgEpd = farmEpdValues.length > 0 ? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100 : null; return { traitName, cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null, cowEpd: cowEpd !== null ? Math.round(cowEpd * 100) / 100 : null, farmRank: farmRank && farmRank > 0 ? farmRank : null, farmTotal, regionRank: regionRank && regionRank > 0 ? regionRank : null, regionTotal, farmAvgEbv, regionAvgEbv, farmAvgEpd, regionAvgEpd, }; } /** * 농가의 보은군 내 순위 조회 (대시보드용) * 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리 * @param farmNo - 농장 번호 */ async getFarmRegionRanking( farmNo: number, inputTraitConditions?: { traitNm: string; weight?: number }[] ): Promise<{ farmNo: number; farmerName: string | null; farmAvgScore: number | null; regionAvgScore: number | null; farmRankInRegion: number | null; totalFarmsInRegion: number; percentile: number | null; farmCowCount: number; regionCowCount: number; }> { // 1. 농가 정보 조회 const farm = await this.farmRepository.findOne({ where: { pkFarmNo: farmNo, delDt: IsNull() }, }); if (!farm) { return { farmNo, farmerName: null, farmAvgScore: null, regionAvgScore: null, farmRankInRegion: null, totalFarmsInRegion: 0, percentile: null, farmCowCount: 0, regionCowCount: 0, }; } // 2. 모든 유전체 분석 의뢰 조회 const allRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow', 'farm'], }); // 3. 모든 형질 상세 데이터를 한 번에 조회 (N+1 문제 해결) const allTraitDetails = await this.genomeTraitDetailRepository.find({ where: { delDt: IsNull() }, }); // cowId로 형질 데이터를 그룹화 (Map으로 빠른 조회) const traitDetailsByCowId = new Map(); for (const detail of allTraitDetails) { if (!detail.cowId) continue; if (!traitDetailsByCowId.has(detail.cowId)) { traitDetailsByCowId.set(detail.cowId, []); } traitDetailsByCowId.get(detail.cowId)!.push(detail); } // 4. 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', ]; // inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 const traitConditions = inputTraitConditions && inputTraitConditions.length > 0 ? inputTraitConditions // 프론트에서 보낸 형질사용 : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용 console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits'); // 5. 각 개체별 점수 계산 (메모리에서 처리 - DB 쿼리 없음) const allScores: { cowId: string; score: number; farmNo: number | null }[] = []; for (const request of allRequests) { if (!request.cow?.cowId) continue; if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue; // Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) const traitDetails = traitDetailsByCowId.get(request.cow.cowId); if (!traitDetails || traitDetails.length === 0) continue; let weightedSum = 0; let totalWeight = 0; let hasAllTraits = true; for (const condition of traitConditions) { const trait = traitDetails.find((d) => d.traitName === condition.traitNm); const weight = condition.weight || 1; if (trait && trait.traitEbv !== null) { // 등지방두께 등 낮을수록 좋은 형질은 부호 반전 const ebv = Number(trait.traitEbv); const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm); const adjustedEbv = isNegativeTrait ? -ebv : ebv; weightedSum += adjustedEbv * weight; totalWeight += weight; } else { hasAllTraits = false; } } if (hasAllTraits && totalWeight > 0) { const score = weightedSum; allScores.push({ cowId: request.cow.cowId, score: Math.round(score * 100) / 100, farmNo: request.fkFarmNo, }); } } // 6. 농가별 평균 점수 계산 const farmScoresMap = new Map(); for (const score of allScores) { if (score.farmNo === null) continue; if (!farmScoresMap.has(score.farmNo)) { const farmInfo = allRequests.find(r => r.fkFarmNo === score.farmNo)?.farm; farmScoresMap.set(score.farmNo, { scores: [], farmerName: farmInfo?.farmerName || null, }); } farmScoresMap.get(score.farmNo)!.scores.push(score.score); } // 7. 농가별 평균 계산 및 정렬 const farmAverages: { farmNo: number; avgScore: number; cowCount: number; farmerName: string | null }[] = []; for (const [fNo, data] of farmScoresMap.entries()) { if (data.scores.length > 0) { const avg = data.scores.reduce((a, b) => a + b, 0) / data.scores.length; farmAverages.push({ farmNo: fNo, avgScore: Math.round(avg * 100) / 100, cowCount: data.scores.length, farmerName: data.farmerName, }); } } // 내림차순 정렬 farmAverages.sort((a, b) => b.avgScore - a.avgScore); // 8. 현재 농가 순위 찾기 (표준 경쟁 순위: 동률 시 같은 순위) const myFarmData = farmAverages.find(f => f.farmNo === farmNo); let farmRankInRegion: number | null = null; if (myFarmData) { // 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위 const higherCount = farmAverages.filter(f => f.avgScore > myFarmData.avgScore).length; farmRankInRegion = higherCount + 1; } // 9. 보은군 전체 평균 const regionAvgScore = allScores.length > 0 ? Math.round((allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length) * 100) / 100 : null; // 디버깅: 상위 5개 농가 점수 출력 console.log('[getFarmRegionRanking] 결과:', { myFarmRank: farmRankInRegion, myFarmScore: myFarmData?.avgScore, totalFarms: farmAverages.length, top5: farmAverages.slice(0, 5).map(f => ({ farmNo: f.farmNo, score: f.avgScore })) }); return { farmNo, farmerName: farm.farmerName || null, farmAvgScore: myFarmData?.avgScore ?? null, regionAvgScore, farmRankInRegion, totalFarmsInRegion: farmAverages.length, percentile: farmRankInRegion !== null && farmAverages.length > 0 ? Math.round((farmRankInRegion / farmAverages.length) * 100) : null, farmCowCount: myFarmData?.cowCount || 0, regionCowCount: allScores.length, }; } /** * 특정 개체들의 상세 정보 조회 (디버깅용) */ async checkSpecificCows(cowIds: string[]): Promise { const results = []; for (const cowId of cowIds) { const request = await this.genomeRequestRepository.findOne({ where: { delDt: IsNull() }, relations: ['cow'], }); // cowId로 조회 const cow = await this.cowRepository.findOne({ where: { cowId, delDt: IsNull() }, }); if (cow) { const req = await this.genomeRequestRepository.findOne({ where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, }); results.push({ cowId, chipSireName: req?.chipSireName, chipDamName: req?.chipDamName, requestDt: req?.requestDt, cowRemarks: req?.cowRemarks, }); } else { results.push({ cowId, error: 'cow not found' }); } } return results; } /** * 연도별 유전능력 추이 (형질별/카테고리별) * 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회 * * @param farmNo - 농장 번호 * @param traitName - 형질명 (선택, 없으면 카테고리 전체) * @param category - 카테고리명 (성장/생산/체형/무게/비율) */ async getYearlyTraitTrend( farmNo: number, category: string, traitName?: string, ): Promise<{ category: string; traitName: string | null; yearlyData: { year: number; farmAvgEbv: number; regionAvgEbv: number; farmCount: number; regionCount: number; }[]; traitList: string[]; farmRank: { rank: number | null; totalFarms: number; percentile: number | null; farmAvgEbv: number | null; regionAvgEbv: number; }; }> { // 해당 카테고리의 형질 목록 const traitsInCategory = Object.entries(TRAIT_CATEGORY_MAP) .filter(([_, cat]) => cat === category) .map(([trait, _]) => trait); // 대상 형질 결정 const targetTraits = traitName ? [traitName] : traitsInCategory; // 단일 쿼리로 모든 데이터 조회 (N+1 문제 해결) // genome_request + cow + genome_trait_detail을 한번에 조인 const allData = await this.genomeRequestRepository .createQueryBuilder('r') .innerJoin('r.cow', 'c') .innerJoin( GenomeTraitDetailModel, 'd', 'd.cow_id = c.cow_id AND d.del_dt IS NULL', ) .select('r.fk_farm_no', 'reqFarmNo') .addSelect('r.chip_sire_name', 'chipSireName') .addSelect('r.chip_dam_name', 'chipDamName') .addSelect('c.cow_id', 'cowId') .addSelect('EXTRACT(YEAR FROM r.request_dt)', 'year') .addSelect('d.trait_name', 'traitName') .addSelect('d.trait_val', 'traitVal') .where('r.del_dt IS NULL') .andWhere('d.trait_name IN (:...targetTraits)', { targetTraits }) .getRawMany(); // cowId별로 데이터 그룹화 const cowDataMap = new Map(); for (const row of allData) { const cowId = row.cowId; if (!cowId) continue; if (!cowDataMap.has(cowId)) { cowDataMap.set(cowId, { farmNo: row.reqFarmNo, year: row.year || new Date().getFullYear(), chipSireName: row.chipSireName, chipDamName: row.chipDamName, traits: [], }); } if (row.traitVal !== null) { cowDataMap.get(cowId)!.traits.push({ traitName: row.traitName, traitVal: parseFloat(row.traitVal), }); } } // 연도별/농가별 데이터 집계 const farmYearMap = new Map(); const regionYearMap = new Map(); const farmEbvMap = new Map(); for (const [cowId, data] of cowDataMap) { // 유효한 분석인지 확인 if (!isValidGenomeAnalysis(data.chipSireName, data.chipDamName, cowId)) continue; if (data.traits.length === 0) continue; // 대상 형질의 평균 육종가 계산 const targetTraitData = data.traits.filter(t => targetTraits.includes(t.traitName)); if (targetTraitData.length === 0) continue; const avgVal = targetTraitData.reduce((sum, t) => sum + t.traitVal, 0) / targetTraitData.length; const year = data.year; const cowFarmNo = data.farmNo; // 보은군 전체 (연도별) if (!regionYearMap.has(year)) { regionYearMap.set(year, { sum: 0, count: 0 }); } const regionYearData = regionYearMap.get(year)!; regionYearData.sum += avgVal; regionYearData.count++; // 요청 농가 (연도별) if (cowFarmNo === farmNo) { if (!farmYearMap.has(year)) { farmYearMap.set(year, { sum: 0, count: 0 }); } const farmYearData = farmYearMap.get(year)!; farmYearData.sum += avgVal; farmYearData.count++; } // 농가별 평균 (순위용) if (cowFarmNo) { if (!farmEbvMap.has(cowFarmNo)) { farmEbvMap.set(cowFarmNo, { sum: 0, count: 0 }); } const farmData = farmEbvMap.get(cowFarmNo)!; farmData.sum += avgVal; farmData.count++; } } // 모든 연도 합치기 const allYears = new Set([...farmYearMap.keys(), ...regionYearMap.keys()]); const yearlyData = Array.from(allYears) .sort((a, b) => a - b) .map(year => { const farmData = farmYearMap.get(year); const regionData = regionYearMap.get(year); return { year, farmAvgEbv: farmData ? Math.round((farmData.sum / farmData.count) * 100) / 100 : 0, regionAvgEbv: regionData ? Math.round((regionData.sum / regionData.count) * 100) / 100 : 0, farmCount: farmData?.count || 0, regionCount: regionData?.count || 0, }; }); // 농가별 평균 계산 및 정렬 const farmAverages = Array.from(farmEbvMap.entries()) .map(([fNo, data]) => ({ farmNo: fNo, avgEbv: data.count > 0 ? Math.round((data.sum / data.count) * 100) / 100 : 0, })) .sort((a, b) => b.avgEbv - a.avgEbv); // 현재 농가 순위 const myFarmIndex = farmAverages.findIndex(f => f.farmNo === farmNo); const myFarmData = farmAverages.find(f => f.farmNo === farmNo); // 보은군 전체 평균 const allFarmEbvs = farmAverages.map(f => f.avgEbv); const regionAvgEbv = allFarmEbvs.length > 0 ? Math.round((allFarmEbvs.reduce((a, b) => a + b, 0) / allFarmEbvs.length) * 100) / 100 : 0; return { category, traitName: traitName || null, yearlyData, traitList: traitsInCategory, // 농가 순위 정보 추가 farmRank: { rank: myFarmIndex >= 0 ? myFarmIndex + 1 : null, totalFarms: farmAverages.length, percentile: myFarmIndex >= 0 && farmAverages.length > 0 ? Math.round(((myFarmIndex + 1) / farmAverages.length) * 100) : null, farmAvgEbv: myFarmData?.avgEbv || null, regionAvgEbv, }, }; } }