2229 lines
81 KiB
TypeScript
2229 lines
81 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { IsNull, Repository } from 'typeorm';
|
||
import {
|
||
isValidGenomeAnalysis,
|
||
VALID_CHIP_SIRE_NAME
|
||
} from '../common/config/GenomeAnalysisConfig';
|
||
import { CowModel } from '../cow/entities/cow.entity';
|
||
import { FarmModel } from '../farm/entities/farm.entity';
|
||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||
import { MptModel } from '../mpt/entities/mpt.entity';
|
||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
||
|
||
/**
|
||
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
||
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
||
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
||
*/
|
||
const NEGATIVE_TRAITS = ['등지방두께'];
|
||
|
||
/**
|
||
* 형질명 → 카테고리 매핑 상수
|
||
* - 성장: 월령별 체중 관련 형질
|
||
* - 생산: 도체(도축 후 고기) 품질 관련 형질
|
||
* - 체형: 소의 신체 구조/외형 관련 형질
|
||
* - 무게: 각 부위별 실제 무게 (단위: kg)
|
||
* - 비율: 각 부위별 비율 (단위: %)
|
||
*/
|
||
const TRAIT_CATEGORY_MAP: Record<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>,
|
||
|
||
// 번식능력검사 Repository
|
||
@InjectRepository(MptModel)
|
||
private readonly mptRepository: Repository<MptModel>,
|
||
|
||
// 유전자검사 상세 Repository
|
||
@InjectRepository(GeneDetailModel)
|
||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||
) { }
|
||
|
||
// ============================================
|
||
// 대시보드 통계 관련 메서드
|
||
// ============================================
|
||
|
||
/**
|
||
* 농가별 형질 비교 데이터 (농가 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: {
|
||
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외)
|
||
genomeCowCount: number; // 유전체 분석 개체 수
|
||
geneCowCount: number; // 유전자검사 개체 수
|
||
mptCowCount: number; // 번식능력검사 개체 수
|
||
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성)
|
||
analyzedCount: number;
|
||
pendingCount: number;
|
||
mismatchCount: number;
|
||
maleCount: number; // 수컷 수
|
||
femaleCount: number; // 암컷 수
|
||
};
|
||
// 검사 종류별 현황
|
||
testTypeStats: {
|
||
snp: { total: number; completed: number };
|
||
ms: { total: number; completed: number };
|
||
};
|
||
// 친자감별 결과 현황 (상호 배타적 분류)
|
||
paternityStats: {
|
||
analysisComplete: number; // 분석 완료 (부 일치 + 모 일치/null/정보없음)
|
||
sireMismatch: number; // 부 불일치
|
||
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치)
|
||
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재)
|
||
pending: number; // 대기
|
||
};
|
||
// 월별 접수 현황
|
||
monthlyStats: { month: number; count: number }[];
|
||
// 칩 종류별 분포
|
||
chipTypeStats: { chipType: string; count: number }[];
|
||
// 모근량별 분포
|
||
sampleAmountStats: { sampleAmount: string; count: number }[];
|
||
// 연도별 주요 형질 평균 (차트용)
|
||
yearlyTraitAverages: {
|
||
year: number;
|
||
traits: { traitName: string; avgEbv: number | null }[];
|
||
}[];
|
||
// 연도별 평균 표준화육종가 (농가 vs 보은군 비교)
|
||
yearlyAvgEbv: {
|
||
year: number;
|
||
farmAvgEbv: number; // 농가 평균
|
||
regionAvgEbv: number; // 보은군 평균
|
||
traitCount: number;
|
||
}[];
|
||
}> {
|
||
// Step 1: 농장의 모든 분석 의뢰 조회
|
||
const requests = await this.genomeRequestRepository.find({
|
||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||
relations: ['cow'],
|
||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||
});
|
||
|
||
// Step 1.5: 모든 형질 상세 데이터를 한 번에 조회 (N+1 문제 해결)
|
||
const allTraitDetails = await this.genomeTraitDetailRepository.find({
|
||
where: { delDt: IsNull() },
|
||
});
|
||
|
||
// cowId로 형질 데이터를 그룹화 (Map으로 빠른 조회)
|
||
const traitDetailsByCowId = new Map<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;
|
||
|
||
// Step 5.1: 검사 유형별 개체 수 계산 (합집합, 중복 제외)
|
||
// 농장 소유 개체의 cowId 목록 조회
|
||
const farmCows = await this.cowRepository.find({
|
||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||
select: ['cowId', 'cowSex'],
|
||
});
|
||
const farmCowIds = new Set(farmCows.map(c => c.cowId).filter(Boolean));
|
||
const farmCowMap = new Map(farmCows.map(c => [c.cowId, c]));
|
||
|
||
// 각 검사별 cowId 조회 (병렬 처리) - genomeRequest도 포함 (리스트 페이지와 일치)
|
||
const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
|
||
// 유전체 분석 의뢰가 있는 개체 (분석불가 포함)
|
||
this.genomeRequestRepository
|
||
.createQueryBuilder('request')
|
||
.innerJoin('request.cow', 'cow')
|
||
.select('DISTINCT cow.cowId', 'cowId')
|
||
.where('request.delDt IS NULL')
|
||
.getRawMany()
|
||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
||
// 유전체 분석 개체 (형질 데이터 보유)
|
||
this.genomeTraitDetailRepository
|
||
.createQueryBuilder('trait')
|
||
.select('DISTINCT trait.cowId', 'cowId')
|
||
.where('trait.delDt IS NULL')
|
||
.getRawMany()
|
||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
||
// 유전자검사 개체
|
||
this.geneDetailRepository
|
||
.createQueryBuilder('gene')
|
||
.select('DISTINCT gene.cowId', 'cowId')
|
||
.where('gene.delDt IS NULL')
|
||
.getRawMany()
|
||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
||
// 번식능력검사 개체
|
||
this.mptRepository
|
||
.createQueryBuilder('mpt')
|
||
.select('DISTINCT mpt.cowId', 'cowId')
|
||
.where('mpt.delDt IS NULL')
|
||
.getRawMany()
|
||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
||
]);
|
||
|
||
// 농장 소유 개체만 필터링
|
||
const farmGenomeRequestCowIds = genomeRequestCowIds.filter(id => farmCowIds.has(id));
|
||
const farmGenomeCowIds = genomeCowIds.filter(id => farmCowIds.has(id));
|
||
const farmGeneCowIds = geneCowIds.filter(id => farmCowIds.has(id));
|
||
const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id));
|
||
|
||
// 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치)
|
||
const allTestedCowIds = new Set([
|
||
...farmGenomeRequestCowIds,
|
||
...farmGenomeCowIds,
|
||
...farmGeneCowIds,
|
||
...farmMptCowIds,
|
||
]);
|
||
|
||
const totalCows = allTestedCowIds.size;
|
||
const genomeCowCount = farmGenomeCowIds.length;
|
||
const geneCowCount = farmGeneCowIds.length;
|
||
const mptCowCount = farmMptCowIds.length;
|
||
|
||
// 성별 체크 - 전체 검사 개체 기준 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리)
|
||
let maleCount = 0;
|
||
let femaleCount = 0;
|
||
for (const cowId of allTestedCowIds) {
|
||
const cow = farmCowMap.get(cowId);
|
||
const sex = cow?.cowSex?.toUpperCase();
|
||
if (sex === 'M' || sex === '수' || sex === '1') {
|
||
maleCount++;
|
||
} else {
|
||
femaleCount++;
|
||
}
|
||
}
|
||
|
||
// Step 6: 검사 종류별 현황 (SNP, MS)
|
||
const testTypeStats = {
|
||
snp: {
|
||
total: requests.filter(r => r.snpTest === 'Y' || r.snpTest === '유').length,
|
||
completed: requests.filter(r => (r.snpTest === 'Y' || r.snpTest === '유') && r.chipSireName === '일치').length,
|
||
},
|
||
ms: {
|
||
total: requests.filter(r => r.msTest === 'Y' || r.msTest === '유').length,
|
||
completed: requests.filter(r => (r.msTest === 'Y' || r.msTest === '유') && r.msResultStatus).length,
|
||
},
|
||
};
|
||
|
||
// Step 7: 친자감별 결과 현황 (상호 배타적 분류 - 총합이 totalRequests와 일치)
|
||
// 1. 분석 완료: 부 일치 + 모가 불일치/이력제부재가 아닌 경우
|
||
// 2. 부 불일치: 부가 일치가 아닌 경우
|
||
// 3. 모 불일치: 부 일치 + 모 불일치
|
||
// 4. 모 이력제부재: 부 일치 + 모 이력제부재
|
||
const paternityStats = {
|
||
// 분석 완료 (부 일치 + 모가 불일치/이력제부재 아님)
|
||
analysisComplete: requests.filter(r =>
|
||
r.chipSireName === '일치' &&
|
||
r.chipDamName !== '불일치' &&
|
||
r.chipDamName !== '이력제부재'
|
||
).length,
|
||
// 부 불일치 (부가 일치가 아닌 모든 경우)
|
||
sireMismatch: requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length,
|
||
// 모 불일치 (부 일치 + 모 불일치)
|
||
damMismatch: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '불일치').length,
|
||
// 모 이력제부재 (부 일치 + 모 이력제부재)
|
||
damNoRecord: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '이력제부재').length,
|
||
// 대기 (chipSireName이 없는 경우)
|
||
pending: requests.filter(r => !r.chipSireName).length,
|
||
};
|
||
|
||
// Step 8: 월별 접수 현황 (올해 기준)
|
||
const currentYear = new Date().getFullYear();
|
||
const monthlyStats: { month: number; count: number }[] = [];
|
||
for (let m = 1; m <= 12; m++) {
|
||
const count = requests.filter(r => {
|
||
if (!r.requestDt) return false;
|
||
const dt = new Date(r.requestDt);
|
||
return dt.getFullYear() === currentYear && dt.getMonth() + 1 === m;
|
||
}).length;
|
||
monthlyStats.push({ month: m, count });
|
||
}
|
||
|
||
// Step 9: 칩 종류별 분포
|
||
const chipTypeMap = new Map<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: {
|
||
totalCows, // 검사 받은 전체 개체 수 (합집합)
|
||
genomeCowCount, // 유전체 분석 개체 수
|
||
geneCowCount, // 유전자검사 개체 수
|
||
mptCowCount, // 번식능력검사 개체 수
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
} |