파일 정리

This commit is contained in:
2025-12-24 22:50:13 +09:00
parent 05d89fdfcd
commit 2877a474eb
22 changed files with 1274 additions and 646 deletions

View File

@@ -213,31 +213,31 @@ export const MPT_CATEGORIES: MptCategory[] = [
{
key: 'energy',
name: '에너지 대사',
color: 'bg-orange-500',
color: 'bg-muted/50',
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
},
{
key: 'protein',
name: '단백질 대사',
color: 'bg-blue-500',
color: 'bg-muted/50',
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
},
{
key: 'liver',
name: '간기능',
color: 'bg-green-500',
color: 'bg-muted/50',
items: ['ast', 'ggt', 'fattyLiverIdx'],
},
{
key: 'mineral',
name: '미네랄',
color: 'bg-purple-500',
color: 'bg-muted/50',
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
},
{
key: 'etc',
name: '기타',
color: 'bg-gray-500',
color: 'bg-muted/50',
items: ['creatine'],
},
];

View File

@@ -189,22 +189,14 @@ export class CowService {
.where('gene.delDt IS NULL')
.getRawMany(),
// 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령
// 서브쿼리로 최신 검사일 기준 데이터 가져오기
// cowId별 최신 검사일 기준으로 중복 제거 (GROUP BY)
this.mptRepository
.createQueryBuilder('mpt')
.select('mpt.cowId', 'cowId')
.addSelect('mpt.testDt', 'testDt')
.addSelect('mpt.monthAge', 'monthAge')
.addSelect('MAX(mpt.testDt)', 'testDt')
.addSelect('MAX(mpt.monthAge)', 'monthAge')
.where('mpt.delDt IS NULL')
.andWhere(qb => {
const subQuery = qb.subQuery()
.select('MAX(sub.testDt)')
.from('tb_mpt', 'sub')
.where('sub.cow_id = mpt.cowId')
.andWhere('sub.del_dt IS NULL')
.getQuery();
return `mpt.testDt = ${subQuery}`;
})
.groupBy('mpt.cowId')
.getRawMany(),
]);
@@ -223,8 +215,23 @@ export class CowService {
.map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }])
);
// 데이터가 있는 개체가 없으면 빈 배열 반환
if (allCowIds.length === 0) {
// 데이터가 있는 개체가 없으면 빈 배열 반환 (단, 테스트 농장 예외)
const TEST_FARM_NO = 26; // 코쿤 테스트 농장
// farmNo 체크: filterOptions.farmNo 또는 filterOptions.filters에서 추출
let isTestFarm = Number(filterOptions?.farmNo) === TEST_FARM_NO;
if (!isTestFarm && filterOptions?.filters) {
const farmFilter = filterOptions.filters.find(
(f: { field: string; value: number | number[] }) => f.field === 'cow.fkFarmNo'
);
if (farmFilter) {
const farmNos = Array.isArray(farmFilter.value) ? farmFilter.value : [farmFilter.value];
// 숫자/문자열 모두 처리 (프론트에서 문자열로 올 수 있음)
isTestFarm = farmNos.map(Number).includes(TEST_FARM_NO);
}
}
if (allCowIds.length === 0 && !isTestFarm) {
return { cows: [], mptCowIdMap };
}
@@ -232,8 +239,12 @@ export class CowService {
const queryBuilder = this.cowRepository
.createQueryBuilder('cow')
.leftJoinAndSelect('cow.farm', 'farm')
.where('cow.cowId IN (:...cowIds)', { cowIds: allCowIds })
.andWhere('cow.delDt IS NULL');
.where('cow.delDt IS NULL');
// 테스트 농장(26번)은 tb_cow 전체 조회, 그 외는 데이터 있는 개체만
if (!isTestFarm && allCowIds.length > 0) {
queryBuilder.andWhere('cow.cowId IN (:...cowIds)', { cowIds: allCowIds });
}
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
if (filterOptions?.farmNo) {
@@ -242,18 +253,29 @@ export class CowService {
});
}
// FilterEngine 사용하여 동적 필터 적용
// FilterEngine 사용하여 동적 필터 적용 (페이지네이션 없이 전체 조회)
if (filterOptions?.filters) {
const result = await this.filterEngineService.executeFilteredQuery(
queryBuilder,
filterOptions,
{
...filterOptions,
pagination: { page: 1, limit: 10000 }, // 전체 조회 (프론트에서 페이지네이션 처리)
},
);
return { cows: result.data, mptCowIdMap };
// cowId 기준 중복 제거 (tb_cow에 같은 cowId가 여러 row일 수 있음)
const uniqueCows = Array.from(
new Map(result.data.map((cow: CowModel) => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
}
// 필터 없으면 전체 조회 (최신순)
const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
return { cows, mptCowIdMap };
// cowId 기준 중복 제거
const uniqueCows = Array.from(
new Map(cows.map(cow => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
}
// ============================================================

View File

@@ -0,0 +1,25 @@
/**
* 카테고리별 평균 EBV 정보
*/
export interface CategoryAverageDto {
/** 카테고리명 (성장/생산/체형/무게/비율) */
category: string;
/** 평균 EBV 값 (표준화 육종가) */
avgEbv: number;
/** 평균 EPD 값 (원래 육종가) */
avgEpd: number;
/** 해당 카테고리의 데이터 개수 */
count: number;
}
/**
* 전국/지역/농가 비교 평균 데이터
*/
export interface ComparisonAveragesDto {
/** 전국 평균 */
nationwide: CategoryAverageDto[];
/** 지역 평균 */
region: CategoryAverageDto[];
/** 농가 평균 */
farm: CategoryAverageDto[];
}

View File

@@ -0,0 +1,102 @@
/**
* 대시보드 요약 정보 DTO
*/
export interface DashboardSummaryDto {
// 요약
summary: {
totalCows: number; // 검사 받은 전체 개체 수
genomeCowCount: number; // 유전체 분석 개체 수
geneCowCount: number; // 유전자검사 개체 수
mptCowCount: number; // 번식능력검사 개체 수
totalRequests: number; // 유전체 의뢰 건수
analyzedCount: number; // 분석 완료
pendingCount: number; // 대기
mismatchCount: number; // 불일치
maleCount: number; // 수컷 수
femaleCount: number; // 암컷 수
};
// 친자감별 결과 현황
paternityStats: {
analysisComplete: number; // 분석 완료
sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치
damNoRecord: number; // 모 이력제부재
notAnalyzed: number; // 미분석
};
// 검사 종류별 현황
testTypeStats: {
snp: { total: number; completed: number };
ms: { total: number; completed: number };
};
}
/**
* 연도별 통계 DTO
*/
export interface YearlyStatsDto {
// 연도별 분석 현황
yearlyStats: {
year: number;
totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[];
// 월별 접수 현황
monthlyStats: { month: number; count: number }[];
// 연도별 평균 EBV (농가 vs 보은군)
yearlyAvgEbv: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
traitCount: number;
}[];
}
/**
* 형질 평균 DTO
*/
export interface TraitAveragesDto {
traitAverages: {
traitName: string;
category: string;
avgEbv: number;
avgEpd: number;
avgPercentile: number;
count: number;
rank: number | null;
totalFarms: number;
percentile: number | null;
regionAvgEpd?: number;
}[];
// 연도별 형질 평균 (차트용)
yearlyTraitAverages: {
year: number;
traits: { traitName: string; avgEbv: number | null }[];
}[];
}
/**
* 접수 내역 DTO
*/
export interface RequestHistoryDto {
requestHistory: {
pkRequestNo: number;
cowId: string;
cowRemarks: string | null;
requestDt: string | null;
chipSireName: string | null;
chipReportDt: string | null;
status: string;
}[];
}
/**
* 칩/모근 통계 DTO
*/
export interface ChipStatsDto {
chipTypeStats: { chipType: string; count: number }[];
sampleAmountStats: { sampleAmount: string; count: number }[];
}

View File

@@ -0,0 +1,19 @@
/**
* 형질별 평균 EBV 응답 DTO
*/
export interface TraitAverageDto {
traitName: string; // 형질명
category: string; // 카테고리
avgEbv: number; // 평균 EBV (표준화 육종가)
avgEpd: number; // 평균 EPD (육종가 원본값)
count: number; // 데이터 개수
}
/**
* 형질별 비교 평균 응답 DTO
*/
export interface TraitComparisonAveragesDto {
nationwide: TraitAverageDto[]; // 전국 평균
region: TraitAverageDto[]; // 지역(군) 평균
farm: TraitAverageDto[]; // 농장 평균
}

View File

@@ -1,11 +1,13 @@
import { BaseModel } from 'src/common/entities/base.entity';
import { CowModel } from 'src/cow/entities/cow.entity';
import { FarmModel } from 'src/farm/entities/farm.entity';
import { GenomeTraitDetailModel } from './genome-trait-detail.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
@@ -189,4 +191,7 @@ export class GenomeRequestModel extends BaseModel {
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fk_farm_no' })
farm: FarmModel;
@OneToMany(() => GenomeTraitDetailModel, (trait) => trait.genomeRequest)
traitDetails: GenomeTraitDetailModel[];
}

View File

@@ -1,17 +1,6 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { GenomeService } from './genome.service';
export interface CategoryAverageDto {
category: string;
avgEbv: number;
count: number;
}
export interface ComparisonAveragesDto {
nationwide: CategoryAverageDto[];
region: CategoryAverageDto[];
farm: CategoryAverageDto[];
}
import { ComparisonAveragesDto } from './dto/comparison-averages.dto';
@Controller('genome')
export class GenomeController {
@@ -100,6 +89,16 @@ export class GenomeController {
}
/**
* GET /genome/yearly-ebv-stats/:farmNo
* 연도별 EBV 통계 (개체상세 > 유전체 통합비교용)
* @param farmNo - 농장 번호
*/
@Get('yearly-ebv-stats/:farmNo')
getYearlyEbvStats(@Param('farmNo') farmNo: string) {
return this.genomeService.getYearlyEbvStats(+farmNo);
}
/**
* GET /genome/yearly-trait-trend/:farmNo
* 연도별 유전능력 추이 (형질별/카테고리별)

View File

@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import {
isValidGenomeAnalysis,
VALID_CHIP_SIRE_NAME
} from '../common/config/GenomeAnalysisConfig';
import {
ALL_TRAITS,
@@ -17,46 +16,8 @@ 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(추정육종가) 응답 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[]; // 농장 평균
}
import { CategoryAverageDto, ComparisonAveragesDto } from './dto/comparison-averages.dto';
import { TraitAverageDto, TraitComparisonAveragesDto } from './dto/trait-comparison.dto';
/**
* 유전체 분석 서비스
@@ -105,6 +66,8 @@ export class GenomeService {
* - 형질별 농장 평균 EBV
* - 접수 내역 목록
*
* @usedBy /dashboard - 대시보드 페이지
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교
* @param farmNo - 농장 번호
*/
async getDashboardStats(farmNo: number): Promise<{
@@ -164,7 +127,7 @@ export class GenomeService {
sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치)
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재)
pending: number; // 대기
notAnalyzed: number; // 미분석
};
// 월별 접수 현황
monthlyStats: { month: number; count: number }[];
@@ -185,28 +148,13 @@ export class GenomeService {
traitCount: number;
}[];
}> {
// Step 1: 농장의 모든 분석 의뢰 조회
// Step 1: 농장의 모든 분석 의뢰 조회 (traitDetails 포함)
const requests = await this.genomeRequestRepository.find({
where: { fkFarmNo: farmNo, delDt: IsNull() },
relations: ['cow'],
relations: ['cow', 'traitDetails'],
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 }>();
@@ -243,7 +191,7 @@ export class GenomeService {
sireMatchRate: stat.total > 0 ? Math.round((stat.sireMatch / stat.total) * 100) : 0,
}));
// Step 3: 분석 완료된 개체의 형질 데이터 수집 (메모리에서 처리)
// Step 3: 분석 완료된 개체의 형질 데이터 수집
const validRequests = requests.filter(r => r.chipSireName === '일치');
const traitDataMap = new Map<string, { sum: number; epdSum: number; percentileSum: number; count: number; category: string }>();
@@ -251,8 +199,8 @@ export class GenomeService {
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 || '') || [];
// relations로 조회된 traitDetails 사용
const details = request.traitDetails || [];
if (details.length === 0) continue;
// 연도 추출
@@ -290,14 +238,15 @@ export class GenomeService {
}
// 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요)
// Step: 보은군 내 모든 농가의 형질별 평균 EBV 계산 (메모리에서 처리)
// 보은군 내 모든 농가의 형질별 평균 EBV 계산
const allFarmsTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
// 보은군 내 모든 분석 완료된 요청 조회
// 보은군 내 모든 분석 완료된 요청 조회 (traitDetails 포함)
const allRegionValidRequests = await this.genomeRequestRepository
.createQueryBuilder('req')
.leftJoinAndSelect('req.cow', 'cow')
.leftJoinAndSelect('req.farm', 'farm')
.leftJoinAndSelect('req.traitDetails', 'traitDetails')
.where('req.delDt IS NULL')
.andWhere('req.chipSireName = :match', { match: '일치' })
.getMany();
@@ -306,8 +255,8 @@ export class GenomeService {
const reqFarmNo = req.fkFarmNo;
if (!reqFarmNo) continue;
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회)
const details = traitDetailsByCowId.get(req.cow?.cowId || '') || [];
// relations로 조회된 traitDetails 사용
const details = req.traitDetails || [];
if (details.length === 0) continue;
if (!allFarmsTraitMap.has(reqFarmNo)) {
@@ -350,7 +299,7 @@ export class GenomeService {
// 보은군 전체 형질별 평균 EPD 계산 (모든 농가의 모든 개체 데이터 사용)
const regionTraitEpdMap = new Map<string, { sum: number; count: number }>();
for (const req of allRegionValidRequests) {
const details = traitDetailsByCowId.get(req.cow?.cowId || '') || [];
const details = req.traitDetails || [];
for (const detail of details) {
if (detail.traitVal !== null && detail.traitName) {
const traitName = detail.traitName;
@@ -421,21 +370,21 @@ export class GenomeService {
})),
}));
// 보은군 전체 연도별 평균 계산을 위한 데이터 조회
// 보은군 전체 연도별 평균 계산을 위한 데이터 조회 (traitDetails 포함)
const allRegionRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() },
relations: ['cow'],
relations: ['cow', 'traitDetails'],
});
// 보은군 연도별 형질 데이터 수집 (메모리에서 처리)
// 보은군 연도별 형질 데이터 수집
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 || '') || [];
// relations로 조회된 traitDetails 사용
const details = req.traitDetails || [];
if (details.length === 0) continue;
if (!regionYearlyTraitMap.has(year)) {
@@ -565,12 +514,18 @@ export class GenomeService {
const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id));
// 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치)
const allTestedCowIds = new Set([
...farmGenomeRequestCowIds,
...farmGenomeCowIds,
...farmGeneCowIds,
...farmMptCowIds,
]);
const TEST_FARM_NO = 26; // 코쿤 테스트 농장
const isTestFarm = farmNo === TEST_FARM_NO;
// 테스트 농장(26번)은 tb_cow 전체, 그 외는 검사 받은 개체만
const allTestedCowIds = isTestFarm
? farmCowIds
: new Set([
...farmGenomeRequestCowIds,
...farmGenomeCowIds,
...farmGeneCowIds,
...farmMptCowIds,
]);
const totalCows = allTestedCowIds.size;
const genomeCowCount = farmGenomeCowIds.length;
@@ -620,8 +575,8 @@ export class GenomeService {
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,
// 미분석 (chipSireName이 없는 경우)
notAnalyzed: requests.filter(r => !r.chipSireName).length,
};
// Step 8: 월별 접수 현황 (올해 기준)
@@ -694,6 +649,7 @@ export class GenomeService {
* 개체식별번호(cowId)로 유전체 데이터 조회
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (유전체 데이터 조회)
* @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 유전체 분석 결과 배열
* - request: 분석 의뢰 정보
@@ -754,6 +710,7 @@ export class GenomeService {
/**
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (분석 의뢰 정보 조회)
* @param cowId - 개체식별번호 (예: KOR002115897818)
* @returns 최신 분석 의뢰 정보 (없으면 null)
*/
@@ -787,6 +744,7 @@ export class GenomeService {
* 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균,
* 같은 농장 평균과 비교하여 상대적 위치 파악
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (카테고리별 레이더 차트)
* @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 전국/지역/농장별 카테고리 평균 EBV
* @throws NotFoundException - 개체를 찾을 수 없는 경우
@@ -850,6 +808,7 @@ export class GenomeService {
* 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해
* 전국/지역/농장 평균을 형질 단위로 제공
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (형질별 폴리곤 차트)
* @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 전국/지역/농장별 형질별 평균 EBV
* @throws NotFoundException - 개체를 찾을 수 없는 경우
@@ -1036,6 +995,7 @@ export class GenomeService {
/**
* 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위
*
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (선발지수 계산)
* @param cowId - 개체식별번호 (예: KOR002119144049)
* @param traitConditions - 형질별 가중치 조건 배열
* @returns 선발지수 점수, 순위, 상세 내역
@@ -1333,6 +1293,8 @@ export class GenomeService {
/**
* 개별 형질 기준 순위 조회
*
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교, 정규분포 차트
* @param cowId - 개체식별번호 (KOR...)
* @param traitName - 형질명 (도체중, 근내지방도 등)
*/
@@ -1480,7 +1442,9 @@ export class GenomeService {
/**
* 농가의 보은군 내 순위 조회 (대시보드용)
* 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리
* JOIN으로 한 번에 조회
*
* @usedBy /dashboard - 대시보드 페이지 (농가 순위 카드)
* @param farmNo - 농장 번호
*/
async getFarmRegionRanking(
@@ -1516,43 +1480,28 @@ export class GenomeService {
};
}
// 2. 모든 유전체 분석 의뢰 조회
// 2. 모든 유전체 분석 의뢰 조회 (traitDetails 포함)
const allRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() },
relations: ['cow', 'farm'],
relations: ['cow', 'farm', 'traitDetails'],
});
// 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. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
// 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');
// 5. 각 개체별 점수 계산 (메모리에서 처리 - DB 쿼리 없음)
// 4. 각 개체별 점수 계산
const allScores: { cowId: string; score: number; farmNo: number | null }[] = [];
for (const request of allRequests) {
if (!request.cow?.cowId) continue;
if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue;
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회)
const traitDetails = traitDetailsByCowId.get(request.cow.cowId);
// relations로 조회된 traitDetails 사용
const traitDetails = request.traitDetails;
if (!traitDetails || traitDetails.length === 0) continue;
let weightedSum = 0;
@@ -1657,9 +1606,41 @@ export class GenomeService {
}
/**
* 연도별 유전능력 추이 (형질별/카테고리별)
* 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회
* 연도별 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 - 카테고리명 (성장/생산/체형/무게/비율)
@@ -1695,8 +1676,7 @@ export class GenomeService {
// 대상 형질 결정
const targetTraits = traitName ? [traitName] : traitsInCategory;
// 단일 쿼리로 모든 데이터 조회 (N+1 문제 해결)
// genome_request + cow + genome_trait_detail을 한번에 조인
// JOIN으로 한 번에 조회 (genome_request + cow + genome_trait_detail)
const allData = await this.genomeRequestRepository
.createQueryBuilder('r')
.innerJoin('r.cow', 'c')

View File

@@ -0,0 +1,20 @@
/**
* MPT 통계 응답 DTO
*/
export interface MptStatisticsDto {
totalMptCows: number;
latestTestDate: Date | null;
categories: {
energy: { safe: number; caution: number };
protein: { safe: number; caution: number };
liver: { safe: number; caution: number };
mineral: { safe: number; caution: number };
};
riskyCows: Array<{
cowId: string;
category: string;
itemName: string;
value: number;
status: 'high' | 'low';
}>;
}

View File

@@ -3,6 +3,7 @@ import { FarmModel } from 'src/farm/entities/farm.entity';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -20,6 +21,7 @@ export class MptModel extends BaseModel {
})
pkMptNo: number;
@Index('idx_mpt_cow_id')
@Column({
name: 'cow_id',
type: 'varchar',
@@ -38,6 +40,7 @@ export class MptModel extends BaseModel {
})
cowShortNo: string;
@Index('idx_mpt_fk_farm_no')
@Column({
name: 'fk_farm_no',
type: 'int',

View File

@@ -8,27 +8,7 @@ import {
MptReferenceRange,
MptCategory,
} from '../common/const/MptReference';
/**
* MPT 통계 응답 DTO
*/
export interface MptStatisticsDto {
totalMptCows: number;
latestTestDate: Date | null;
categories: {
energy: { safe: number; caution: number };
protein: { safe: number; caution: number };
liver: { safe: number; caution: number };
mineral: { safe: number; caution: number };
};
riskyCows: Array<{
cowId: string;
category: string;
itemName: string;
value: number;
status: 'high' | 'low';
}>;
}
import { MptStatisticsDto } from './dto/mpt-statistics.dto';
@Injectable()
export class MptService {

View File

@@ -0,0 +1,16 @@
/**
* 시스템 헬스체크 응답 DTO
*/
export interface SystemHealthResponse {
status: 'ok' | 'error';
timestamp: string;
environment: string;
database: {
host: string;
port: number;
database: string;
user: string;
status: 'connected' | 'disconnected';
error?: string;
};
}

View File

@@ -0,0 +1,69 @@
/**
* 검사 집계 DTO
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
// 개체별 검사 상세
export class CowTestDetailDto {
cowId: string; // 개체번호
cowBirthDt: string | null; // 생년월일
cowSex: string | null; // 성별
hasGenome: boolean; // 유전체 검사 여부
hasGene: boolean; // 유전자 검사 여부
hasMpt: boolean; // 번식능력 검사 여부
testCount: number; // 받은 검사 수 (1~3)
testTypes: string[]; // 검사 종류 목록
}
// 농가별 검사 집계
export class FarmTestSummaryDto {
farmNo: number;
farmerName: string | null;
regionSi: string | null;
// 검사별 개체수 (중복 허용)
genomeCowCount: number; // 유전체 검사 개체수
geneCowCount: number; // 유전자 검사 개체수
mptCowCount: number; // 번식능력 검사 개체수
// 중복 검사 조합별 개체수
genomeOnly: number; // 유전체만
geneOnly: number; // 유전자만
mptOnly: number; // 번식능력만
genomeAndGene: number; // 유전체 + 유전자
genomeAndMpt: number; // 유전체 + 번식능력
geneAndMpt: number; // 유전자 + 번식능력
allThree: number; // 유전체 + 유전자 + 번식능력
// 합계
totalCows: number; // 전체 개체수 (합집합, 중복 제외)
totalTests: number; // 총 검사 건수 (중복 포함)
// 개체별 상세 (선택적)
cows?: CowTestDetailDto[];
}
// 전체 검사 집계 (모든 농가 합산)
export class TestSummaryDto {
// 전체 집계
totalFarms: number; // 농가 수
totalCows: number; // 전체 개체수 (합집합)
totalTests: number; // 총 검사 건수 (중복 포함)
// 검사별 개체수 (중복 허용)
genomeCowCount: number;
geneCowCount: number;
mptCowCount: number;
// 중복 검사 조합별 개체수
genomeOnly: number;
geneOnly: number;
mptOnly: number;
genomeAndGene: number;
genomeAndMpt: number;
geneAndMpt: number;
allThree: number;
// 농가별 상세
farms: FarmTestSummaryDto[];
}

View File

@@ -1,5 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { SystemService, SystemHealthResponse } from './system.service';
import { SystemService } from './system.service';
import { SystemHealthResponse } from './dto/system-health.dto';
import { TestSummaryDto } from './dto/test-summary.dto';
import { Public } from '../common/decorators/public.decorator';
@Controller('system')
@@ -11,4 +13,14 @@ export class SystemController {
async getHealth(): Promise<SystemHealthResponse> {
return this.systemService.getHealth();
}
/**
* 전체 검사 집계 조회
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
@Public()
@Get('test-summary')
async getTestSummary(): Promise<TestSummaryDto> {
return this.systemService.getTestSummary();
}
}

View File

@@ -1,8 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SystemController } from './system.controller';
import { SystemService } from './system.service';
import { CowModel } from '../cow/entities/cow.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
CowModel,
FarmModel,
GenomeTraitDetailModel,
GeneDetailModel,
MptModel,
]),
],
controllers: [SystemController],
providers: [SystemService],
})

View File

@@ -1,27 +1,30 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
export interface SystemHealthResponse {
status: 'ok' | 'error';
timestamp: string;
environment: string;
database: {
host: string;
port: number;
database: string;
user: string;
status: 'connected' | 'disconnected';
error?: string;
};
}
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Repository } from 'typeorm';
import { SystemHealthResponse } from './dto/system-health.dto';
import { TestSummaryDto, FarmTestSummaryDto, CowTestDetailDto } from './dto/test-summary.dto';
import { CowModel } from '../cow/entities/cow.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
@Injectable()
export class SystemService {
constructor(
private configService: ConfigService,
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
@InjectRepository(FarmModel)
private readonly farmRepository: Repository<FarmModel>,
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@InjectRepository(MptModel)
private readonly mptRepository: Repository<MptModel>,
) {}
async getHealth(): Promise<SystemHealthResponse> {
@@ -50,4 +53,233 @@ export class SystemService {
return { ...config, status: 'disconnected' as const, error: error.message };
}
}
/**
* 전체 검사 집계 조회
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
async getTestSummary(): Promise<TestSummaryDto> {
// 1. 모든 농가 조회
const farms = await this.farmRepository.find({
where: { delDt: IsNull() },
order: { farmerName: 'ASC' },
});
// 2. 각 검사별 cowId 조회 (전체)
const [genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
// 유전체 검사 개체 (형질 데이터 보유)
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 genomeSet = new Set(genomeCowIds);
const geneSet = new Set(geneCowIds);
const mptSet = new Set(mptCowIds);
// 3. 모든 개체 정보 조회 (cowId로 농가 매핑)
const allCows = await this.cowRepository.find({
where: { delDt: IsNull() },
select: ['cowId', 'cowBirthDt', 'cowSex', 'fkFarmNo'],
});
const cowFarmMap = new Map<string, number>();
const cowInfoMap = new Map<string, { cowBirthDt: Date | null; cowSex: string | null }>();
for (const cow of allCows) {
if (cow.cowId && cow.fkFarmNo) {
cowFarmMap.set(cow.cowId, cow.fkFarmNo);
cowInfoMap.set(cow.cowId, { cowBirthDt: cow.cowBirthDt, cowSex: cow.cowSex });
}
}
// 4. 농가별 집계
const farmSummaries: FarmTestSummaryDto[] = [];
for (const farm of farms) {
const farmNo = farm.pkFarmNo;
const farmCowIds = new Set<string>();
// 해당 농가의 개체 필터링
for (const [cowId, fNo] of cowFarmMap.entries()) {
if (fNo === farmNo) {
// 검사 받은 개체만 추가
if (genomeSet.has(cowId) || geneSet.has(cowId) || mptSet.has(cowId)) {
farmCowIds.add(cowId);
}
}
}
// 개체별 검사 상세
const cows: CowTestDetailDto[] = [];
let genomeCowCount = 0;
let geneCowCount = 0;
let mptCowCount = 0;
let genomeOnly = 0;
let geneOnly = 0;
let mptOnly = 0;
let genomeAndGene = 0;
let genomeAndMpt = 0;
let geneAndMpt = 0;
let allThree = 0;
let totalTests = 0;
for (const cowId of farmCowIds) {
const hasGenome = genomeSet.has(cowId);
const hasGene = geneSet.has(cowId);
const hasMpt = mptSet.has(cowId);
const cowInfo = cowInfoMap.get(cowId);
const testTypes: string[] = [];
if (hasGenome) testTypes.push('유전체');
if (hasGene) testTypes.push('유전자');
if (hasMpt) testTypes.push('번식능력');
// cowBirthDt 포맷 처리 (Date 객체 또는 문자열)
let birthDtStr: string | null = null;
if (cowInfo?.cowBirthDt) {
if (cowInfo.cowBirthDt instanceof Date) {
birthDtStr = cowInfo.cowBirthDt.toISOString().split('T')[0];
} else {
birthDtStr = String(cowInfo.cowBirthDt).split('T')[0];
}
}
cows.push({
cowId,
cowBirthDt: birthDtStr,
cowSex: cowInfo?.cowSex || null,
hasGenome,
hasGene,
hasMpt,
testCount: testTypes.length,
testTypes,
});
// 검사별 카운트
if (hasGenome) genomeCowCount++;
if (hasGene) geneCowCount++;
if (hasMpt) mptCowCount++;
totalTests += testTypes.length;
// 중복 검사 조합별 카운트
if (hasGenome && hasGene && hasMpt) {
allThree++;
} else if (hasGenome && hasGene && !hasMpt) {
genomeAndGene++;
} else if (hasGenome && !hasGene && hasMpt) {
genomeAndMpt++;
} else if (!hasGenome && hasGene && hasMpt) {
geneAndMpt++;
} else if (hasGenome && !hasGene && !hasMpt) {
genomeOnly++;
} else if (!hasGenome && hasGene && !hasMpt) {
geneOnly++;
} else if (!hasGenome && !hasGene && hasMpt) {
mptOnly++;
}
}
// testCount 내림차순, cowId 오름차순 정렬
cows.sort((a, b) => {
if (b.testCount !== a.testCount) return b.testCount - a.testCount;
return a.cowId.localeCompare(b.cowId);
});
farmSummaries.push({
farmNo,
farmerName: farm.farmerName || null,
regionSi: farm.regionSi || null,
genomeCowCount,
geneCowCount,
mptCowCount,
genomeOnly,
geneOnly,
mptOnly,
genomeAndGene,
genomeAndMpt,
geneAndMpt,
allThree,
totalCows: farmCowIds.size,
totalTests,
cows,
});
}
// 검사 개체가 있는 농가만 필터링
const activeFarms = farmSummaries.filter(f => f.totalCows > 0);
// 5. 전체 집계 계산
const allTestedCowIds = new Set([...genomeCowIds, ...geneCowIds, ...mptCowIds]);
let totalGenomeOnly = 0;
let totalGeneOnly = 0;
let totalMptOnly = 0;
let totalGenomeAndGene = 0;
let totalGenomeAndMpt = 0;
let totalGeneAndMpt = 0;
let totalAllThree = 0;
let totalTestsSum = 0;
for (const cowId of allTestedCowIds) {
const hasGenome = genomeSet.has(cowId);
const hasGene = geneSet.has(cowId);
const hasMpt = mptSet.has(cowId);
let testCount = 0;
if (hasGenome) testCount++;
if (hasGene) testCount++;
if (hasMpt) testCount++;
totalTestsSum += testCount;
if (hasGenome && hasGene && hasMpt) {
totalAllThree++;
} else if (hasGenome && hasGene && !hasMpt) {
totalGenomeAndGene++;
} else if (hasGenome && !hasGene && hasMpt) {
totalGenomeAndMpt++;
} else if (!hasGenome && hasGene && hasMpt) {
totalGeneAndMpt++;
} else if (hasGenome && !hasGene && !hasMpt) {
totalGenomeOnly++;
} else if (!hasGenome && hasGene && !hasMpt) {
totalGeneOnly++;
} else if (!hasGenome && !hasGene && hasMpt) {
totalMptOnly++;
}
}
return {
totalFarms: activeFarms.length,
totalCows: allTestedCowIds.size,
totalTests: totalTestsSum,
genomeCowCount: genomeCowIds.length,
geneCowCount: geneCowIds.length,
mptCowCount: mptCowIds.length,
genomeOnly: totalGenomeOnly,
geneOnly: totalGeneOnly,
mptOnly: totalMptOnly,
genomeAndGene: totalGenomeAndGene,
genomeAndMpt: totalGenomeAndMpt,
geneAndMpt: totalGeneAndMpt,
allThree: totalAllThree,
farms: activeFarms,
};
}
}