From 2877a474ebcb5198b57f477f1cfb929c563b901d Mon Sep 17 00:00:00 2001 From: chu eun ju Date: Wed, 24 Dec 2025 22:50:13 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/common/const/MptReference.ts | 10 +- backend/src/cow/cow.service.ts | 62 ++- .../src/genome/dto/comparison-averages.dto.ts | 25 + backend/src/genome/dto/dashboard.dto.ts | 102 ++++ .../src/genome/dto/trait-comparison.dto.ts | 19 + .../genome/entities/genome-request.entity.ts | 5 + backend/src/genome/genome.controller.ts | 23 +- backend/src/genome/genome.service.ts | 192 ++++---- backend/src/mpt/dto/mpt-statistics.dto.ts | 20 + backend/src/mpt/entities/mpt.entity.ts | 3 + backend/src/mpt/mpt.service.ts | 22 +- backend/src/system/dto/system-health.dto.ts | 16 + backend/src/system/dto/test-summary.dto.ts | 69 +++ backend/src/system/system.controller.ts | 14 +- backend/src/system/system.module.ts | 15 + backend/src/system/system.service.ts | 264 ++++++++++- .../genome-integrated-comparison.tsx | 6 +- .../reproduction/_components/mpt-table.tsx | 66 ++- frontend/src/app/cow/page.tsx | 224 ++------- frontend/src/app/demo/test-summary/page.tsx | 448 ++++++++++++++++++ .../genome/gene-possession-status.tsx | 206 -------- frontend/src/lib/api/genome.api.ts | 109 ++--- 22 files changed, 1274 insertions(+), 646 deletions(-) create mode 100644 backend/src/genome/dto/comparison-averages.dto.ts create mode 100644 backend/src/genome/dto/dashboard.dto.ts create mode 100644 backend/src/genome/dto/trait-comparison.dto.ts create mode 100644 backend/src/mpt/dto/mpt-statistics.dto.ts create mode 100644 backend/src/system/dto/system-health.dto.ts create mode 100644 backend/src/system/dto/test-summary.dto.ts create mode 100644 frontend/src/app/demo/test-summary/page.tsx delete mode 100644 frontend/src/components/genome/gene-possession-status.tsx diff --git a/backend/src/common/const/MptReference.ts b/backend/src/common/const/MptReference.ts index 70b5879..f6f08aa 100644 --- a/backend/src/common/const/MptReference.ts +++ b/backend/src/common/const/MptReference.ts @@ -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'], }, ]; diff --git a/backend/src/cow/cow.service.ts b/backend/src/cow/cow.service.ts index 5ad21fe..0b01e6e 100644 --- a/backend/src/cow/cow.service.ts +++ b/backend/src/cow/cow.service.ts @@ -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 }; } // ============================================================ diff --git a/backend/src/genome/dto/comparison-averages.dto.ts b/backend/src/genome/dto/comparison-averages.dto.ts new file mode 100644 index 0000000..b33c8d7 --- /dev/null +++ b/backend/src/genome/dto/comparison-averages.dto.ts @@ -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[]; +} diff --git a/backend/src/genome/dto/dashboard.dto.ts b/backend/src/genome/dto/dashboard.dto.ts new file mode 100644 index 0000000..0af9356 --- /dev/null +++ b/backend/src/genome/dto/dashboard.dto.ts @@ -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 }[]; +} diff --git a/backend/src/genome/dto/trait-comparison.dto.ts b/backend/src/genome/dto/trait-comparison.dto.ts new file mode 100644 index 0000000..2b8076c --- /dev/null +++ b/backend/src/genome/dto/trait-comparison.dto.ts @@ -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[]; // 농장 평균 +} diff --git a/backend/src/genome/entities/genome-request.entity.ts b/backend/src/genome/entities/genome-request.entity.ts index 1f1911b..c4a0663 100644 --- a/backend/src/genome/entities/genome-request.entity.ts +++ b/backend/src/genome/entities/genome-request.entity.ts @@ -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[]; } diff --git a/backend/src/genome/genome.controller.ts b/backend/src/genome/genome.controller.ts index 8e22632..29c3bd4 100644 --- a/backend/src/genome/genome.controller.ts +++ b/backend/src/genome/genome.controller.ts @@ -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 * 연도별 유전능력 추이 (형질별/카테고리별) diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index 43badce..c2a69fa 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -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(); - 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(); @@ -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(); @@ -251,8 +199,8 @@ export class GenomeService { const yearlyTraitMap = new Map>(); 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>(); - // 보은군 내 모든 분석 완료된 요청 조회 + // 보은군 내 모든 분석 완료된 요청 조회 (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(); 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>(); 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(); - 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') diff --git a/backend/src/mpt/dto/mpt-statistics.dto.ts b/backend/src/mpt/dto/mpt-statistics.dto.ts new file mode 100644 index 0000000..101721e --- /dev/null +++ b/backend/src/mpt/dto/mpt-statistics.dto.ts @@ -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'; + }>; +} diff --git a/backend/src/mpt/entities/mpt.entity.ts b/backend/src/mpt/entities/mpt.entity.ts index 2f3218c..2f24cab 100644 --- a/backend/src/mpt/entities/mpt.entity.ts +++ b/backend/src/mpt/entities/mpt.entity.ts @@ -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', diff --git a/backend/src/mpt/mpt.service.ts b/backend/src/mpt/mpt.service.ts index 33f52ea..431e35e 100644 --- a/backend/src/mpt/mpt.service.ts +++ b/backend/src/mpt/mpt.service.ts @@ -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 { diff --git a/backend/src/system/dto/system-health.dto.ts b/backend/src/system/dto/system-health.dto.ts new file mode 100644 index 0000000..75b3cd8 --- /dev/null +++ b/backend/src/system/dto/system-health.dto.ts @@ -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; + }; +} diff --git a/backend/src/system/dto/test-summary.dto.ts b/backend/src/system/dto/test-summary.dto.ts new file mode 100644 index 0000000..5701771 --- /dev/null +++ b/backend/src/system/dto/test-summary.dto.ts @@ -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[]; +} diff --git a/backend/src/system/system.controller.ts b/backend/src/system/system.controller.ts index acf360c..ab7c82f 100644 --- a/backend/src/system/system.controller.ts +++ b/backend/src/system/system.controller.ts @@ -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 { return this.systemService.getHealth(); } + + /** + * 전체 검사 집계 조회 + * 농가별/개체별 유전체, 유전자, 번식능력 검사 현황 + */ + @Public() + @Get('test-summary') + async getTestSummary(): Promise { + return this.systemService.getTestSummary(); + } } diff --git a/backend/src/system/system.module.ts b/backend/src/system/system.module.ts index e10e1ca..9ba149f 100644 --- a/backend/src/system/system.module.ts +++ b/backend/src/system/system.module.ts @@ -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], }) diff --git a/backend/src/system/system.service.ts b/backend/src/system/system.service.ts index b4c4e51..2add7ef 100644 --- a/backend/src/system/system.service.ts +++ b/backend/src/system/system.service.ts @@ -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, + @InjectRepository(FarmModel) + private readonly farmRepository: Repository, + @InjectRepository(GenomeTraitDetailModel) + private readonly genomeTraitDetailRepository: Repository, + @InjectRepository(GeneDetailModel) + private readonly geneDetailRepository: Repository, + @InjectRepository(MptModel) + private readonly mptRepository: Repository, ) {} async getHealth(): Promise { @@ -50,4 +53,233 @@ export class SystemService { return { ...config, status: 'disconnected' as const, error: error.message }; } } + + /** + * 전체 검사 집계 조회 + * 농가별/개체별 유전체, 유전자, 번식능력 검사 현황 + */ + async getTestSummary(): Promise { + // 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(); + const cowInfoMap = new Map(); + 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(); + + // 해당 농가의 개체 필터링 + 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, + }; + } } diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/genome-integrated-comparison.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/genome-integrated-comparison.tsx index d72bf5a..f5cd854 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/genome-integrated-comparison.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/genome-integrated-comparison.tsx @@ -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() diff --git a/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx b/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx index 4dfcb72..470c855 100644 --- a/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx +++ b/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx @@ -181,7 +181,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
- {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))}개월` + : '-'}
@@ -217,7 +219,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
월령 - {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))}개월` + : '-'}
@@ -242,7 +246,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT {selectedMpt ? ( <>

혈액화학검사 결과

- + + {/* 데스크탑: 테이블 */} +
@@ -310,6 +316,60 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT + {/* 모바일: 카드 레이아웃 */} +
+ {categories.map((category) => ( + +
+ {category.name} +
+ + {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 ( +
+
+ 검사항목 + {ref?.name || itemKey} +
+
+ 측정값 +
+ + {value !== null && value !== undefined ? value.toFixed(2) : '-'} + + {value !== null && value !== undefined ? ( + + {status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'} + + ) : null} +
+
+
+ 참조범위 + + {ref?.lowerLimit ?? '-'} ~ {ref?.upperLimit ?? '-'} {ref?.unit || ''} + +
+
+ ) + })} +
+
+ ))} +
+ {/* 검사 이력 (여러 검사 결과가 있을 경우) */} {mptData.length > 1 && ( <> diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index 60f1557..b0b40b4 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -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, 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() { 선발지수 {selectedDisplayGenes.length > 0 && ( - + )} {selectedDisplayTraits.length > 0 && ( - + )} @@ -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() { {selectedDisplayGenes.length > 0 && (
유전자형유전자형형질형질
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 (
{geneName} {gene ? ( - + {genotype} ) : ( @@ -1134,7 +976,7 @@ function MyCowContent() { )} {selectedDisplayTraits.length > 0 && (
e.stopPropagation()} >
@@ -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 ( - + {geneName} {genotype} ) diff --git a/frontend/src/app/demo/test-summary/page.tsx b/frontend/src/app/demo/test-summary/page.tsx new file mode 100644 index 0000000..7d28806 --- /dev/null +++ b/frontend/src/app/demo/test-summary/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [expandedFarms, setExpandedFarms] = useState>(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 ( + + + + +
+
+
+ + + ) + } + + if (!data) { + return ( + + + + +
+ 데이터를 불러올 수 없습니다 +
+
+
+ ) + } + + return ( + + + + +
+ {/* 헤더 */} +
+

검사 집계표

+

+ 농가별/개체별 유전체, 유전자, 번식능력 검사 현황 +

+
+ + {/* 전체 요약 카드 */} +
+
+

총 농가 수

+

{data.totalFarms}

+
+
+

총 검사 개체 수

+

{data.totalCows}

+
+
+

총 검사 건수

+

{data.totalTests}

+
+
+

평균 검사/개체

+

+ {data.totalCows > 0 ? (data.totalTests / data.totalCows).toFixed(1) : 0} +

+
+
+ + {/* 검사별 집계 */} +
+
+

검사별 개체 수

+
+
+
+
+ +
+

유전체

+

{data.genomeCowCount}

+
+
+
+ +
+

유전자

+

{data.geneCowCount}

+
+
+
+ +
+

번식능력

+

{data.mptCowCount}

+
+
+
+ + {/* 중복 검사 조합 */} +

검사 조합별 개체 수

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
조합유전체유전자번식능력개체 수
유전체만{data.genomeOnly}
유전자만{data.geneOnly}
번식능력만{data.mptOnly}
유전체 + 유전자{data.genomeAndGene}
유전체 + 번식능력{data.genomeAndMpt}
유전자 + 번식능력{data.geneAndMpt}
3종 모두{data.allThree}
합계 (총 검사 개체){data.totalCows}
+
+
+
+ + {/* 농가별 집계 */} +
+
+

농가별 검사 현황

+
+
+ + + + + + + + + + + + + + {data.farms.map((farm) => ( + <> + toggleFarm(farm.farmNo)} + > + + + + + + + + + {/* 펼쳐진 개체 목록 */} + {expandedFarms.has(farm.farmNo) && farm.cows && farm.cows.length > 0 && ( + + + + )} + + ))} + + + + + + + + + + + + +
농가유전체유전자번식능력개체 수검사 건수
+ {expandedFarms.has(farm.farmNo) ? ( + + ) : ( + + )} + + {farm.farmerName || `농가 ${farm.farmNo}`} + {farm.regionSi && ( + {farm.regionSi} + )} + + + {farm.genomeCowCount} + + + + {farm.geneCowCount} + + + + {farm.mptCowCount} + + + {farm.totalCows} + + {farm.totalTests} +
+
+ + + + + + + + + + + + + + {farm.cows.map((cow) => ( + + + + + + + + + + ))} + +
개체번호생년월일성별유전체유전자번식능력검사 수
+ {formatCowId(cow.cowId)} + + {cow.cowBirthDt || '-'} + + + {cow.cowSex === '암' || cow.cowSex === 'F' ? '암' : '수'} + + + {cow.hasGenome ? ( + O + ) : ( + X + )} + + {cow.hasGene ? ( + O + ) : ( + X + )} + + {cow.hasMpt ? ( + O + ) : ( + X + )} + + + {cow.testCount} + +
+
+ {/* 농가별 중복 검사 요약 */} +
+
+ {farm.genomeOnly > 0 && ( + 유전체만: {farm.genomeOnly} + )} + {farm.geneOnly > 0 && ( + 유전자만: {farm.geneOnly} + )} + {farm.mptOnly > 0 && ( + 번식능력만: {farm.mptOnly} + )} + {farm.genomeAndGene > 0 && ( + 유전체+유전자: {farm.genomeAndGene} + )} + {farm.genomeAndMpt > 0 && ( + 유전체+번식능력: {farm.genomeAndMpt} + )} + {farm.geneAndMpt > 0 && ( + 유전자+번식능력: {farm.geneAndMpt} + )} + {farm.allThree > 0 && ( + 3종 모두: {farm.allThree} + )} +
+
+
합계{data.genomeCowCount}{data.geneCowCount}{data.mptCowCount}{data.totalCows}{data.totalTests}
+
+
+
+
+
+ ) +} diff --git a/frontend/src/components/genome/gene-possession-status.tsx b/frontend/src/components/genome/gene-possession-status.tsx deleted file mode 100644 index 93f4519..0000000 --- a/frontend/src/components/genome/gene-possession-status.tsx +++ /dev/null @@ -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([]) - 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 ( -
-

데이터 로딩 중...

-
- ) - } - - if (!farmNo) { - return ( -
-
-

농장 정보가 없습니다

-

로그인 후 다시 시도해주세요

-
-
- ) - } - - // 필터에 따라 표시할 유전자 선택 - 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 ( -
- {/* 필터 배지 표시 */} - {hasFilter && ( -
-
- - 타겟 유전자: -
- {selectedGenes.map(gene => ( - - {gene} - - ))} -
- )} - - {/* 유전자별 바 차트 */} -
- {displayGenes.map((gene, index) => ( -
- {/* 유전자명 + 타입 배지 */} -
-
- - {gene.geneName} - - - {gene.geneType} - -
- - {gene.farmRate}% - -
- - {/* 프로그레스 바 */} -
- {/* 우리 농장 */} -
- {/* 지역 평균 표시 (점선) */} -
-
- - {/* 지역 평균 레이블 */} -
- - 지역 평균: {gene.regionAvgRate}% - -
-
- ))} -
- - {/* 더보기/접기 버튼 */} - {hasMore && ( -
- -
- )} -
- ) -} diff --git a/frontend/src/lib/api/genome.api.ts b/frontend/src/lib/api/genome.api.ts index 53a1e51..805057b 100644 --- a/frontend/src/lib/api/genome.api.ts +++ b/frontend/src/lib/api/genome.api.ts @@ -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 => { + 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; - }[]; }