파일 정리
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
25
backend/src/genome/dto/comparison-averages.dto.ts
Normal file
25
backend/src/genome/dto/comparison-averages.dto.ts
Normal 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[];
|
||||
}
|
||||
102
backend/src/genome/dto/dashboard.dto.ts
Normal file
102
backend/src/genome/dto/dashboard.dto.ts
Normal 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 }[];
|
||||
}
|
||||
19
backend/src/genome/dto/trait-comparison.dto.ts
Normal file
19
backend/src/genome/dto/trait-comparison.dto.ts
Normal 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[]; // 농장 평균
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||
|
||||
@@ -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')
|
||||
|
||||
20
backend/src/mpt/dto/mpt-statistics.dto.ts
Normal file
20
backend/src/mpt/dto/mpt-statistics.dto.ts
Normal 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';
|
||||
}>;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
16
backend/src/system/dto/system-health.dto.ts
Normal file
16
backend/src/system/dto/system-health.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
69
backend/src/system/dto/test-summary.dto.ts
Normal file
69
backend/src/system/dto/test-summary.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,11 +463,11 @@ export function GenomeIntegratedComparison({
|
||||
|
||||
setTrendLoading(true)
|
||||
try {
|
||||
const dashboardStats = await genomeApi.getDashboardStats(farmNo)
|
||||
const ebvStats = await genomeApi.getYearlyEbvStats(farmNo)
|
||||
|
||||
// yearlyStats와 yearlyAvgEbv 합치기
|
||||
const yearlyStats = dashboardStats.yearlyStats || []
|
||||
const yearlyAvgEbv = dashboardStats.yearlyAvgEbv || []
|
||||
const yearlyStats = ebvStats.yearlyStats || []
|
||||
const yearlyAvgEbv = ebvStats.yearlyAvgEbv || []
|
||||
|
||||
// 연도별 데이터 맵 생성
|
||||
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()
|
||||
|
||||
@@ -181,7 +181,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
{cow?.cowBirthDt && selectedMpt.testDt
|
||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +219,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">월령</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
{cow?.cowBirthDt && selectedMpt.testDt
|
||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@@ -242,7 +246,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{selectedMpt ? (
|
||||
<>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈액화학검사 결과</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
|
||||
{/* 데스크탑: 테이블 */}
|
||||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -310,6 +316,60 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모바일: 카드 레이아웃 */}
|
||||
<div className="lg:hidden space-y-4">
|
||||
{categories.map((category) => (
|
||||
<Card key={category.key} className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">{category.name}</span>
|
||||
</div>
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
{category.items.map((itemKey) => {
|
||||
const ref = references[itemKey]
|
||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||
const status = getMptValueStatus(itemKey, value, references)
|
||||
|
||||
return (
|
||||
<div key={itemKey} className="py-2">
|
||||
<div className="flex items-center border-b border-border/50">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">검사항목</span>
|
||||
<span className="flex-1 px-3 py-2 text-sm font-semibold text-foreground">{ref?.name || itemKey}</span>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border/50">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">측정값</span>
|
||||
<div className="flex-1 px-3 py-2 flex items-center justify-between">
|
||||
<span className={`text-base font-bold ${
|
||||
status === 'safe' ? 'text-green-600' :
|
||||
status === 'caution' ? 'text-amber-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||
</span>
|
||||
{value !== null && value !== undefined ? (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
status === 'safe' ? 'bg-green-100 text-green-700' :
|
||||
status === 'caution' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">참조범위</span>
|
||||
<span className="flex-1 px-3 py-2 text-sm text-muted-foreground">
|
||||
{ref?.lowerLimit ?? '-'} ~ {ref?.upperLimit ?? '-'} {ref?.unit || ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||
{mptData.length > 1 && (
|
||||
<>
|
||||
|
||||
@@ -182,9 +182,6 @@ function MyCowContent() {
|
||||
|
||||
setError(null)
|
||||
|
||||
// 마커 타입 정보 (gene.api 제거됨 - 추후 백엔드 구현 시 복구)
|
||||
const currentMarkerTypes = markerTypes
|
||||
|
||||
// 전역 필터 + 랭킹 모드를 기반으로 랭킹 옵션 구성
|
||||
// 타입을 any로 지정하여 백엔드 API와의 호환성 유지
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -279,94 +276,24 @@ function MyCowContent() {
|
||||
ranking?: { traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] }; // 랭킹 형질
|
||||
}
|
||||
const cowsWithMockGenes = response.items.map((item: RankingItem) => {
|
||||
// 백엔드에서 genes 객체를 배열로 변환
|
||||
// genes 객체 형식: { "PLAG1": 2, "NCAPG": 1, ... }
|
||||
// 배열 형식으로 변환: [{ name: "PLAG1", genotype: "AA", favorable: true }, ...]
|
||||
let genesArray = []
|
||||
|
||||
if (item.entity.genes && typeof item.entity.genes === 'object') {
|
||||
// 백엔드 genes 객체를 배열로 변환
|
||||
genesArray = Object.entries(item.entity.genes).map(([markerName, count]) => {
|
||||
const favorableCount = count as number
|
||||
let genotype = 'N/A'
|
||||
let favorable = false
|
||||
|
||||
// favorableCount에 따라 유전자형 결정
|
||||
if (favorableCount === 2) {
|
||||
genotype = 'AA' // 동형 접합 (유리)
|
||||
favorable = true
|
||||
} else if (favorableCount === 1) {
|
||||
genotype = 'AG' // 이형 접합 (중간)
|
||||
favorable = true
|
||||
} else {
|
||||
genotype = 'GG' // 동형 접합 (불리)
|
||||
favorable = false
|
||||
}
|
||||
|
||||
return {
|
||||
name: markerName,
|
||||
genotype,
|
||||
favorable,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 백엔드에서 genes 데이터가 없으면 mock 생성
|
||||
genesArray = generateMockGenes()
|
||||
}
|
||||
|
||||
// currentMarkerTypes를 사용하여 동적으로 육량형/육질형 개수 계산
|
||||
// 동형접합(AA)과 이형접합(AG)을 구분하여 계산
|
||||
const isHomozygous = (genotype: string) => genotype.length === 2 && genotype[0] === genotype[1]
|
||||
|
||||
const quantityHomoCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QTY' && g.favorable && isHomozygous(g.genotype)
|
||||
).length
|
||||
const quantityHeteroCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QTY' && g.favorable && !isHomozygous(g.genotype)
|
||||
).length
|
||||
const qualityHomoCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QLT' && g.favorable && isHomozygous(g.genotype)
|
||||
).length
|
||||
const qualityHeteroCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QLT' && g.favorable && !isHomozygous(g.genotype)
|
||||
).length
|
||||
|
||||
return {
|
||||
...item.entity, // 실제 cow 데이터
|
||||
rank: item.rank, // 백엔드에서 계산한 랭킹
|
||||
rankScore: item.sortValue, // 백엔드에서 계산한 점수
|
||||
grade: item.grade, // 백엔드에서 계산한 등급 (A~E)
|
||||
genes: genesArray,
|
||||
quantityGeneCount: quantityHomoCount + quantityHeteroCount,
|
||||
qualityGeneCount: qualityHomoCount + qualityHeteroCount,
|
||||
quantityHomoCount,
|
||||
quantityHeteroCount,
|
||||
qualityHomoCount,
|
||||
qualityHeteroCount,
|
||||
// 유전체 점수는 sortValue에서 가져옴 (백엔드 랭킹 엔진이 계산한 값)
|
||||
...item.entity,
|
||||
rank: item.rank,
|
||||
rankScore: item.sortValue,
|
||||
grade: item.grade,
|
||||
genomeScore: item.sortValue,
|
||||
geneScore: item.compositeScores?.geneScore,
|
||||
// 번식 정보 (백엔드에서 가져옴 - 암소만)
|
||||
// 번식 정보
|
||||
calvingCount: item.entity.calvingCount,
|
||||
bcs: item.entity.bcs,
|
||||
inseminationCount: item.entity.inseminationCount,
|
||||
// 근친도 (백엔드에서 계산된 근친계수 백분율)
|
||||
inbreedingPercent: item.entity.inbreedingPercent ?? 0,
|
||||
// 아비 KPN 번호 (genome trait에서 가져옴)
|
||||
sireKpn: item.entity.sireKpn ?? null,
|
||||
// 분석일자
|
||||
anlysDt: item.entity.anlysDt ?? null,
|
||||
// 분석불가 사유
|
||||
unavailableReason: item.entity.unavailableReason ?? null,
|
||||
// 번식능력검사(MPT) 여부
|
||||
hasMpt: item.entity.hasMpt ?? false,
|
||||
// MPT 검사일
|
||||
mptTestDt: item.entity.mptTestDt ?? null,
|
||||
// MPT 월령
|
||||
mptMonthAge: item.entity.mptMonthAge ?? null,
|
||||
//====================================================================================================================
|
||||
// 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑)
|
||||
// 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number }
|
||||
// 형질 데이터
|
||||
traits: item.ranking?.traits?.reduce((acc: Record<string,
|
||||
{ breedVal: number | null, traitVal: number | null }>, t: { traitName: string; traitEbv: number | null; traitVal: number | null }) => {
|
||||
acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal };
|
||||
@@ -389,98 +316,6 @@ function MyCowContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, rankingMode, isFilterSet])
|
||||
|
||||
// Mock 유전자 생성 함수 (실제로는 API에서 가져와야 함)
|
||||
const generateMockGenes = () => {
|
||||
// 모든 소가 다양한 유전자를 가지도록 더 많은 유전자 풀 생성
|
||||
const genePool = [
|
||||
// 육량형 유전자
|
||||
{ name: 'PLAG1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'NCAPG', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'LCORL', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'MSTN', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
|
||||
{ name: 'IGF1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'GH1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
|
||||
{ name: 'LAP3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
|
||||
{ name: 'ARRDC3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
// 육질형 유전자
|
||||
{ name: 'CAPN1', genotypes: ['CC', 'CG', 'GG'], favorable: ['CC', 'CG'] },
|
||||
{ name: 'CAST', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
|
||||
{ name: 'FASN', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
|
||||
{ name: 'SCD', genotypes: ['AA', 'AV', 'VV'], favorable: ['VV', 'AV'] },
|
||||
{ name: 'FABP4', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'SREBP1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
|
||||
{ name: 'DGAT1', genotypes: ['AA', 'AK', 'KK'], favorable: ['KK', 'AK'] },
|
||||
{ name: 'LEP', genotypes: ['CC', 'CT', 'TT'], favorable: ['TT', 'CT'] },
|
||||
]
|
||||
|
||||
// 모든 유전자를 포함 (랜덤 유전자형)
|
||||
return genePool.map(gene => {
|
||||
const genotype = gene.genotypes[Math.floor(Math.random() * gene.genotypes.length)]
|
||||
return {
|
||||
name: gene.name,
|
||||
genotype,
|
||||
favorable: gene.favorable.includes(genotype),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 유전자형 판단 및 스타일 정의
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 동형접합 여부 판단
|
||||
* AA, GG, CC, TT 등 → true
|
||||
* AG, CT, AK 등 → false
|
||||
*/
|
||||
const isHomozygous = (genotype: string): boolean => {
|
||||
return genotype.length === 2 && genotype[0] === genotype[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 뱃지 스타일 정의
|
||||
* @param genotype 유전자형 (AA, AG, GG 등)
|
||||
* @param favorable 우량 유전자 여부
|
||||
* @param geneCategory 유전자 카테고리 ('QTY': 육량형, 'QLT': 육질형)
|
||||
*/
|
||||
type GeneBadgeStyle = {
|
||||
className: string
|
||||
icon: 'star' | 'circle' | 'double-circle' | 'minus' | 'none'
|
||||
}
|
||||
|
||||
const getGeneBadgeStyle = (
|
||||
genotype: string,
|
||||
favorable: boolean,
|
||||
geneCategory: 'QTY' | 'QLT'
|
||||
): GeneBadgeStyle => {
|
||||
const isHomo = isHomozygous(genotype)
|
||||
|
||||
// 1. 동형접합 우량 (AA형) → 진한 색 (육량: 파랑, 육질: 주황)
|
||||
if (isHomo && favorable) {
|
||||
return {
|
||||
className: geneCategory === 'QTY'
|
||||
? 'bg-blue-600 text-white border-blue-700'
|
||||
: 'bg-orange-600 text-white border-orange-700',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이형접합 우량 (AG형) → 중간 색 (육량: 파랑, 육질: 주황)
|
||||
if (!isHomo && favorable) {
|
||||
return {
|
||||
className: geneCategory === 'QTY'
|
||||
? 'bg-blue-400 text-white border-blue-500'
|
||||
: 'bg-orange-400 text-white border-orange-500',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 불량형 (GG형) → 연한 회색
|
||||
return {
|
||||
className: 'bg-gray-300 text-gray-600 border-gray-400',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 컬럼 스타일은 globals.css의 CSS 변수로 관리됨
|
||||
@@ -962,10 +797,10 @@ function MyCowContent() {
|
||||
선발지수
|
||||
</th>
|
||||
{selectedDisplayGenes.length > 0 && (
|
||||
<th className="cow-table-header bg-blue-50" style={{ width: '140px' }}>유전자형</th>
|
||||
<th className="cow-table-header" style={{ width: '140px' }}>유전자형</th>
|
||||
)}
|
||||
{selectedDisplayTraits.length > 0 && (
|
||||
<th className="cow-table-header bg-teal-50" style={{ width: '140px' }}>형질</th>
|
||||
<th className="cow-table-header" style={{ width: '140px' }}>형질</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1022,15 +857,20 @@ function MyCowContent() {
|
||||
{(() => {
|
||||
// 번식능력만 있는 개체 판단
|
||||
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
|
||||
if (analysisFilter === 'mptOnly' || hasMptOnly) {
|
||||
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
|
||||
if (cow.cowBirthDt && cow.mptTestDt) {
|
||||
const birthDate = new Date(cow.cowBirthDt)
|
||||
const refDate = new Date(cow.mptTestDt)
|
||||
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
// 유전체 분석일 기준 월령
|
||||
if (cow.cowBirthDt && cow.anlysDt) {
|
||||
const birthDate = new Date(cow.cowBirthDt)
|
||||
const refDate = new Date(cow.anlysDt)
|
||||
const ageInMonths = Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
return `${ageInMonths}개월`
|
||||
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
})()}
|
||||
@@ -1079,7 +919,7 @@ function MyCowContent() {
|
||||
</td>
|
||||
{selectedDisplayGenes.length > 0 && (
|
||||
<td
|
||||
className="py-2 px-2 text-sm bg-blue-50/30"
|
||||
className="py-2 px-2 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(() => {
|
||||
@@ -1092,15 +932,17 @@ function MyCowContent() {
|
||||
{displayGenes.map((geneName) => {
|
||||
const gene = cow.genes?.find(g => g.name === geneName)
|
||||
const genotype = gene?.genotype || '-'
|
||||
const favorable = gene?.favorable || false
|
||||
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
|
||||
const badgeStyle = gene ? getGeneBadgeStyle(genotype, favorable, geneCategory) : null
|
||||
// 육량형: 파랑, 육질형: 주황
|
||||
const badgeClass = geneCategory === 'QTY'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
|
||||
return (
|
||||
<div key={geneName} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600 min-w-[60px]">{geneName}</span>
|
||||
{gene ? (
|
||||
<Badge className={`text-xs font-semibold ${badgeStyle?.className}`}>
|
||||
<Badge className={`text-xs font-semibold ${badgeClass}`}>
|
||||
{genotype}
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -1134,7 +976,7 @@ function MyCowContent() {
|
||||
)}
|
||||
{selectedDisplayTraits.length > 0 && (
|
||||
<td
|
||||
className="py-2 px-2 text-sm bg-teal-50/30"
|
||||
className="py-2 px-2 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
@@ -1264,10 +1106,14 @@ function MyCowContent() {
|
||||
{(() => {
|
||||
// 번식능력만 있는 개체 판단
|
||||
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
|
||||
if (analysisFilter === 'mptOnly' || hasMptOnly) {
|
||||
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
|
||||
if (cow.cowBirthDt && cow.mptTestDt) {
|
||||
return `${Math.floor((new Date(cow.mptTestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
// 유전체 분석일 기준 월령
|
||||
if (cow.cowBirthDt && cow.anlysDt) {
|
||||
return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
@@ -1344,12 +1190,14 @@ function MyCowContent() {
|
||||
{displayGenes.map((geneName) => {
|
||||
const gene = cow.genes?.find(g => g.name === geneName)
|
||||
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
|
||||
const genotype = gene?.genotype || 'GG'
|
||||
const favorable = gene?.favorable || false
|
||||
const badgeStyle = getGeneBadgeStyle(genotype, favorable, geneCategory)
|
||||
const genotype = gene?.genotype || '-'
|
||||
// 육량형: 파랑, 육질형: 주황
|
||||
const badgeClass = geneCategory === 'QTY'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
|
||||
return (
|
||||
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeStyle.className}`}>
|
||||
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeClass}`}>
|
||||
{geneName} {genotype}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
448
frontend/src/app/demo/test-summary/page.tsx
Normal file
448
frontend/src/app/demo/test-summary/page.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ChevronDown, ChevronRight, Check, X, Dna, TestTube, Baby } from "lucide-react"
|
||||
|
||||
// 타입 정의
|
||||
interface CowTestDetail {
|
||||
cowId: string
|
||||
cowBirthDt: string | null
|
||||
cowSex: string | null
|
||||
hasGenome: boolean
|
||||
hasGene: boolean
|
||||
hasMpt: boolean
|
||||
testCount: number
|
||||
testTypes: string[]
|
||||
}
|
||||
|
||||
interface FarmTestSummary {
|
||||
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?: CowTestDetail[]
|
||||
}
|
||||
|
||||
interface TestSummary {
|
||||
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: FarmTestSummary[]
|
||||
}
|
||||
|
||||
export default function TestSummaryPage() {
|
||||
const [data, setData] = useState<TestSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedFarms, setExpandedFarms] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/system/test-summary') as TestSummary
|
||||
setData(response)
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const toggleFarm = (farmNo: number) => {
|
||||
setExpandedFarms(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(farmNo)) {
|
||||
next.delete(farmNo)
|
||||
} else {
|
||||
next.add(farmNo)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const formatCowId = (cowId: string) => {
|
||||
const digits = cowId.replace(/\D/g, '')
|
||||
if (digits.length === 12) {
|
||||
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
|
||||
}
|
||||
return cowId
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 items-center justify-center text-red-500">
|
||||
데이터를 불러올 수 없습니다
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 bg-slate-50 min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">검사 집계표</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
농가별/개체별 유전체, 유전자, 번식능력 검사 현황
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전체 요약 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 농가 수</p>
|
||||
<p className="text-3xl font-bold text-slate-900">{data.totalFarms}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 검사 개체 수</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{data.totalCows}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 검사 건수</p>
|
||||
<p className="text-3xl font-bold text-emerald-600">{data.totalTests}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">평균 검사/개체</p>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
{data.totalCows > 0 ? (data.totalTests / data.totalCows).toFixed(1) : 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사별 집계 */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-900">검사별 개체 수</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
<Dna className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-600 font-medium">유전체</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{data.genomeCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-purple-50 rounded-lg">
|
||||
<TestTube className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm text-purple-600 font-medium">유전자</p>
|
||||
<p className="text-2xl font-bold text-purple-700">{data.geneCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-pink-50 rounded-lg">
|
||||
<Baby className="w-8 h-8 text-pink-600" />
|
||||
<div>
|
||||
<p className="text-sm text-pink-600 font-medium">번식능력</p>
|
||||
<p className="text-2xl font-bold text-pink-700">{data.mptCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중복 검사 조합 */}
|
||||
<h3 className="font-medium text-slate-700 mb-3">검사 조합별 개체 수</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-100">
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-600">조합</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">유전체</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">유전자</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">번식능력</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-slate-600">개체 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">유전체만</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">유전자만</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.geneOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">번식능력만</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.mptOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-blue-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전체 + 유전자</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndGene}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-blue-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전체 + 번식능력</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndMpt}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-purple-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전자 + 번식능력</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.geneAndMpt}</td>
|
||||
</tr>
|
||||
<tr className="bg-emerald-50">
|
||||
<td className="px-4 py-2 text-emerald-700 font-medium">3종 모두</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-bold text-emerald-700">{data.allThree}</td>
|
||||
</tr>
|
||||
<tr className="bg-slate-100 font-semibold">
|
||||
<td className="px-4 py-2 text-slate-900" colSpan={4}>합계 (총 검사 개체)</td>
|
||||
<td className="px-4 py-2 text-right text-slate-900">{data.totalCows}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 농가별 집계 */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-900">농가별 검사 현황</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 border-b">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 w-8"></th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600">농가</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">유전체</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">유전자</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">번식능력</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">개체 수</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">검사 건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.farms.map((farm) => (
|
||||
<>
|
||||
<tr
|
||||
key={farm.farmNo}
|
||||
className="border-b hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => toggleFarm(farm.farmNo)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{expandedFarms.has(farm.farmNo) ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-slate-900">{farm.farmerName || `농가 ${farm.farmNo}`}</span>
|
||||
{farm.regionSi && (
|
||||
<span className="text-slate-400 text-xs ml-2">{farm.regionSi}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
{farm.genomeCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
|
||||
{farm.geneCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-pink-100 text-pink-700 font-medium">
|
||||
{farm.mptCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-semibold text-slate-900">
|
||||
{farm.totalCows}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-semibold text-emerald-600">
|
||||
{farm.totalTests}
|
||||
</td>
|
||||
</tr>
|
||||
{/* 펼쳐진 개체 목록 */}
|
||||
{expandedFarms.has(farm.farmNo) && farm.cows && farm.cows.length > 0 && (
|
||||
<tr key={`${farm.farmNo}-detail`}>
|
||||
<td colSpan={7} className="bg-slate-50 px-4 py-2">
|
||||
<div className="ml-6 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-white">
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500">개체번호</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">생년월일</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">성별</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">유전체</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">유전자</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">번식능력</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">검사 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{farm.cows.map((cow) => (
|
||||
<tr key={cow.cowId} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono text-slate-700">
|
||||
{formatCowId(cow.cowId)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-slate-600">
|
||||
{cow.cowBirthDt || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
cow.cowSex === '암' || cow.cowSex === 'F'
|
||||
? 'bg-pink-100 text-pink-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{cow.cowSex === '암' || cow.cowSex === 'F' ? '암' : '수'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasGenome ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasGene ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasMpt ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-pink-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
|
||||
cow.testCount === 3 ? 'bg-emerald-100 text-emerald-700' :
|
||||
cow.testCount === 2 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{cow.testCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* 농가별 중복 검사 요약 */}
|
||||
<div className="ml-6 mt-3 p-3 bg-white rounded-lg border text-xs">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{farm.genomeOnly > 0 && (
|
||||
<span className="text-slate-600">유전체만: <span className="font-bold">{farm.genomeOnly}</span></span>
|
||||
)}
|
||||
{farm.geneOnly > 0 && (
|
||||
<span className="text-slate-600">유전자만: <span className="font-bold">{farm.geneOnly}</span></span>
|
||||
)}
|
||||
{farm.mptOnly > 0 && (
|
||||
<span className="text-slate-600">번식능력만: <span className="font-bold">{farm.mptOnly}</span></span>
|
||||
)}
|
||||
{farm.genomeAndGene > 0 && (
|
||||
<span className="text-blue-600">유전체+유전자: <span className="font-bold">{farm.genomeAndGene}</span></span>
|
||||
)}
|
||||
{farm.genomeAndMpt > 0 && (
|
||||
<span className="text-blue-600">유전체+번식능력: <span className="font-bold">{farm.genomeAndMpt}</span></span>
|
||||
)}
|
||||
{farm.geneAndMpt > 0 && (
|
||||
<span className="text-purple-600">유전자+번식능력: <span className="font-bold">{farm.geneAndMpt}</span></span>
|
||||
)}
|
||||
{farm.allThree > 0 && (
|
||||
<span className="text-emerald-600">3종 모두: <span className="font-bold">{farm.allThree}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-slate-200 font-semibold">
|
||||
<td className="px-4 py-3"></td>
|
||||
<td className="px-4 py-3 text-slate-900">합계</td>
|
||||
<td className="px-4 py-3 text-center text-blue-700">{data.genomeCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-purple-700">{data.geneCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-pink-700">{data.mptCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-slate-900">{data.totalCows}</td>
|
||||
<td className="px-4 py-3 text-center text-emerald-700">{data.totalTests}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
interface GeneData {
|
||||
geneName: string
|
||||
geneType: '육량' | '육질' // 유전자 분류
|
||||
farmRate: number // 우리 농장 우량형(AA) 보유율
|
||||
regionAvgRate: number // 지역 평균
|
||||
}
|
||||
|
||||
interface GenePossessionStatusProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const { filters } = useFilterStore()
|
||||
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// 선택된 유전자 확인
|
||||
const selectedGenes = filters.selectedGenes || []
|
||||
const hasFilter = selectedGenes.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
|
||||
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
|
||||
const mockAllGenes: GeneData[] = [
|
||||
// 육량 관련
|
||||
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
|
||||
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
|
||||
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
|
||||
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
|
||||
|
||||
// 육질 관련
|
||||
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
|
||||
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
|
||||
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
|
||||
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
|
||||
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
|
||||
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
|
||||
]
|
||||
|
||||
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
|
||||
if (selectedGenes.length > 0) {
|
||||
selectedGenes.forEach(geneName => {
|
||||
if (!mockAllGenes.find(g => g.geneName === geneName)) {
|
||||
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
|
||||
mockAllGenes.push({
|
||||
geneName: geneName,
|
||||
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
|
||||
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
|
||||
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setAllGenes(mockAllGenes)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [selectedYear, farmNo, selectedGenes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!farmNo) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2">농장 정보가 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">로그인 후 다시 시도해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 필터에 따라 표시할 유전자 선택
|
||||
const allDisplayGenes = hasFilter
|
||||
? allGenes.filter(g => selectedGenes.includes(g.geneName))
|
||||
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
|
||||
|
||||
// 접기/펼치기 적용 (4개 기준)
|
||||
// 단, 선택된 유전자가 있을 때는 모두 표시
|
||||
const DISPLAY_LIMIT = 4
|
||||
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
|
||||
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 배지 표시 */}
|
||||
{hasFilter && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">타겟 유전자:</span>
|
||||
</div>
|
||||
{selectedGenes.map(gene => (
|
||||
<Badge
|
||||
key={gene}
|
||||
variant="secondary"
|
||||
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
|
||||
>
|
||||
{gene}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 유전자별 바 차트 */}
|
||||
<div className="space-y-2.5">
|
||||
{displayGenes.map((gene, index) => (
|
||||
<div key={gene.geneName} className="space-y-1">
|
||||
{/* 유전자명 + 타입 배지 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
|
||||
{gene.geneName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0 ${
|
||||
gene.geneType === '육량'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
}`}
|
||||
>
|
||||
{gene.geneType}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{gene.farmRate}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 바 */}
|
||||
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* 우리 농장 */}
|
||||
<div
|
||||
className={`absolute h-full transition-all duration-800 ${
|
||||
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${gene.farmRate}%` }}
|
||||
/>
|
||||
{/* 지역 평균 표시 (점선) */}
|
||||
<div
|
||||
className="absolute h-full border-l-2 border-dashed border-gray-400"
|
||||
style={{ left: `${gene.regionAvgRate}%` }}
|
||||
title={`지역 평균: ${gene.regionAvgRate}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지역 평균 레이블 */}
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-gray-500">
|
||||
지역 평균: {gene.regionAvgRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 더보기/접기 버튼 */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
나머지 {allDisplayGenes.length - DISPLAY_LIMIT}개 더보기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -131,6 +131,13 @@ export const genomeApi = {
|
||||
return await apiClient.get(`/genome/dashboard-stats/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/yearly-ebv-stats/:farmNo - 연도별 EBV 통계 (개체상세용)
|
||||
*/
|
||||
getYearlyEbvStats: async (farmNo: number): Promise<YearlyEbvStatsDto> => {
|
||||
return await apiClient.get(`/genome/yearly-ebv-stats/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용)
|
||||
*/
|
||||
@@ -222,7 +229,7 @@ export interface FarmRegionRankingDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 통계 데이터 타입
|
||||
* 대시보드 통계 데이터 타입 (필수 4개만)
|
||||
*/
|
||||
export interface DashboardStatsDto {
|
||||
// 연도별 분석 현황
|
||||
@@ -231,91 +238,63 @@ export interface DashboardStatsDto {
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
sireMatchCount: number; // 친자 일치 수
|
||||
analyzeRate: number; // 분석 완료율 (%)
|
||||
sireMatchRate: number; // 친자 일치율 (%)
|
||||
sireMatchCount: number;
|
||||
analyzeRate: number;
|
||||
sireMatchRate: number;
|
||||
}[];
|
||||
// 형질별 농장 평균
|
||||
traitAverages: {
|
||||
traitName: string;
|
||||
category: string;
|
||||
avgEbv: number;
|
||||
avgEpd: number; // 농가 육종가(EPD) 평균
|
||||
regionAvgEpd: number; // 보은군 육종가(EPD) 평균
|
||||
avgEpd: number;
|
||||
regionAvgEpd?: number;
|
||||
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;
|
||||
rank: number | null;
|
||||
totalFarms: number;
|
||||
percentile: number | null;
|
||||
}[];
|
||||
// 요약
|
||||
summary: {
|
||||
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외)
|
||||
genomeCowCount: number; // 유전체 분석 개체 수
|
||||
geneCowCount: number; // 유전자검사 개체 수
|
||||
mptCowCount: number; // 번식능력검사 개체 수
|
||||
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성)
|
||||
totalCows: number;
|
||||
genomeCowCount: number;
|
||||
geneCowCount: number;
|
||||
mptCowCount: number;
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
mismatchCount: number;
|
||||
maleCount: number; // 수컷 수
|
||||
femaleCount: 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; // 대기
|
||||
analysisComplete: number;
|
||||
sireMismatch: number;
|
||||
damMismatch: number;
|
||||
damNoRecord: number;
|
||||
notAnalyzed: number;
|
||||
};
|
||||
// 월별 접수 현황
|
||||
monthlyStats: {
|
||||
month: number;
|
||||
count: number;
|
||||
}[];
|
||||
// 칩 종류별 분포
|
||||
chipTypeStats: {
|
||||
chipType: string;
|
||||
count: number;
|
||||
}[];
|
||||
// 모근량별 분포
|
||||
sampleAmountStats: {
|
||||
sampleAmount: string;
|
||||
count: number;
|
||||
}[];
|
||||
// 연도별 주요 형질 평균 (차트용)
|
||||
yearlyTraitAverages: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도별 EBV 통계 (개체상세용)
|
||||
*/
|
||||
export interface YearlyEbvStatsDto {
|
||||
yearlyStats: {
|
||||
year: number;
|
||||
traits: { traitName: string; avgEbv: number | null }[];
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
sireMatchCount: number;
|
||||
analyzeRate: number;
|
||||
sireMatchRate: number;
|
||||
}[];
|
||||
// 연도별 평균 표준화육종가 (농가 vs 보은군 비교)
|
||||
yearlyAvgEbv: {
|
||||
year: number;
|
||||
farmAvgEbv: number; // 농가 평균
|
||||
regionAvgEbv: number; // 보은군 평균
|
||||
farmAvgEbv: number;
|
||||
regionAvgEbv: number;
|
||||
traitCount: number;
|
||||
}[];
|
||||
// 우수 개체 TOP 5 (옵션)
|
||||
topAnimals?: {
|
||||
animalId?: string;
|
||||
identNo?: string;
|
||||
birthDt?: string;
|
||||
avgEbv?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user