import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { isValidGenomeAnalysis, } from '../common/config/GenomeAnalysisConfig'; import { ALL_TRAITS, NEGATIVE_TRAITS, TRAIT_CATEGORY_MAP, getTraitCategory, } from '../common/const/TraitTypes'; 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'; import { CategoryAverageDto, ComparisonAveragesDto } from './dto/comparison-averages.dto'; import { TraitAverageDto, TraitComparisonAveragesDto } from './dto/trait-comparison.dto'; /** * 유전체 분석 서비스 * * 주요 기능: * 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, ) { } // ============================================ // 대시보드 통계 관련 메서드 // ============================================ /** * 대시보드용 농가 통계 데이터 * - 연도별 분석 현황 * - 형질별 농장 평균 EBV * - 접수 내역 목록 * * @usedBy /dashboard - 대시보드 페이지 * @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교 * @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; // 모 이력제부재 (부 일치 + 모 이력제부재) notAnalyzed: 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: 농장의 모든 분석 의뢰 조회 (traitDetails 포함) const requests = await this.genomeRequestRepository.find({ where: { fkFarmNo: farmNo, delDt: IsNull() }, relations: ['cow', 'traitDetails'], order: { requestDt: 'DESC', regDt: 'DESC' }, }); // 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) { // relations로 조회된 traitDetails 사용 const details = request.traitDetails || []; 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++; } } } // 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요) // 보은군 내 모든 농가의 형질별 평균 EBV 계산 const allFarmsTraitMap = new Map>(); // 보은군 내 모든 분석 완료된 요청 조회 (traitDetails 포함) const allRegionValidRequests = await this.genomeRequestRepository .createQueryBuilder('req') .leftJoinAndSelect('req.cow', 'cow') .leftJoinAndSelect('req.farm', 'farm') .leftJoinAndSelect('req.traitDetails', 'traitDetails') .where('req.delDt IS NULL') .andWhere('req.chipSireName = :match', { match: '일치' }) .getMany(); for (const req of allRegionValidRequests) { const reqFarmNo = req.fkFarmNo; if (!reqFarmNo) continue; // relations로 조회된 traitDetails 사용 const details = req.traitDetails || []; 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 = req.traitDetails || []; 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, })), })); // 보은군 전체 연도별 평균 계산을 위한 데이터 조회 (traitDetails 포함) const allRegionRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow', 'traitDetails'], }); // 보은군 연도별 형질 데이터 수집 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(); // relations로 조회된 traitDetails 사용 const details = req.traitDetails || []; 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 TEST_FARM_NO = 26; // 코쿤 테스트 농장 const isTestFarm = farmNo === TEST_FARM_NO; // 테스트 농장(26번)은 tb_cow 전체, 그 외는 검사 받은 개체만 const allTestedCowIds = isTestFarm ? farmCowIds : 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이 없는 경우) notAnalyzed: 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) 관련 메서드 // ============================================ /** * 개체식별번호(cowId)로 유전체 데이터 조회 * 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환 * * @usedBy /cow/[cowNo] - 개체 상세 페이지 (유전체 데이터 조회) * @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: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환 return [{ request: latestRequest, // 분석 의뢰 정보 genomeCows: traitDetails.map(detail => ({ traitVal: detail.traitVal, // 형질 측정값 breedVal: detail.traitEbv, // EBV (추정육종가) percentile: detail.traitPercentile, // 백분위 순위 traitInfo: { traitNm: detail.traitName, // 형질명 traitCtgry: getTraitCategory(detail.traitName || ''), // 카테고리 (공통 함수 사용) traitDesc: '', // 형질 설명 (빈값) }, })), }]; } /** * 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회 * * @usedBy /cow/[cowNo] - 개체 상세 페이지 (분석 의뢰 정보 조회) * @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; } // ============================================ // 비교 분석 (Comparison) 관련 메서드 // ============================================ /** * 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터 조회 * * 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균, * 같은 농장 평균과 비교하여 상대적 위치 파악 * * @usedBy /cow/[cowNo] - 개체 상세 페이지 (카테고리별 레이더 차트) * @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 비교 데이터 조회 * * 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해 * 전국/지역/농장 평균을 형질 단위로 제공 * * @usedBy /cow/[cowNo] - 개체 상세 페이지 (형질별 폴리곤 차트) * @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, }; }); } // ============================================================ // 선발지수 계산 메서드 // ============================================================ /** * 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위 * * @usedBy /cow/[cowNo] - 개체 상세 페이지 (선발지수 계산) * @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, }; } /** * 개별 형질 기준 순위 조회 * * @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교, 정규분포 차트 * @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, }; } /** * 농가의 보은군 내 순위 조회 (대시보드용) * JOIN으로 한 번에 조회 * * @usedBy /dashboard - 대시보드 페이지 (농가 순위 카드) * @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. 모든 유전체 분석 의뢰 조회 (traitDetails 포함) const allRequests = await this.genomeRequestRepository.find({ where: { delDt: IsNull() }, relations: ['cow', 'farm', 'traitDetails'], }); // 3. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import) const traitConditions = inputTraitConditions && inputTraitConditions.length > 0 ? inputTraitConditions // 프론트에서 보낸 형질사용 : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용 console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits'); // 4. 각 개체별 점수 계산 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; // relations로 조회된 traitDetails 사용 const traitDetails = request.traitDetails; 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, }; } /** * 연도별 EBV 통계 조회 (개체상세용) * getDashboardStats의 yearlyStats와 yearlyAvgEbv 부분만 반환 * * @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교 * @param farmNo - 농장 번호 */ async getYearlyEbvStats(farmNo: number): Promise<{ yearlyStats: { year: number; totalRequests: number; analyzedCount: number; pendingCount: number; sireMatchCount: number; analyzeRate: number; sireMatchRate: number; }[]; yearlyAvgEbv: { year: number; farmAvgEbv: number; regionAvgEbv: number; traitCount: number; }[]; }> { const dashboardStats = await this.getDashboardStats(farmNo); return { yearlyStats: dashboardStats.yearlyStats, yearlyAvgEbv: dashboardStats.yearlyAvgEbv, }; } /** * 연도별 유전능력 추이 (형질별/카테고리별) * JOIN으로 한 번에 조회 * * @usedBy /dashboard - 대시보드 페이지 (연도별 추이 차트) * @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; // JOIN으로 한 번에 조회 (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, }, }; } }