Files
genome2025/backend/src/genome/genome.service.ts
2026-01-07 15:13:42 +09:00

1942 lines
71 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_REGION,
} from '../common/config/GenomeAnalysisConfig';
import {
ALL_TRAITS,
NEGATIVE_TRAITS,
TRAIT_CATEGORY_MAP,
getTraitCategory,
} from '../common/const/TraitTypes';
import { CowModel } from '../cow/entities/cow.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeRequestModel } from './entities/genome-request.entity';
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { CategoryAverageDto, ComparisonAveragesDto } from './dto/comparison-averages.dto';
import { TraitAverageDto, TraitComparisonAveragesDto } from './dto/trait-comparison.dto';
/**
* 유전체 분석 서비스
*
* 주요 기능:
* 1. 유전체 분석 의뢰(Request) CRUD
* 2. 형질(Trait) 데이터 관리
* 3. 형질 상세(TraitDetail) 데이터 관리
* 4. 전국/지역/농장별 EBV 평균 비교 분석
*/
@Injectable()
export class GenomeService {
constructor(
// 유전체 분석 의뢰 Repository
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<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>,
) { }
// ============================================
// 대시보드 통계 관련 메서드
// ============================================
/**
* 대시보드용 농가 통계 데이터
* - 연도별 분석 현황
* - 형질별 농장 평균 EBV
* - 접수 내역 목록
*
* @usedBy /dashboard - 대시보드 페이지
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교
* @param farmNo - 농장 번호
*/
async getDashboardStats(farmNo: number): Promise<{
// 연도별 분석 현황
yearlyStats: {
year: number;
totalRequests: number; // 총 접수
analyzedCount: number; // 분석 완료 (친자일치)
pendingCount: number; // 분석 대기/불일치
sireMatchCount: number; // 친자 일치 수
analyzeRate: number; // 분석 완료율 (%)
sireMatchRate: number; // 친자 일치율 (%)
}[];
// 형질별 농장 평균 (전체 개체 기준)
traitAverages: {
traitName: string;
category: string;
avgEbv: number;
avgEpd: number; // 육종가(EPD) 평균
avgPercentile: number;
count: number;
rank: number | null; // 보은군 내 농가 순위
totalFarms: number; // 보은군 내 총 농가 수
percentile: number | null; // 상위 백분율
}[];
// 접수 내역 목록
requestHistory: {
pkRequestNo: number;
cowId: string;
cowRemarks: string | null;
requestDt: string | null;
chipSireName: string | null; // 친자감별 결과
chipReportDt: string | null;
status: string; // 완료/대기/불일치
}[];
// 요약
summary: {
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외)
genomeCowCount: number; // 유전체 분석 개체 수
geneCowCount: number; // 유전자검사 개체 수
mptCowCount: number; // 번식능력검사 개체 수
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성)
analyzedCount: number;
pendingCount: number;
mismatchCount: number;
maleCount: number; // 수컷 수
femaleCount: number; // 암컷 수
};
// 검사 종류별 현황
testTypeStats: {
snp: { total: number; completed: number };
ms: { total: number; completed: number };
};
// 친자감별 결과 현황 (상호 배타적 분류)
paternityStats: {
analysisComplete: number; // 분석 완료 (부 일치 + 모 일치/null/정보없음)
sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치)
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재)
notAnalyzed: number; // 미분석
};
// 월별 접수 현황
monthlyStats: { month: number; count: number }[];
// 칩 종류별 분포
chipTypeStats: { chipType: string; count: number }[];
// 모근량별 분포
sampleAmountStats: { sampleAmount: string; count: number }[];
// 연도별 주요 형질 평균 (차트용)
yearlyTraitAverages: {
year: number;
traits: { traitName: string; avgEbv: number | null }[];
}[];
// 연도별 평균 표준화육종가 (농가 vs 보은군 비교)
yearlyAvgEbv: {
year: number;
farmAvgEbv: number; // 농가 평균
regionAvgEbv: number; // 보은군 평균
traitCount: number;
}[];
}> {
const startTime = Date.now();
console.log('[Dashboard] 시작');
// Step 0: 조회자의 농장 정보 확인 (테스트 농가 여부 판단)
const viewerFarm = await this.farmRepository.findOne({
where: { pkFarmNo: farmNo, delDt: IsNull() },
});
const isViewerTestFarm = viewerFarm?.regionSi !== VALID_REGION;
// Step 1: 농장의 모든 분석 의뢰 조회 (traitDetails 포함)
const requests = await this.genomeRequestRepository.find({
where: { fkFarmNo: farmNo, delDt: IsNull() },
relations: ['cow', 'traitDetails'],
order: { requestDt: 'DESC', regDt: 'DESC' },
});
console.log(`[Dashboard] Step1 농장 요청 조회: ${Date.now() - startTime}ms`);
// 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) {
// relations로 조회된 traitDetails 사용
const details = request.traitDetails || [];
if (details.length === 0) continue;
// 연도 추출
const year = request.requestDt ? new Date(request.requestDt).getFullYear() : new Date().getFullYear();
for (const detail of details) {
if (detail.traitEbv !== null && detail.traitName) {
const traitName = detail.traitName;
const category = TRAIT_CATEGORY_MAP[traitName] || '기타';
// 전체 형질 평균
if (!traitDataMap.has(traitName)) {
traitDataMap.set(traitName, { sum: 0, epdSum: 0, percentileSum: 0, count: 0, category });
}
const t = traitDataMap.get(traitName)!;
t.sum += Number(detail.traitEbv);
t.epdSum += Number(detail.traitVal) || 0; // 육종가(EPD) 합계
t.percentileSum += Number(detail.traitPercentile) || 50;
t.count++;
// 연도별 전체 형질 평균 (차트용)
if (!yearlyTraitMap.has(year)) {
yearlyTraitMap.set(year, new Map());
}
const yearTraits = yearlyTraitMap.get(year)!;
if (!yearTraits.has(traitName)) {
yearTraits.set(traitName, { sum: 0, count: 0 });
}
const yt = yearTraits.get(traitName)!;
yt.sum += Number(detail.traitEbv);
yt.count++;
}
}
}
// 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요)
// DB 집계로 최적화 + 병렬 실행
console.log(`[Dashboard] Step3 DB 집계 쿼리 시작 (병렬): ${Date.now() - startTime}ms`);
// 1, 2번 쿼리 병렬 실행 (보은군 농가만, 테스트 농가가 조회 시 자기 데이터도 포함)
const [farmTraitAvgResults, regionEpdResults] = await Promise.all([
// 1. 농가별 형질 평균 EBV (DB 집계)
this.genomeTraitDetailRepository
.createQueryBuilder('detail')
.innerJoin('detail.genomeRequest', 'req')
.innerJoin('tb_farm', 'farm', 'req.fkFarmNo = farm.pkFarmNo')
.select('req.fkFarmNo', 'farmNo')
.addSelect('detail.traitName', 'traitName')
.addSelect('AVG(detail.traitEbv)', 'avgEbv')
.where('detail.delDt IS NULL')
.andWhere('req.delDt IS NULL')
.andWhere('req.chipSireName = :match', { match: '일치' })
.andWhere('detail.traitEbv IS NOT NULL')
// 보은군 농가 또는 조회자 자신(테스트 농가일 때)
.andWhere('(farm.regionSi = :region OR req.fkFarmNo = :viewerFarmNo)',
{ region: VALID_REGION, viewerFarmNo: isViewerTestFarm ? farmNo : -1 })
.groupBy('req.fkFarmNo')
.addGroupBy('detail.traitName')
.getRawMany(),
// 2. 보은군 전체 형질별 평균 EPD (DB 집계) - 순수하게 보은군만
this.genomeTraitDetailRepository
.createQueryBuilder('detail')
.innerJoin('detail.genomeRequest', 'req')
.innerJoin('tb_farm', 'farm', 'req.fkFarmNo = farm.pkFarmNo')
.select('detail.traitName', 'traitName')
.addSelect('AVG(detail.traitVal)', 'avgEpd')
.addSelect('COUNT(*)', 'count')
.where('detail.delDt IS NULL')
.andWhere('req.delDt IS NULL')
.andWhere('req.chipSireName = :match', { match: '일치' })
.andWhere('detail.traitVal IS NOT NULL')
.andWhere('farm.regionSi = :region', { region: VALID_REGION })
.groupBy('detail.traitName')
.getRawMany(),
]);
// 형질별로 모든 농가의 평균 EBV 정렬 (순위용)
const traitRankingMap = new Map<string, { farmNo: number; avgEbv: number }[]>();
for (const row of farmTraitAvgResults) {
const traitName = row.traitName;
const farmNo = Number(row.farmNo);
const avgEbv = parseFloat(row.avgEbv);
if (!traitRankingMap.has(traitName)) {
traitRankingMap.set(traitName, []);
}
traitRankingMap.get(traitName)!.push({ farmNo, avgEbv });
}
// 각 형질별로 EBV 내림차순 정렬 (높을수록 좋음)
for (const [, farms] of traitRankingMap) {
farms.sort((a, b) => b.avgEbv - a.avgEbv);
}
const regionTraitEpdMap = new Map<string, { sum: number; count: number }>();
for (const row of regionEpdResults) {
const count = parseInt(row.count);
regionTraitEpdMap.set(row.traitName, {
sum: parseFloat(row.avgEpd) * count, // 평균 * count = 합계
count,
});
}
console.log(`[Dashboard] Step3 DB 집계 쿼리 완료: ${Date.now() - startTime}ms`);
// 형질별 평균 및 순위 계산 (표준 경쟁 순위 방식: 동률 시 같은 순위, 다음 순위 건너뜀)
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,
})),
}));
// 3. 보은군 연도별 형질 데이터 (DB 집계) - 순수하게 보은군만
const regionYearlyResults = await this.genomeTraitDetailRepository
.createQueryBuilder('detail')
.innerJoin('detail.genomeRequest', 'req')
.innerJoin('tb_farm', 'farm', 'req.fkFarmNo = farm.pkFarmNo')
.select('EXTRACT(YEAR FROM req.requestDt)', 'year')
.addSelect('detail.traitName', 'traitName')
.addSelect('SUM(detail.traitEbv)', 'sum')
.addSelect('COUNT(*)', 'count')
.where('detail.delDt IS NULL')
.andWhere('req.delDt IS NULL')
.andWhere('req.chipSireName = :match', { match: '일치' })
.andWhere('detail.traitEbv IS NOT NULL')
.andWhere('req.requestDt IS NOT NULL')
.andWhere('farm.regionSi = :region', { region: VALID_REGION })
.groupBy('EXTRACT(YEAR FROM req.requestDt)')
.addGroupBy('detail.traitName')
.getRawMany();
const regionYearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
for (const row of regionYearlyResults) {
const year = parseInt(row.year);
const traitName = row.traitName;
if (!regionYearlyTraitMap.has(year)) {
regionYearlyTraitMap.set(year, new Map());
}
regionYearlyTraitMap.get(year)!.set(traitName, {
sum: parseFloat(row.sum),
count: parseInt(row.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 조회 (병렬 처리 + cow JOIN으로 farmNo 조건)
console.log(`[Dashboard] Step5 4개 테이블 조회 시작: ${Date.now() - startTime}ms`);
const [farmGenomeRequestCowIds, farmGenomeCowIds, farmGeneCowIds, farmMptCowIds] = await Promise.all([
// 유전체 분석 의뢰가 있는 개체 (cow JOIN으로 farmNo 필터)
this.genomeRequestRepository
.createQueryBuilder('request')
.innerJoin('request.cow', 'cow')
.select('DISTINCT cow.cowId', 'cowId')
.where('request.delDt IS NULL')
.andWhere('cow.fkFarmNo = :farmNo', { farmNo })
.andWhere('cow.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
// 유전체 분석 개체 (cow JOIN으로 farmNo 필터)
this.genomeTraitDetailRepository
.createQueryBuilder('trait')
.innerJoin('tb_cow', 'cow', 'trait.cowId = cow.cowId')
.select('DISTINCT trait.cowId', 'cowId')
.where('trait.delDt IS NULL')
.andWhere('cow.fkFarmNo = :farmNo', { farmNo })
.andWhere('cow.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
// 유전자검사 개체 (cow JOIN으로 farmNo 필터)
this.geneDetailRepository
.createQueryBuilder('gene')
.innerJoin('tb_cow', 'cow', 'gene.cowId = cow.cowId')
.select('DISTINCT gene.cowId', 'cowId')
.where('gene.delDt IS NULL')
.andWhere('cow.fkFarmNo = :farmNo', { farmNo })
.andWhere('cow.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
// 번식능력검사 개체 (cow JOIN으로 farmNo 필터)
this.mptRepository
.createQueryBuilder('mpt')
.innerJoin('tb_cow', 'cow', 'mpt.cowId = cow.cowId')
.select('DISTINCT mpt.cowId', 'cowId')
.where('mpt.delDt IS NULL')
.andWhere('cow.fkFarmNo = :farmNo', { farmNo })
.andWhere('cow.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
]);
console.log(`[Dashboard] Step5 4개 테이블 조회 완료: ${Date.now() - startTime}ms`);
// 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치)
const isTestFarm = viewerFarm?.regionSi !== VALID_REGION;
/**
* 대시보드 개체 수 집계 로직
*
* [일반 농가 (regionSi = '보은군')]
* - 실제 검사(유전체/유전자/번식능력)를 받은 개체만 집계
* - 검사 데이터가 없는 개체는 대시보드에 표시되지 않음
*
* [테스트/기관 농가 (regionSi != '보은군', 예: '코쿤')]
* - 검사 여부와 관계없이 농장 소유 전체 개체를 집계
* - UI 테스트 목적으로 가짜 데이터 없이도 개체 목록 확인 가능
* - 실제 운영 데이터에는 영향 없음 (순위/평균 계산에서 제외됨)
*/
const allTestedCowIds = isTestFarm
? farmCowIds // 테스트 농가: 모든 소
: new Set([
...farmGenomeRequestCowIds,
...farmGenomeCowIds,
...farmGeneCowIds,
...farmMptCowIds,
]);
const totalCows = allTestedCowIds.size;
const genomeCowCount = farmGenomeCowIds.length;
const geneCowCount = farmGeneCowIds.length;
const mptCowCount = farmMptCowIds.length;
// 성별 체크 - 전체 검사 개체 기준 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리)
let maleCount = 0;
let femaleCount = 0;
for (const cowId of allTestedCowIds) {
const cow = farmCowMap.get(cowId);
const sex = cow?.cowSex?.toUpperCase();
if (sex === 'M' || sex === '수' || sex === '1') {
maleCount++;
} else {
femaleCount++;
}
}
// Step 6: 검사 종류별 현황 (SNP, MS)
const testTypeStats = {
snp: {
total: requests.filter(r => r.snpTest === 'Y' || r.snpTest === '유').length,
completed: requests.filter(r => (r.snpTest === 'Y' || r.snpTest === '유') && r.chipSireName === '일치').length,
},
ms: {
total: requests.filter(r => r.msTest === 'Y' || r.msTest === '유').length,
completed: requests.filter(r => (r.msTest === 'Y' || r.msTest === '유') && r.msResultStatus).length,
},
};
// Step 7: 친자감별 결과 현황 (상호 배타적 분류 - 총합이 totalRequests와 일치)
// 1. 분석 완료: 부 일치 + 모가 불일치/이력제부재가 아닌 경우
// 2. 부 불일치: 부가 일치가 아닌 경우
// 3. 모 불일치: 부 일치 + 모 불일치
// 4. 모 이력제부재: 부 일치 + 모 이력제부재
const paternityStats = {
// 분석 완료 (부 일치 + 모가 불일치/이력제부재 아님)
analysisComplete: requests.filter(r =>
r.chipSireName === '일치' &&
r.chipDamName !== '불일치' &&
r.chipDamName !== '이력제부재'
).length,
// 부 불일치 (부가 일치가 아닌 모든 경우)
sireMismatch: requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length,
// 모 불일치 (부 일치 + 모 불일치)
damMismatch: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '불일치').length,
// 모 이력제부재 (부 일치 + 모 이력제부재)
damNoRecord: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '이력제부재').length,
// 미분석 (chipSireName이 없는 경우)
notAnalyzed: requests.filter(r => !r.chipSireName).length,
};
// Step 8: 월별 접수 현황 (올해 기준)
const currentYear = new Date().getFullYear();
const monthlyStats: { month: number; count: number }[] = [];
for (let m = 1; m <= 12; m++) {
const count = requests.filter(r => {
if (!r.requestDt) return false;
const dt = new Date(r.requestDt);
return dt.getFullYear() === currentYear && dt.getMonth() + 1 === m;
}).length;
monthlyStats.push({ month: m, count });
}
// Step 9: 칩 종류별 분포
const chipTypeMap = new Map<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,
}));
console.log(`[Dashboard] 완료: ${Date.now() - startTime}ms`);
return {
yearlyStats,
traitAverages,
yearlyTraitAverages,
yearlyAvgEbv,
requestHistory,
summary: {
totalCows, // 검사 받은 전체 개체 수 (합집합)
genomeCowCount, // 유전체 분석 개체 수
geneCowCount, // 유전자검사 개체 수
mptCowCount, // 번식능력검사 개체 수
totalRequests, // 유전체 의뢰 건수 (기존 호환성)
analyzedCount,
pendingCount,
mismatchCount,
maleCount,
femaleCount,
},
testTypeStats,
paternityStats,
monthlyStats,
chipTypeStats,
sampleAmountStats,
};
}
// ============================================
// 유전체 분석 의뢰 (Genome Request) 관련 메서드
// ============================================
/**
* 개체식별번호(cowId)로 유전체 데이터 조회
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (유전체 데이터 조회)
* @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 유전체 분석 결과 배열
* - request: 분석 의뢰 정보
* - trait: 형질 기본 정보
* - genomeCows: 형질별 상세 데이터 (EBV, 백분위 등)
* @throws NotFoundException - 개체를 찾을 수 없는 경우
*/
async findByCowId(cowId: string): Promise<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: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
return [{
request: latestRequest, // 분석 의뢰 정보
genomeCows: traitDetails.map(detail => ({
traitVal: detail.traitVal, // 형질 측정값
breedVal: detail.traitEbv, // EBV (추정육종가)
percentile: detail.traitPercentile, // 백분위 순위
traitName: detail.traitName, // 형질명 (평평한 구조)
traitCategory: getTraitCategory(detail.traitName || ''), // 카테고리
traitDesc: '', // 형질 설명 (빈값)
})),
}];
}
/**
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (분석 의뢰 정보 조회)
* @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;
}
// ============================================
// 비교 분석 (Comparison) 관련 메서드
// ============================================
/**
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터 조회
*
* 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균,
* 같은 농장 평균과 비교하여 상대적 위치 파악
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (카테고리별 레이더 차트)
* @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 비교 데이터 조회
*
* 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해
* 전국/지역/농장 평균을 형질 단위로 제공
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (형질별 폴리곤 차트)
* @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,
};
});
}
// ============================================================
// 선발지수 계산 메서드
// ============================================================
/**
* 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (선발지수 계산)
* @param cowId - 개체식별번호 (예: KOR002119144049)
* @param traitConditions - 형질별 가중치 조건 배열
* @returns 선발지수 점수, 순위, 상세 내역
*
* @example
* traitConditions = [
* { traitNm: '도체중', weight: 8 },
* { traitNm: '근내지방도', weight: 10 }
* ]
*/
async getSelectionIndex(
cowId: string,
traitConditions: { traitNm: string; weight?: number }[]
): Promise<{
score: number | null;
percentile: number | null;
farmRank: number | null; // 농가 순위
farmTotal: number; // 농가 전체 수
regionRank: number | null; // 지역(보은군) 순위
regionTotal: number; // 지역 전체 수
regionName: string | null; // 지역명
farmerName: string | null; // 농가명 (농장주명)
farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
details: { traitNm: string; ebv: number; weight: number; contribution: number }[];
message?: string;
}> {
// Step 1: cowId로 개체 조회
const cow = await this.cowRepository.findOne({
where: { cowId: cowId, delDt: IsNull() },
});
if (!cow) {
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
}
// Step 2: 최신 유전체 분석 의뢰 조회
const latestRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
});
if (!latestRequest) {
return {
score: null, percentile: null,
farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null,
details: [], message: '유전체 분석 데이터 없음'
};
}
// Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
const traitDetails = await this.genomeTraitDetailRepository.find({
where: { cowId: cowId, delDt: IsNull() },
});
if (traitDetails.length === 0) {
return {
score: null, percentile: null,
farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null,
details: [], message: '형질 데이터 없음'
};
}
// Step 4: 가중 평균 계산 ================================================================================
let weightedSum = 0; // Σ(EBV × 가중치)
let totalWeight = 0; // Σ(가중치)
let percentileSum = 0; // 백분위 합계 (평균 계산용)
let percentileCount = 0; // 백분위 개수
let hasAllTraits = true; // 모든 선택 형질 존재 여부 (리스트와 동일 로직)
const details: {
traitNm: string;
ebv: number;
weight: number;
contribution: number
}[] = [];
for (const condition of traitConditions) {
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
const weight = condition.weight || 1; // 가중치 (기본값: 1)
if (trait && trait.traitEbv !== null) {
const ebv = Number(trait.traitEbv);
// 등지방두께 등 낮을수록 좋은 형질은 부호 반전
const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm);
const adjustedEbv = isNegativeTrait ? -ebv : ebv;
const contribution = adjustedEbv * weight; // EBV × 가중치
weightedSum += contribution;
totalWeight += weight;
// 백분위 평균 계산용
if (trait.traitPercentile !== null) {
percentileSum += Number(trait.traitPercentile);
percentileCount++;
}
details.push({
traitNm: condition.traitNm,
ebv: ebv,
weight: weight,
contribution: contribution,
});
} else {
// 형질이 없거나 EBV가 null이면 플래그 설정
hasAllTraits = false;
}
}
// Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산) ================================================================
const score = (hasAllTraits && totalWeight > 0) ? weightedSum : null;
const percentile = percentileCount > 0 ? percentileSum / percentileCount : null;
// Step 7: 현재 개체의 농장/지역 정보 조회
let regionName: string | null = null;
let farmerName: string | null = null;
let farmNo: number | null = latestRequest.fkFarmNo;
if (farmNo) {
const farm = await this.farmRepository.findOne({
where: { pkFarmNo: farmNo, delDt: IsNull() },
});
regionName = farm?.regionSi || null;
farmerName = farm?.farmerName || null;
}
// Step 8: 선택 형질 누락 시 조기 반환
if (!hasAllTraits) {
return {
score: null,
percentile: null,
farmRank: null,
farmTotal: 0,
regionRank: null,
regionTotal: 0,
regionName,
farmerName,
farmAvgScore: null,
regionAvgScore: null,
details,
message: '선택한 형질 중 일부 데이터가 없습니다',
};
}
// Step 9: 농가/지역 순위 및 평균 선발지수 계산
const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore } =
await this.calculateRanks(cowId, score, traitConditions, farmNo, regionName);
return {
score: score !== null ? Math.round(score * 100) / 100 : null,
percentile: percentile !== null ? Math.round(percentile * 100) / 100 : null,
farmRank,
farmTotal,
regionRank,
regionTotal,
regionName,
farmerName,
farmAvgScore,
regionAvgScore,
details,
};
}
/**
* 농가/지역 순위 계산 (Private)
*
* @param currentCowId - 현재 개체 식별번호
* @param currentScore - 현재 개체 점수
* @param traitConditions - 형질별 가중치 조건
* @param farmNo - 농가 번호
* @param regionName - 지역명 (보은군 등)
*/
private async calculateRanks(
currentCowId: string,
currentScore: number | null,
traitConditions: { traitNm: string; weight?: number }[],
farmNo: number | null,
regionName: string | null
): Promise<{
farmRank: number | null;
farmTotal: number;
regionRank: number | null;
regionTotal: number;
farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
}> {
// 점수가 없으면 순위 계산 불가
if (currentScore === null) {
return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null };
}
// 모든 유전체 분석 의뢰 조회 (유전체 데이터가 있는 모든 개체)
const allRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() },
relations: ['cow', 'farm'],
});
// 각 개체별 점수 계산
const allScores: { cowId: string; score: number; farmNo: number | null; regionSi: string | null }[] = [];
for (const request of allRequests) {
if (!request.cow?.cowId) continue;
// 보은군 농가만 포함 (테스트 농가가 조회 시 자기 데이터도 포함)
const isValidRegion = request.farm?.regionSi === VALID_REGION;
const isViewerOwnData = request.fkFarmNo === farmNo;
if (!isValidRegion && !isViewerOwnData) continue;
// 친자감별 결과가 '일치'인 경우만 포함 (분석불가 개체 제외)
if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue;
// cowId로 직접 형질 상세 데이터 조회
const traitDetails = await this.genomeTraitDetailRepository.find({
where: { cowId: request.cow.cowId, delDt: IsNull() },
});
if (traitDetails.length === 0) continue;
// 가중 평균 계산 (모든 형질 있어야 점수 계산)
let weightedSum = 0;
let totalWeight = 0;
let hasAllTraits = true;
for (const condition of traitConditions) {
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
const weight = condition.weight || 1;
if (trait && trait.traitEbv !== null) {
// 등지방두께 등 낮을수록 좋은 형질은 부호 반전
const ebv = Number(trait.traitEbv);
const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm);
const adjustedEbv = isNegativeTrait ? -ebv : ebv;
weightedSum += adjustedEbv * weight;
totalWeight += weight;
} else {
hasAllTraits = false;
}
}
// 모든 선택 형질이 있는 경우만 점수에 포함
if (hasAllTraits && totalWeight > 0) {
const score = weightedSum;
allScores.push({
cowId: request.cow.cowId,
score: Math.round(score * 100) / 100,
farmNo: request.fkFarmNo,
regionSi: request.farm?.regionSi || null,
});
}
}
// 점수 기준 내림차순 정렬
allScores.sort((a, b) => b.score - a.score);
console.log('[calculateRanks] 샘플 점수:', allScores.slice(0, 5).map(s => ({ cowId: s.cowId, score: s.score })));
// 농가 순위 및 평균 선발지수 계산
let farmRank: number | null = null;
let farmTotal = 0;
let farmAvgScore: number | null = null;
if (farmNo) {
const farmScores = allScores.filter(s => s.farmNo === farmNo);
farmTotal = farmScores.length;
const farmIndex = farmScores.findIndex(s => s.cowId === currentCowId);
farmRank = farmIndex >= 0 ? farmIndex + 1 : null;
// 농가 평균 선발지수 계산
if (farmScores.length > 0) {
const farmScoreSum = farmScores.reduce((sum, s) => sum + s.score, 0);
farmAvgScore = Math.round((farmScoreSum / farmScores.length) * 100) / 100;
}
}
// 지역(보은군) 순위 및 평균 선발지수 계산 - 전체 유전체 데이터가 보은군이므로 필터링 없이 전체 사용
let regionRank: number | null = null;
let regionTotal = allScores.length;
let regionAvgScore: number | null = null;
const regionIndex = allScores.findIndex(s => s.cowId === currentCowId);
regionRank = regionIndex >= 0 ? regionIndex + 1 : null;
// 보은군(지역) 평균 선발지수 계산
if (allScores.length > 0) {
const regionScoreSum = allScores.reduce((sum, s) => sum + s.score, 0);
regionAvgScore = Math.round((regionScoreSum / allScores.length) * 100) / 100;
}
return {
farmRank,
farmTotal,
regionRank,
regionTotal,
farmAvgScore,
regionAvgScore,
};
}
/**
* 개별 형질 기준 순위 조회
*
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교, 정규분포 차트
* @param cowId - 개체식별번호 (KOR...)
* @param traitName - 형질명 (도체중, 근내지방도 등)
*/
async getTraitRank(cowId: string, traitName: string): Promise<{
traitName: string;
cowEbv: number | null;
cowEpd: number | null; // 개체 육종가(EPD)
farmRank: number | null;
farmTotal: number;
regionRank: number | null;
regionTotal: number;
farmAvgEbv: number | null;
regionAvgEbv: number | null;
farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
}> {
// 1. 현재 개체의 의뢰 정보 조회
const cow = await this.cowRepository.findOne({
where: { cowId, delDt: IsNull() },
});
if (!cow) {
return {
traitName,
cowEbv: null,
cowEpd: null,
farmRank: null,
farmTotal: 0,
regionRank: null,
regionTotal: 0,
farmAvgEbv: null,
regionAvgEbv: null,
farmAvgEpd: null,
regionAvgEpd: null,
};
}
const currentRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
relations: ['farm'],
});
if (!currentRequest) {
return {
traitName,
cowEbv: null,
cowEpd: null,
farmRank: null,
farmTotal: 0,
regionRank: null,
regionTotal: 0,
farmAvgEbv: null,
regionAvgEbv: null,
farmAvgEpd: null,
regionAvgEpd: null,
};
}
const farmNo = currentRequest.fkFarmNo;
// 2. 모든 유전체 분석 의뢰 조회
const allRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() },
relations: ['cow', 'farm'],
});
// 3. 각 개체별 해당 형질 EBV, EPD 수집
const allScores: { cowId: string; ebv: number; epd: number | null; farmNo: number | null }[] = [];
for (const request of allRequests) {
if (!request.cow?.cowId) continue;
// 보은군 농가만 포함 (테스트 농가가 조회 시 자기 데이터도 포함)
const isValidRegion = request.farm?.regionSi === VALID_REGION;
const isViewerOwnData = request.fkFarmNo === farmNo;
if (!isValidRegion && !isViewerOwnData) continue;
if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue;
const traitDetail = await this.genomeTraitDetailRepository.findOne({
where: {
fkRequestNo: request.pkRequestNo,
traitName: traitName,
delDt: IsNull()
},
});
if (traitDetail && traitDetail.traitEbv !== null) {
allScores.push({
cowId: request.cow.cowId,
ebv: Number(traitDetail.traitEbv),
epd: traitDetail.traitVal !== null ? Number(traitDetail.traitVal) : null, // 육종가(EPD)
farmNo: request.fkFarmNo,
});
}
}
// 4. EBV 기준 내림차순 정렬
allScores.sort((a, b) => b.ebv - a.ebv);
// 5. 현재 개체의 EBV, EPD 찾기
const currentCowData = allScores.find(s => s.cowId === cowId);
const cowEbv = currentCowData?.ebv ?? null;
const cowEpd = currentCowData?.epd ?? null;
// 6. 보은군 전체 순위
const regionRank = currentCowData
? allScores.findIndex(s => s.cowId === cowId) + 1
: null;
const regionTotal = allScores.length;
// 보은군 평균 EBV, EPD
const regionAvgEbv = allScores.length > 0
? Math.round((allScores.reduce((sum, s) => sum + s.ebv, 0) / allScores.length) * 100) / 100
: null;
const regionEpdValues = allScores.filter(s => s.epd !== null).map(s => s.epd as number);
const regionAvgEpd = regionEpdValues.length > 0
? Math.round((regionEpdValues.reduce((sum, v) => sum + v, 0) / regionEpdValues.length) * 100) / 100
: null;
// 7. 농가 내 순위
const farmScores = allScores.filter(s => s.farmNo === farmNo);
const farmRank = currentCowData && farmNo
? farmScores.findIndex(s => s.cowId === cowId) + 1
: null;
const farmTotal = farmScores.length;
// 농가 평균 EBV, EPD
const farmAvgEbv = farmScores.length > 0
? Math.round((farmScores.reduce((sum, s) => sum + s.ebv, 0) / farmScores.length) * 100) / 100
: null;
const farmEpdValues = farmScores.filter(s => s.epd !== null).map(s => s.epd as number);
const farmAvgEpd = farmEpdValues.length > 0
? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100
: null;
return {
traitName,
cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null,
cowEpd: cowEpd !== null ? Math.round(cowEpd * 100) / 100 : null,
farmRank: farmRank && farmRank > 0 ? farmRank : null,
farmTotal,
regionRank: regionRank && regionRank > 0 ? regionRank : null,
regionTotal,
farmAvgEbv,
regionAvgEbv,
farmAvgEpd,
regionAvgEpd,
};
}
/**
* 농가의 보은군 내 순위 조회 (대시보드용)
* JOIN으로 한 번에 조회
*
* @usedBy /dashboard - 대시보드 페이지 (농가 순위 카드)
* @param farmNo - 농장 번호
*/
async getFarmRegionRanking(
farmNo: number,
inputTraitConditions?: { traitNm: string; weight?: number }[]
): Promise<{
farmNo: number;
farmerName: string | null;
farmAvgScore: number | null;
regionAvgScore: number | null;
farmRankInRegion: number | null;
totalFarmsInRegion: number;
percentile: number | null;
farmCowCount: number;
regionCowCount: number;
}> {
// 1. 농가 정보 조회
const farm = await this.farmRepository.findOne({
where: { pkFarmNo: farmNo, delDt: IsNull() },
});
if (!farm) {
return {
farmNo,
farmerName: null,
farmAvgScore: null,
regionAvgScore: null,
farmRankInRegion: null,
totalFarmsInRegion: 0,
percentile: null,
farmCowCount: 0,
regionCowCount: 0,
};
}
// 2. 모든 유전체 분석 의뢰 조회 (traitDetails 포함)
const allRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() },
relations: ['cow', 'farm', 'traitDetails'],
});
// 3. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions // 프론트에서 보낸 형질사용
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용
console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits');
// 4. 각 개체별 점수 계산
const allScores: { cowId: string; score: number; farmNo: number | null }[] = [];
for (const request of allRequests) {
if (!request.cow?.cowId) continue;
// 보은군 농가만 포함 (테스트 농가가 조회 시 자기 데이터도 포함)
const isValidRegion = request.farm?.regionSi === VALID_REGION;
const isViewerOwnData = request.fkFarmNo === farmNo;
if (!isValidRegion && !isViewerOwnData) continue;
if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue;
// relations로 조회된 traitDetails 사용
const traitDetails = request.traitDetails;
if (!traitDetails || traitDetails.length === 0) continue;
let weightedSum = 0;
let totalWeight = 0;
let hasAllTraits = true;
for (const condition of traitConditions) {
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
const weight = condition.weight || 1;
if (trait && trait.traitEbv !== null) {
// 등지방두께 등 낮을수록 좋은 형질은 부호 반전
const ebv = Number(trait.traitEbv);
const isNegativeTrait = NEGATIVE_TRAITS.includes(condition.traitNm);
const adjustedEbv = isNegativeTrait ? -ebv : ebv;
weightedSum += adjustedEbv * weight;
totalWeight += weight;
} else {
hasAllTraits = false;
}
}
if (hasAllTraits && totalWeight > 0) {
const score = weightedSum;
allScores.push({
cowId: request.cow.cowId,
score: Math.round(score * 100) / 100,
farmNo: request.fkFarmNo,
});
}
}
// 6. 농가별 평균 점수 계산
const farmScoresMap = new Map<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,
};
}
/**
* 연도별 EBV 통계 조회 (개체상세용)
* getDashboardStats의 yearlyStats와 yearlyAvgEbv 부분만 반환
*
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교
* @param farmNo - 농장 번호
*/
async getYearlyEbvStats(farmNo: number): Promise<{
yearlyStats: {
year: number;
totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[];
yearlyAvgEbv: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
traitCount: number;
}[];
}> {
const dashboardStats = await this.getDashboardStats(farmNo);
return {
yearlyStats: dashboardStats.yearlyStats,
yearlyAvgEbv: dashboardStats.yearlyAvgEbv,
};
}
/**
* 연도별 유전능력 추이 (형질별/카테고리별)
* JOIN으로 한 번에 조회
*
* @usedBy /dashboard - 대시보드 페이지 (연도별 추이 차트)
* @param farmNo - 농장 번호
* @param traitName - 형질명 (선택, 없으면 카테고리 전체)
* @param category - 카테고리명 (성장/생산/체형/무게/비율)
*/
async getYearlyTraitTrend(
farmNo: number,
category: string,
traitName?: string,
): Promise<{
category: string;
traitName: string | null;
yearlyData: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
farmCount: number;
regionCount: number;
}[];
traitList: string[];
farmRank: {
rank: number | null;
totalFarms: number;
percentile: number | null;
farmAvgEbv: number | null;
regionAvgEbv: number;
};
}> {
// 해당 카테고리의 형질 목록
const traitsInCategory = Object.entries(TRAIT_CATEGORY_MAP)
.filter(([_, cat]) => cat === category)
.map(([trait, _]) => trait);
// 대상 형질 결정
const targetTraits = traitName ? [traitName] : traitsInCategory;
// JOIN으로 한 번에 조회 (genome_request + cow + genome_trait_detail + farm)
const allData = await this.genomeRequestRepository
.createQueryBuilder('r')
.innerJoin('r.cow', 'c')
.innerJoin('r.farm', 'f')
.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')
.addSelect('f.region_si', 'regionSi')
.where('r.del_dt IS NULL')
.andWhere('d.trait_name IN (:...targetTraits)', { targetTraits })
.getRawMany();
// cowId별로 데이터 그룹화
const cowDataMap = new Map<string, {
farmNo: number;
year: number;
regionSi: string | null;
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(),
regionSi: row.regionSi || null,
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;
// 보은군 농가만 포함 (테스트 농가가 조회 시 자기 데이터도 포함)
const isValidRegion = data.regionSi === VALID_REGION;
const isViewerOwnData = data.farmNo === farmNo;
if (!isValidRegion && !isViewerOwnData) 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,
},
};
}
/**
* 농장의 가장 최근 분석 연도 조회
* chip_report_dt 또는 ms_report_dt 중 가장 최근 날짜의 년도 반환
* 둘 다 없으면 현재 년도 반환
*
* @param farmNo - 농장 번호
* @returns { year: number } - 가장 최근 분석 연도
*/
async getLatestAnalysisYear(farmNo: number): Promise<{ year: number }> {
console.log(`[getLatestAnalysisYear] farmNo: ${farmNo}`);
// 농장의 모든 분석 의뢰 조회
const requests = await this.genomeRequestRepository.find({
where: { fkFarmNo: farmNo, delDt: IsNull() },
select: ['chipReportDt', 'msReportDt'],
});
console.log(`[getLatestAnalysisYear] Found ${requests?.length || 0} requests`);
if (!requests || requests.length === 0) {
console.log('[getLatestAnalysisYear] No requests found, returning current year');
return { year: new Date().getFullYear() };
}
// chip_report_dt와 ms_report_dt 중 가장 최근 날짜 찾기
let latestDate: Date | null = null;
let latestChipDate: Date | null = null;
let latestMsDate: Date | null = null;
for (const request of requests) {
// chip_report_dt 확인
if (request.chipReportDt) {
const chipDate = new Date(request.chipReportDt);
if (!latestChipDate || chipDate > latestChipDate) {
latestChipDate = chipDate;
}
if (!latestDate || chipDate > latestDate) {
latestDate = chipDate;
}
}
// ms_report_dt 확인
if (request.msReportDt) {
const msDate = new Date(request.msReportDt);
if (!latestMsDate || msDate > latestMsDate) {
latestMsDate = msDate;
}
if (!latestDate || msDate > latestDate) {
latestDate = msDate;
}
}
}
console.log(`[getLatestAnalysisYear] Latest chip_report_dt: ${latestChipDate?.toISOString()}`);
console.log(`[getLatestAnalysisYear] Latest ms_report_dt: ${latestMsDate?.toISOString()}`);
console.log(`[getLatestAnalysisYear] Latest date overall: ${latestDate?.toISOString()}`);
// 가장 최근 날짜가 있으면 그 연도, 없으면 현재 연도
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
console.log(`[getLatestAnalysisYear] Returning year: ${year}`);
return { year };
}
}