Files
genome2025/backend/src/genome/genome.service.ts
2025-12-15 18:47:26 +09:00

2153 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/**
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
*/
const NEGATIVE_TRAITS = ['등지방두께'];
/**
* 형질명 → 카테고리 매핑 상수
* - 성장: 월령별 체중 관련 형질
* - 생산: 도체(도축 후 고기) 품질 관련 형질
* - 체형: 소의 신체 구조/외형 관련 형질
* - 무게: 각 부위별 실제 무게 (단위: kg)
* - 비율: 각 부위별 비율 (단위: %)
*/
const TRAIT_CATEGORY_MAP: Record<string, string> = {
// 성장 카테고리 - 월령별 체중
'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<GenomeRequestModel>,
// 형질 상세 정보 Repository
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 개체(소) 정보 Repository
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
// 농장 정보 Repository
@InjectRepository(FarmModel)
private readonly farmRepository: Repository<FarmModel>,
) { }
// ============================================
// 대시보드 통계 관련 메서드
// ============================================
/**
* 농가별 형질 비교 데이터 (농가 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<string, { sum: number; percentileSum: number; count: number; category: string }>();
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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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: {
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<string, typeof allTraitDetails>();
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<number, { total: number; analyzed: number; pending: number; sireMatch: number }>();
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<string, { sum: number; epdSum: number; percentileSum: number; count: number; category: string }>();
// 연도별 형질 평균 계산용 (전체 35개 형질)
const yearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
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<number, Map<string, { sum: number; count: number }>>();
// 보은군 내 모든 분석 완료된 요청 조회
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<string, { farmNo: number; avgEbv: number }[]>();
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<string, { sum: number; count: number }>();
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<number, Map<string, { sum: number; count: number }>>();
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;
// 성별 체크 - 디버깅 강화
// 실제 성별 값 분석
const sexAnalysis = requests.map(r => ({
cowId: r.cow?.cowId,
cowSex: r.cow?.cowSex,
hasCow: !!r.cow,
}));
// 성별 체크 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리)
const maleCount = requests.filter(r => {
const sex = r.cow?.cowSex?.toUpperCase();
return sex === 'M' || sex === '수' || sex === '1';
}).length;
// 수컷이 아니면 모두 암컷으로 처리 (null 포함)
const femaleCount = requests.length - maleCount;
// 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<string, number>();
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<string, number>();
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: {
totalRequests,
analyzedCount,
pendingCount,
mismatchCount,
maleCount,
femaleCount,
},
testTypeStats,
paternityStats,
monthlyStats,
chipTypeStats,
sampleAmountStats,
};
}
// ============================================
// 유전체 분석 의뢰 (Genome Request) 관련 메서드
// ============================================
/**
* 전체 유전체 분석 의뢰 목록 조회
*
* @returns 삭제되지 않은 모든 분석 의뢰 목록
* - cow, farm 관계 데이터 포함
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
*/
async findAllRequests(): Promise<GenomeRequestModel[]> {
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<GenomeRequestModel[]> {
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<any[]> {
// 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<GenomeRequestModel[]> {
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<GenomeRequestModel | null> {
// 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<GenomeRequestModel>): Promise<GenomeRequestModel> {
// 엔티티 인스턴스 생성
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<GenomeTraitDetailModel[]> {
return this.genomeTraitDetailRepository.find({
where: { fkRequestNo: requestNo, delDt: IsNull() },
});
}
/**
* cowId로 해당 개체의 형질 상세 목록 조회
*
* @param cowId - 개체식별번호 (KOR...)
* @returns 해당 개체의 모든 형질 상세 목록
*/
async findTraitDetailsByCowId(cowId: string): Promise<GenomeTraitDetailModel[]> {
return this.genomeTraitDetailRepository.find({
where: { cowId: cowId, delDt: IsNull() },
});
}
/**
* 새로운 형질 상세 데이터 생성
*
* @param data - 생성할 형질 상세 데이터
* @returns 생성된 형질 상세 엔티티
*/
async createTraitDetail(data: Partial<GenomeTraitDetailModel>): Promise<GenomeTraitDetailModel> {
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<ComparisonAveragesDto> {
// 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<TraitComparisonAveragesDto> {
// 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<TraitAverageDto[]> {
// 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<CategoryAverageDto[]> {
// 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<string, { ebvSum: number; epdSum: number; count: number }>();
// 각 상세 데이터를 카테고리별로 분류 및 합산
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<string, typeof allTraitDetails>();
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<number, { scores: number[]; farmerName: string | null }>();
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<any[]> {
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<string, {
farmNo: number;
year: number;
chipSireName: string | null;
chipDamName: string | null;
traits: { traitName: string; traitVal: number }[];
}>();
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<number, { sum: number; count: number }>();
const regionYearMap = new Map<number, { sum: number; count: number }>();
const farmEbvMap = new Map<number, { sum: number; count: number }>();
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,
},
};
}
}