From 1644fcf241b458e2b3efa5aa1c135d6b3ecda3fa Mon Sep 17 00:00:00 2001 From: chu eun ju Date: Mon, 22 Dec 2025 19:52:38 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=88=EC=8B=9D=20=EB=8A=A5=EB=A0=A5=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/cow/cow.module.ts | 2 + backend/src/cow/cow.service.ts | 188 ++++++++++--- backend/src/genome/genome.module.ts | 4 + backend/src/genome/genome.service.ts | 108 ++++++-- backend/src/mpt/mpt.controller.ts | 14 + backend/src/mpt/mpt.service.ts | 190 +++++++++++++ frontend/src/app/cow/[cowNo]/page.tsx | 101 ++++--- .../reproduction/_components/mpt-table.tsx | 250 ++++-------------- frontend/src/app/cow/page.tsx | 224 ++++++++++++---- frontend/src/app/dashboard/page.tsx | 172 +++++++++--- frontend/src/constants/mpt-reference.ts | 17 +- frontend/src/lib/api/genome.api.ts | 6 +- frontend/src/lib/api/mpt.api.ts | 41 ++- .../src/lib/utils/genome-analysis-config.ts | 1 + frontend/src/types/cow.types.ts | 5 + 15 files changed, 916 insertions(+), 407 deletions(-) diff --git a/backend/src/cow/cow.module.ts b/backend/src/cow/cow.module.ts index 46f31ef..3ac4d5c 100644 --- a/backend/src/cow/cow.module.ts +++ b/backend/src/cow/cow.module.ts @@ -20,6 +20,7 @@ import { CowModel } from './entities/cow.entity'; import { GenomeRequestModel } from '../genome/entities/genome-request.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'; import { FilterEngineModule } from '../shared/filter/filter-engine.module'; @Module({ @@ -29,6 +30,7 @@ import { FilterEngineModule } from '../shared/filter/filter-engine.module'; GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request) GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail) GeneDetailModel, // 유전자 상세 (tb_gene_detail) + MptModel, // 번식능력 (tb_mpt) ]), FilterEngineModule, // 필터 엔진 모듈 ], diff --git a/backend/src/cow/cow.service.ts b/backend/src/cow/cow.service.ts index 8fb6dd1..2b81427 100644 --- a/backend/src/cow/cow.service.ts +++ b/backend/src/cow/cow.service.ts @@ -20,13 +20,14 @@ import { CowModel } from './entities/cow.entity'; import { GenomeRequestModel } from '../genome/entities/genome-request.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'; import { FilterEngineService } from '../shared/filter/filter-engine.service'; import { RankingRequestDto, RankingCriteriaType, TraitRankingCondition, } from './dto/ranking-request.dto'; -import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig'; +import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig'; /** * 낮을수록 좋은 형질 목록 (부호 반전 필요) @@ -62,6 +63,10 @@ export class CowService { @InjectRepository(GeneDetailModel) private readonly geneDetailRepository: Repository, + // 번식능력 Repository (MPT 데이터 접근용) + @InjectRepository(MptModel) + private readonly mptRepository: Repository, + // 동적 필터링 서비스 (검색, 정렬, 페이지네이션) private readonly filterEngineService: FilterEngineService, ) { } @@ -176,61 +181,133 @@ export class CowService { const { filterOptions, rankingOptions } = rankingRequest; const { criteriaType } = rankingOptions; - // Step 2: 필터 조건에 맞는 개체 목록 조회 - const cows = await this.getFilteredCows(filterOptions); + // Step 2: 필터 조건에 맞는 개체 목록 조회 (+ MPT cowId Set) + const { cows, mptCowIdMap } = await this.getFilteredCows(filterOptions); // Step 3: 랭킹 기준에 따라 분기 처리 switch (criteriaType) { // 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) case RankingCriteriaType.GENOME: - return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []); + return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap); // 기본값: 랭킹 없이 순서대로 반환 default: return { - items: cows.map((cow, index) => ({ - entity: cow, - rank: index + 1, - sortValue: 0, - })), + items: cows.map((cow, index) => { + const mptData = mptCowIdMap.get(cow.cowId); + return { + entity: { + ...cow, + hasMpt: mptCowIdMap.has(cow.cowId), + mptTestDt: mptData?.testDt || null, + mptMonthAge: mptData?.monthAge || null, + }, + rank: index + 1, + sortValue: 0, + }; + }), total: cows.length, criteriaType, }; } } - /** - * 필터 조건에 맞는 개체 목록 조회 (Private) - * - * @param filterOptions - 필터/정렬/페이지네이션 옵션 - * @returns 필터링된 개체 목록 - */ - private async getFilteredCows(filterOptions?: any): Promise { - // QueryBuilder로 기본 쿼리 구성 - const queryBuilder = this.cowRepository - .createQueryBuilder('cow') - .leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN - .where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만 +/** + * 필터 조건에 맞는 개체 목록 조회 (Private) + * 유전체 분석 의뢰/유전체 형질/유전자/번식능력(MPT) 데이터 중 하나라도 있는 개체 조회 + * + * @param filterOptions - 필터/정렬/페이지네이션 옵션 + * @returns { cows: 필터링된 개체 목록, mptCowIdMap: MPT cowId -> { testDt, monthAge } Map } + */ + private async getFilteredCows(filterOptions?: any): Promise<{ cows: CowModel[], mptCowIdMap: Map }> { + // Step 1: 4가지 데이터 소스에서 cowId 수집 (병렬 처리) + const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([ + // 유전체 분석 의뢰가 있는 개체의 cowId (cow 테이블 조인) + this.genomeRequestRepository + .createQueryBuilder('request') + .innerJoin('request.cow', 'cow') + .select('DISTINCT cow.cowId', 'cowId') + .where('request.delDt IS NULL') + .getRawMany(), + // 유전체 형질 데이터가 있는 cowId + this.genomeTraitDetailRepository + .createQueryBuilder('trait') + .select('DISTINCT trait.cowId', 'cowId') + .where('trait.delDt IS NULL') + .getRawMany(), + // 유전자 데이터가 있는 cowId + this.geneDetailRepository + .createQueryBuilder('gene') + .select('DISTINCT gene.cowId', 'cowId') + .where('gene.delDt IS NULL') + .getRawMany(), + // 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령 + // 서브쿼리로 최신 검사일 기준 데이터 가져오기 + this.mptRepository + .createQueryBuilder('mpt') + .select('mpt.cowId', 'cowId') + .addSelect('mpt.testDt', 'testDt') + .addSelect('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}`; + }) + .getRawMany(), + ]); - // farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성) - if (filterOptions?.farmNo) { - queryBuilder.andWhere('cow.fkFarmNo = :farmNo', { - farmNo: filterOptions.farmNo - }); - } + // Step 2: cowId 통합 (중복 제거) + const allCowIds = [...new Set([ + ...genomeRequestCowIds.map(c => c.cowId).filter(Boolean), + ...genomeCowIds.map(c => c.cowId).filter(Boolean), + ...geneCowIds.map(c => c.cowId).filter(Boolean), + ...mptCowIds.map(c => c.cowId).filter(Boolean), + ])]; - // FilterEngine 사용하여 동적 필터 적용 - if (filterOptions?.filters) { - const result = await this.filterEngineService.executeFilteredQuery( - queryBuilder, - filterOptions, + // MPT cowId -> { testDt, monthAge } Map 생성 + const mptCowIdMap = new Map( + mptCowIds + .filter(c => c.cowId) + .map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }]) ); - return result.data; - } - // 필터 없으면 전체 조회 (최신순) - return queryBuilder.orderBy('cow.regDt', 'DESC').getMany(); - } + // 데이터가 있는 개체가 없으면 빈 배열 반환 + if (allCowIds.length === 0) { + return { cows: [], mptCowIdMap }; + } + + // Step 3: 해당 cowId로 개체 조회 + const queryBuilder = this.cowRepository + .createQueryBuilder('cow') + .leftJoinAndSelect('cow.farm', 'farm') + .where('cow.cowId IN (:...cowIds)', { cowIds: allCowIds }) + .andWhere('cow.delDt IS NULL'); + + // farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성) + if (filterOptions?.farmNo) { + queryBuilder.andWhere('cow.fkFarmNo = :farmNo', { + farmNo: filterOptions.farmNo + }); + } + + // FilterEngine 사용하여 동적 필터 적용 + if (filterOptions?.filters) { + const result = await this.filterEngineService.executeFilteredQuery( + queryBuilder, + filterOptions, + ); + return { cows: result.data, mptCowIdMap }; + } + + // 필터 없으면 전체 조회 (최신순) + const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany(); + return { cows, mptCowIdMap }; + } // ============================================================ // 유전체(GENOME) 랭킹 메서드 @@ -255,6 +332,7 @@ export class CowService { private async applyGenomeRanking( cows: CowModel[], inputTraitConditions: TraitRankingCondition[], + mptCowIdMap: Map, ): Promise { // 35개 전체 형질 (기본값) const ALL_TRAITS = [ @@ -286,7 +364,10 @@ export class CowService { // 분석불가 사유 결정 let unavailableReason: string | null = null; - if (!latestRequest || !latestRequest.chipSireName) { + // EXCLUDED_COW_IDS에 포함된 개체 (모근 오염/불량 등 기타 사유) + if (EXCLUDED_COW_IDS.includes(cow.cowId)) { + unavailableReason = '분석불가'; + } else if (!latestRequest || !latestRequest.chipSireName) { // latestRequest 없거나 chipSireName이 null → '-' 표시 (프론트에서 null은 '-'로 표시) unavailableReason = null; } else if (latestRequest.chipSireName === '분석불가' || latestRequest.chipSireName === '정보없음') { @@ -301,7 +382,19 @@ export class CowService { unavailableReason = '모 이력제부재'; } - return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] }; + const mptData = mptCowIdMap.get(cow.cowId); + return { + entity: { + ...cow, + unavailableReason, + hasMpt: mptCowIdMap.has(cow.cowId), + mptTestDt: mptData?.testDt || null, + mptMonthAge: mptData?.monthAge || null, + anlysDt: latestRequest?.requestDt ?? null, + }, + sortValue: null, + details: [], + }; } // Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값) @@ -311,7 +404,18 @@ export class CowService { // 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음) if (traitDetails.length === 0) { - return { entity: { ...cow, unavailableReason: '형질정보없음' }, sortValue: null, details: [] }; + const mptData = mptCowIdMap.get(cow.cowId); + return { + entity: { + ...cow, + unavailableReason: '형질정보없음', + hasMpt: mptCowIdMap.has(cow.cowId), + mptTestDt: mptData?.testDt || null, + mptMonthAge: mptData?.monthAge || null, + }, + sortValue: null, + details: [], + }; } // Step 4: 가중 합계 계산 @@ -354,10 +458,14 @@ export class CowService { : null; // Step 7: 응답 데이터 구성 + const mptData = mptCowIdMap.get(cow.cowId); return { entity: { ...cow, anlysDt: latestRequest.requestDt, // 분석일자 추가 + hasMpt: mptCowIdMap.has(cow.cowId), // MPT 검사 여부 + mptTestDt: mptData?.testDt || null, // MPT 검사일 + mptMonthAge: mptData?.monthAge || null, // MPT 월령 }, sortValue, // 계산된 종합 점수 (선발지수) details, // 점수 계산에 사용된 형질별 상세 diff --git a/backend/src/genome/genome.module.ts b/backend/src/genome/genome.module.ts index f36dfa9..836b696 100644 --- a/backend/src/genome/genome.module.ts +++ b/backend/src/genome/genome.module.ts @@ -6,6 +6,8 @@ import { GenomeRequestModel } from './entities/genome-request.entity'; import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity'; import { CowModel } from '../cow/entities/cow.entity'; import { FarmModel } from '../farm/entities/farm.entity'; +import { MptModel } from '../mpt/entities/mpt.entity'; +import { GeneDetailModel } from '../gene/entities/gene-detail.entity'; @Module({ imports: [ @@ -14,6 +16,8 @@ import { FarmModel } from '../farm/entities/farm.entity'; GenomeTraitDetailModel, CowModel, FarmModel, + MptModel, + GeneDetailModel, ]), ], controllers: [GenomeController], diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index f6777f8..ee100a3 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -9,6 +9,8 @@ import { CowModel } from '../cow/entities/cow.entity'; import { FarmModel } from '../farm/entities/farm.entity'; import { GenomeRequestModel } from './entities/genome-request.entity'; import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity'; +import { MptModel } from '../mpt/entities/mpt.entity'; +import { GeneDetailModel } from '../gene/entities/gene-detail.entity'; /** * 낮을수록 좋은 형질 목록 (부호 반전 필요) @@ -139,6 +141,14 @@ export class GenomeService { // 농장 정보 Repository @InjectRepository(FarmModel) private readonly farmRepository: Repository, + + // 번식능력검사 Repository + @InjectRepository(MptModel) + private readonly mptRepository: Repository, + + // 유전자검사 상세 Repository + @InjectRepository(GeneDetailModel) + private readonly geneDetailRepository: Repository, ) { } // ============================================ @@ -359,7 +369,11 @@ export class GenomeService { }[]; // 요약 summary: { - totalRequests: number; + totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외) + genomeCowCount: number; // 유전체 분석 개체 수 + geneCowCount: number; // 유전자검사 개체 수 + mptCowCount: number; // 번식능력검사 개체 수 + totalRequests: number; // 유전체 의뢰 건수 (기존 호환성) analyzedCount: number; pendingCount: number; mismatchCount: number; @@ -729,21 +743,79 @@ export class GenomeService { const mismatchCount = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length; const pendingCount = totalRequests - analyzedCount - mismatchCount; - // 성별 체크 - 디버깅 강화 - // 실제 성별 값 분석 - const sexAnalysis = requests.map(r => ({ - cowId: r.cow?.cowId, - cowSex: r.cow?.cowSex, - hasCow: !!r.cow, - })); + // Step 5.1: 검사 유형별 개체 수 계산 (합집합, 중복 제외) + // 농장 소유 개체의 cowId 목록 조회 + const farmCows = await this.cowRepository.find({ + where: { fkFarmNo: farmNo, delDt: IsNull() }, + select: ['cowId', 'cowSex'], + }); + const farmCowIds = new Set(farmCows.map(c => c.cowId).filter(Boolean)); + const farmCowMap = new Map(farmCows.map(c => [c.cowId, c])); - // 성별 체크 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리) - const maleCount = requests.filter(r => { - const sex = r.cow?.cowSex?.toUpperCase(); - return sex === 'M' || sex === '수' || sex === '1'; - }).length; - // 수컷이 아니면 모두 암컷으로 처리 (null 포함) - const femaleCount = requests.length - maleCount; + // 각 검사별 cowId 조회 (병렬 처리) - genomeRequest도 포함 (리스트 페이지와 일치) + const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([ + // 유전체 분석 의뢰가 있는 개체 (분석불가 포함) + this.genomeRequestRepository + .createQueryBuilder('request') + .innerJoin('request.cow', 'cow') + .select('DISTINCT cow.cowId', 'cowId') + .where('request.delDt IS NULL') + .getRawMany() + .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), + // 유전체 분석 개체 (형질 데이터 보유) + this.genomeTraitDetailRepository + .createQueryBuilder('trait') + .select('DISTINCT trait.cowId', 'cowId') + .where('trait.delDt IS NULL') + .getRawMany() + .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), + // 유전자검사 개체 + this.geneDetailRepository + .createQueryBuilder('gene') + .select('DISTINCT gene.cowId', 'cowId') + .where('gene.delDt IS NULL') + .getRawMany() + .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), + // 번식능력검사 개체 + this.mptRepository + .createQueryBuilder('mpt') + .select('DISTINCT mpt.cowId', 'cowId') + .where('mpt.delDt IS NULL') + .getRawMany() + .then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)), + ]); + + // 농장 소유 개체만 필터링 + const farmGenomeRequestCowIds = genomeRequestCowIds.filter(id => farmCowIds.has(id)); + const farmGenomeCowIds = genomeCowIds.filter(id => farmCowIds.has(id)); + const farmGeneCowIds = geneCowIds.filter(id => farmCowIds.has(id)); + const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id)); + + // 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치) + const allTestedCowIds = new Set([ + ...farmGenomeRequestCowIds, + ...farmGenomeCowIds, + ...farmGeneCowIds, + ...farmMptCowIds, + ]); + + const totalCows = allTestedCowIds.size; + const genomeCowCount = farmGenomeCowIds.length; + const geneCowCount = farmGeneCowIds.length; + const mptCowCount = farmMptCowIds.length; + + // 성별 체크 - 전체 검사 개체 기준 (M/수/1 = 수컷, 그 외 모두 암컷으로 처리) + let maleCount = 0; + let femaleCount = 0; + for (const cowId of allTestedCowIds) { + const cow = farmCowMap.get(cowId); + const sex = cow?.cowSex?.toUpperCase(); + if (sex === 'M' || sex === '수' || sex === '1') { + maleCount++; + } else { + femaleCount++; + } + } // Step 6: 검사 종류별 현황 (SNP, MS) const testTypeStats = { @@ -822,7 +894,11 @@ export class GenomeService { yearlyAvgEbv, requestHistory, summary: { - totalRequests, + totalCows, // 검사 받은 전체 개체 수 (합집합) + genomeCowCount, // 유전체 분석 개체 수 + geneCowCount, // 유전자검사 개체 수 + mptCowCount, // 번식능력검사 개체 수 + totalRequests, // 유전체 의뢰 건수 (기존 호환성) analyzedCount, pendingCount, mismatchCount, diff --git a/backend/src/mpt/mpt.controller.ts b/backend/src/mpt/mpt.controller.ts index 38477c3..49f0896 100644 --- a/backend/src/mpt/mpt.controller.ts +++ b/backend/src/mpt/mpt.controller.ts @@ -10,16 +10,30 @@ export class MptController { findAll( @Query('farmId') farmId?: string, @Query('cowShortNo') cowShortNo?: string, + @Query('cowId') cowId?: string, ) { if (farmId) { return this.mptService.findByFarmId(+farmId); } + if (cowId) { + return this.mptService.findByCowId(cowId); + } if (cowShortNo) { return this.mptService.findByCowShortNo(cowShortNo); } return this.mptService.findAll(); } + /** + * 농장별 MPT 통계 조회 + * - 카테고리별 정상/주의/위험 개체 수 + * - 위험 개체 목록 + */ + @Get('statistics/:farmNo') + getMptStatistics(@Param('farmNo') farmNo: string) { + return this.mptService.getMptStatistics(+farmNo); + } + @Get(':id') findOne(@Param('id') id: string) { return this.mptService.findOne(+id); diff --git a/backend/src/mpt/mpt.service.ts b/backend/src/mpt/mpt.service.ts index 81921b8..44d1e77 100644 --- a/backend/src/mpt/mpt.service.ts +++ b/backend/src/mpt/mpt.service.ts @@ -3,6 +3,49 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull } from 'typeorm'; import { MptModel } from './entities/mpt.entity'; +/** + * MPT 참조값 범위 (정상/주의/위험 판단 기준) + */ +const MPT_REFERENCE_RANGES: Record = { + glucose: { lower: 40, upper: 84, category: 'energy' }, + cholesterol: { lower: 74, upper: 252, category: 'energy' }, + nefa: { lower: 115, upper: 660, category: 'energy' }, + bcs: { lower: 2.5, upper: 3.5, category: 'energy' }, + totalProtein: { lower: 6.2, upper: 7.7, category: 'protein' }, + albumin: { lower: 3.3, upper: 4.3, category: 'protein' }, + globulin: { lower: 9.1, upper: 36.1, category: 'protein' }, + agRatio: { lower: 0.1, upper: 0.4, category: 'protein' }, + bun: { lower: 11.7, upper: 18.9, category: 'protein' }, + ast: { lower: 47, upper: 92, category: 'liver' }, + ggt: { lower: 11, upper: 32, category: 'liver' }, + fattyLiverIdx: { lower: -1.2, upper: 9.9, category: 'liver' }, + calcium: { lower: 8.1, upper: 10.6, category: 'mineral' }, + phosphorus: { lower: 6.2, upper: 8.9, category: 'mineral' }, + caPRatio: { lower: 1.2, upper: 1.3, category: 'mineral' }, + magnesium: { lower: 1.6, upper: 3.3, category: 'mineral' }, +}; + +/** + * 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'; + }>; +} + @Injectable() export class MptService { constructor( @@ -34,6 +77,14 @@ export class MptService { }); } + async findByCowId(cowId: string): Promise { + return this.mptRepository.find({ + where: { cowId: cowId, delDt: IsNull() }, + relations: ['farm'], + order: { testDt: 'DESC' }, + }); + } + async findOne(id: number): Promise { const mpt = await this.mptRepository.findOne({ where: { pkMptNo: id, delDt: IsNull() }, @@ -65,4 +116,143 @@ export class MptService { const mpt = await this.findOne(id); await this.mptRepository.softRemove(mpt); } + + /** + * 농장별 MPT 통계 조회 + * - 개체별 최신 검사 결과 기준 + * - 카테고리별 정상/주의/위험 개체 수 + * - 위험 개체 목록 (Top 5) + */ + async getMptStatistics(farmNo: number): Promise { + // 농장의 모든 MPT 데이터 조회 + const allMptData = await this.mptRepository.find({ + where: { fkFarmNo: farmNo, delDt: IsNull() }, + order: { testDt: 'DESC' }, + }); + + if (allMptData.length === 0) { + return { + totalMptCows: 0, + latestTestDate: null, + categories: { + energy: { safe: 0, caution: 0 }, + protein: { safe: 0, caution: 0 }, + liver: { safe: 0, caution: 0 }, + mineral: { safe: 0, caution: 0 }, + }, + riskyCows: [], + }; + } + + // 개체별 최신 검사 데이터만 추출 + const latestByCoW = new Map(); + for (const mpt of allMptData) { + if (!mpt.cowId) continue; + if (!latestByCoW.has(mpt.cowId)) { + latestByCoW.set(mpt.cowId, mpt); + } + } + + const latestMptData = Array.from(latestByCoW.values()); + const totalMptCows = latestMptData.length; + const latestTestDate = latestMptData[0]?.testDt || null; + + // 카테고리별 통계 초기화 (안전/주의 2단계) + const categoryStats = { + energy: { safe: 0, caution: 0 }, + protein: { safe: 0, caution: 0 }, + liver: { safe: 0, caution: 0 }, + mineral: { safe: 0, caution: 0 }, + }; + + // 주의 개체 목록 + const riskyCowsList: MptStatisticsDto['riskyCows'] = []; + + // 각 개체별로 카테고리별 상태 평가 + for (const mpt of latestMptData) { + // 각 카테고리별로 이상 항목이 있는지 체크 (안전/주의 2단계) + const categoryStatus: Record = { + energy: 'safe', + protein: 'safe', + liver: 'safe', + mineral: 'safe', + }; + + // 각 항목별 체크 (범위 내 = 안전, 범위 밖 = 주의) + const checkItem = (value: number | null, itemKey: string) => { + if (value === null || value === undefined) return; + const ref = MPT_REFERENCE_RANGES[itemKey]; + if (!ref) return; + + const category = ref.category as keyof typeof categoryStatus; + + // 범위 밖이면 주의 + if (value > ref.upper || value < ref.lower) { + categoryStatus[category] = 'caution'; + + // 주의 개체 목록에 추가 + riskyCowsList.push({ + cowId: mpt.cowId, + category, + itemName: itemKey, + value, + status: value > ref.upper ? 'high' : 'low', + }); + } + }; + + // 에너지 카테고리 + checkItem(mpt.glucose, 'glucose'); + checkItem(mpt.cholesterol, 'cholesterol'); + checkItem(mpt.nefa, 'nefa'); + checkItem(mpt.bcs, 'bcs'); + + // 단백질 카테고리 + checkItem(mpt.totalProtein, 'totalProtein'); + checkItem(mpt.albumin, 'albumin'); + checkItem(mpt.globulin, 'globulin'); + checkItem(mpt.agRatio, 'agRatio'); + checkItem(mpt.bun, 'bun'); + + // 간기능 카테고리 + checkItem(mpt.ast, 'ast'); + checkItem(mpt.ggt, 'ggt'); + checkItem(mpt.fattyLiverIdx, 'fattyLiverIdx'); + + // 미네랄 카테고리 + checkItem(mpt.calcium, 'calcium'); + checkItem(mpt.phosphorus, 'phosphorus'); + checkItem(mpt.caPRatio, 'caPRatio'); + checkItem(mpt.magnesium, 'magnesium'); + + // 카테고리별 통계 업데이트 + for (const [cat, status] of Object.entries(categoryStatus)) { + const category = cat as keyof typeof categoryStats; + categoryStats[category][status]++; + } + } + + // 위험 개체 정렬 및 상위 5개만 + const sortedRiskyCows = riskyCowsList + .sort((a, b) => { + // 범위 이탈 정도로 정렬 + const refA = MPT_REFERENCE_RANGES[a.itemName]; + const refB = MPT_REFERENCE_RANGES[b.itemName]; + const deviationA = a.status === 'high' + ? (a.value - refA.upper) / (refA.upper - refA.lower) + : (refA.lower - a.value) / (refA.upper - refA.lower); + const deviationB = b.status === 'high' + ? (b.value - refB.upper) / (refB.upper - refB.lower) + : (refB.lower - b.value) / (refB.upper - refB.lower); + return deviationB - deviationA; + }) + .slice(0, 5); + + return { + totalMptCows, + latestTestDate, + categories: categoryStats, + riskyCows: sortedRiskyCows, + }; + } } diff --git a/frontend/src/app/cow/[cowNo]/page.tsx b/frontend/src/app/cow/[cowNo]/page.tsx index 15f5c17..2de6010 100644 --- a/frontend/src/app/cow/[cowNo]/page.tsx +++ b/frontend/src/app/cow/[cowNo]/page.tsx @@ -14,7 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useGlobalFilter } from "@/contexts/GlobalFilterContext" import { useMediaQuery } from "@/hooks/use-media-query" import { useToast } from "@/hooks/use-toast" -import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api" +import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto, mptApi } from "@/lib/api" import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config" import { CowDetail } from "@/types/cow.types" import { GenomeTrait } from "@/types/genome.types" @@ -35,6 +35,7 @@ import { CategoryEvaluationCard } from "./genome/_components/category-evaluation import { TraitComparison } from "./genome/_components/genome-integrated-comparison" import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart" import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts" +import { MptTable } from "./reproduction/_components/mpt-table" // 형질명 → 카테고리 매핑 (한우 35개 형질) const TRAIT_CATEGORY_MAP: Record = { @@ -405,9 +406,13 @@ export default function CowOverviewPage() { setGenomeRequest(null) } - // 번식능력 데이터 (현재는 목업 - 추후 API 연동) - // TODO: 번식능력 API 연동 - setHasReproductionData(false) + // 번식능력 데이터 조회 + try { + const mptData = await mptApi.findByCowId(cowNo) + setHasReproductionData(mptData && mptData.length > 0) + } catch { + setHasReproductionData(false) + } // 첫 번째 사용 가능한 탭 자동 선택 if (genomeExists) { @@ -617,15 +622,19 @@ export default function CowOverviewPage() { 목록으로 - {/* 아이콘 */} -
- -
- {/* 타이틀 */} -
-

개체 분석 보고서

-

Analysis Report

-
+ {/* 아이콘 + 타이틀 (클릭시 새로고침) */} + @@ -658,6 +667,9 @@ export default function CowOverviewPage() { > 번식능력 + + {hasReproductionData ? '완료' : '미검사'} + @@ -692,12 +704,12 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석일 기준)
- {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -730,10 +742,10 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석) - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -969,12 +981,12 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석일 기준)
- {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -1003,10 +1015,10 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석) - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -1130,12 +1142,12 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석일 기준)
- {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -1168,10 +1180,10 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석) - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -1666,14 +1678,10 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석일 기준)
- - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - + -
@@ -1700,10 +1708,10 @@ export default function CowOverviewPage() {
- 월령 + 월령 (분석) - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + {cow?.cowBirthDt && genomeData[0]?.request?.requestDt + ? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
@@ -1792,19 +1800,8 @@ export default function CowOverviewPage() { {/* 번식능력 탭 */} - {/* 혈액화학검사(MPT) 테이블 - 추후 사용 + {/* 혈액화학검사(MPT) 테이블 */} - */} - - - - -

번식능력 분석 데이터 없음

-

- 이 개체는 아직 번식능력 분석이 완료되지 않았습니다. -

-
-
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 7de57d9..ef817be 100644 --- a/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx +++ b/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx @@ -12,22 +12,21 @@ import { MPT_REFERENCE_RANGES } from "@/constants/mpt-reference" // 혈액화학검사 카테고리별 항목 const MPT_CATEGORIES = [ - { name: '에너지 대사', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' }, - { name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' }, + { name: '에너지', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' }, + { name: '단백질', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' }, { name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' }, { name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' }, - { name: '기타', items: ['creatine'], color: 'bg-muted/50' }, + { name: '별도', items: ['creatine'], color: 'bg-muted/50' }, ] -// 측정값 상태 판정 -function getMptValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' { +// 측정값 상태 판정: 안전(safe) / 주의(caution) +function getMptValueStatus(key: string, value: number | null): 'safe' | 'caution' | 'unknown' { if (value === null || value === undefined) return 'unknown' const ref = MPT_REFERENCE_RANGES[key] if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown' - if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'normal' - const margin = (ref.upperLimit - ref.lowerLimit) * 0.1 - if (value >= ref.lowerLimit - margin && value <= ref.upperLimit + margin) return 'warning' - return 'danger' + // 하한값 ~ 상한값 사이면 안전, 그 외 주의 + if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe' + return 'caution' } interface MptTableProps { @@ -45,11 +44,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT useEffect(() => { const fetchMptData = async () => { - if (!cowShortNo) return + if (!cowNo) return setLoading(true) try { - const data = await mptApi.findByCowShortNo(cowShortNo) + const data = await mptApi.findByCowId(cowNo) setMptData(data) if (data.length > 0) { setSelectedMpt(data[0]) @@ -62,7 +61,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT } fetchMptData() - }, [cowShortNo]) + }, [cowNo]) if (loading) { return ( @@ -82,7 +81,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT {/* 데스크탑: 가로 그리드 */} -
+
개체번호 @@ -101,25 +100,18 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
-
-
- 월령 -
-
- - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
성별
- {cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'} + {(() => { + const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex + if (sex === 'F' || sex === '암' || sex === '2') return '암소' + if (sex === 'M' || sex === '수' || sex === '1') return '수소' + return '-' + })()}
@@ -138,171 +130,21 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
-
- 월령 - - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
성별 - {cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'} + {(() => { + const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex + if (sex === 'F' || sex === '암' || sex === '2') return '암소' + if (sex === 'M' || sex === '수' || sex === '1') return '수소' + return '-' + })()}
- {/* 혈통정보 섹션 */} -

혈통정보

- - - {/* 데스크탑: 가로 그리드 */} -
-
-
- 부 KPN번호 -
-
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeRequest?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return null - } - })()} -
-
-
-
- 모 개체번호 -
-
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeRequest?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - return null - } - })()} -
-
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 부 KPN번호 -
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeRequest?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return null - } - })()} -
-
-
- 모 개체번호 -
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeRequest?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - return null - } - })()} -
-
-
-
-
- {/* 검사 정보 */} {selectedMpt && ( <> @@ -383,7 +225,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT )} - {/* 혈액화학검사 결과 테이블 */} + {/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */} + {selectedMpt ? ( + <>

혈액화학검사 결과

@@ -391,13 +235,13 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT - - - - - - - + + + + + + + @@ -420,9 +264,8 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT - - + + @@ -958,19 +988,26 @@ function MyCowContent() { - +
카테고리검사항목측정값하한값상한값단위상태카테고리검사항목측정값하한값상한값단위상태
{ref?.name || itemKey} {value !== null && value !== undefined ? value.toFixed(2) : '-'} @@ -434,14 +277,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT {value !== null && value !== undefined ? ( - {status === 'normal' ? '정상' : - status === 'warning' ? '주의' : - status === 'danger' ? '이상' : '-'} + {status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'} ) : ( - @@ -479,19 +319,19 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT )} - - {/* 데이터 없음 안내 */} - {/* {!selectedMpt && ( + + ) : ( + /* 데이터 없음 안내 */ -

혈액화학검사 데이터 없음

+

번식능력검사 데이터 없음

- 이 개체는 아직 혈액화학검사(MPT) 결과가 등록되지 않았습니다. + 이 개체는 번식능력검사를 진행하지 않아 분석보고서를 제공할 수 없습니다.

- )} */} + )} ) } diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index dbcaa27..09bda5f 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -56,8 +56,11 @@ interface CowWithGenes extends Cow { rank?: number // 랭킹 순위 cowShortNo?: string // 개체 요약번호 cowReproType?: string // 번식 타입 - anlysDt?: string // 분석일자 + anlysDt?: string // 분석일자 (유전체) unavailableReason?: string // 분석불가 사유 (부불일치, 모불일치, 모이력제부재 등) + hasMpt?: boolean // 번식능력검사(MPT) 여부 + mptTestDt?: string // MPT 검사일 + mptMonthAge?: number // MPT 검사일 기준 월령 } function MyCowContent() { @@ -75,7 +78,7 @@ function MyCowContent() { const [rankingMode, setRankingMode] = useState<'gene' | 'genome'>('gene') // 유전자순/유전체순 const [sortBy, setSortBy] = useState('rank') // 정렬 기준 (기본: 순위) const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') // 정렬 방향 - const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'unavailable'>('all') // 분석 상태 필터 + const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'mptOnly' | 'unavailable'>('all') // 분석 상태 필터 // 커스텀 컬럼 표시 필터 const [selectedDisplayGenes, setSelectedDisplayGenes] = useState([]) // 테이블에 표시할 유전자 @@ -355,6 +358,12 @@ function MyCowContent() { 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 } @@ -513,9 +522,14 @@ function MyCowContent() { // 분석 상태 필터 if (analysisFilter === 'completed') { + // 유전체 완료 result = result.filter(cow => cow.genomeScore !== undefined && cow.genomeScore !== null) + } else if (analysisFilter === 'mptOnly') { + // 번식능력 검사 완료 (유전체 유무 상관없이) + result = result.filter(cow => cow.hasMpt === true) } else if (analysisFilter === 'unavailable') { - result = result.filter(cow => cow.genomeScore === undefined || cow.genomeScore === null) + // 유전체 분석불가 (부불일치, 모불일치 등) + result = result.filter(cow => cow.unavailableReason !== null && cow.unavailableReason !== undefined) } // 정렬 (sortBy가 'none'이면 정렬하지 않음 - 전역 필터 순서 유지) @@ -684,11 +698,11 @@ function MyCowContent() {

{'농장'} 보유 개체 현황

- {/* 분석 상태 탭 필터 */} -
+ {/* 분석 상태 탭 필터 - 모바일: 2x2 그리드, 데스크톱: 가로 배치 */} +
+
@@ -923,11 +949,15 @@ function MyCowContent() {
순위 개체번호 생년월일월령 성별 모개체번호 아비 KPN분석일자 + {analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'} + + {analysisFilter === 'mptOnly' ? '검사일자' : '분석일자'} + 선발지수 - {cow.cowBirthDt && new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit' - })} - - {cow.cowBirthDt ? (() => { - const birthDate = new Date(cow.cowBirthDt) - const today = new Date() - const ageInMonths = Math.floor((today.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)) - return `${ageInMonths}개월` - })() : '-'} + {(() => { + // 번식능력만 있는 개체 판단 (유전체 데이터 없음) + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산 + if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) { + const testDate = new Date(cow.mptTestDt) + const birthDate = new Date(testDate) + birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge) + return birthDate.toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit' + }) + } + return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit' + }) : '-' + })()} {cow.cowSex === "수" ? "수소" : "암소"} @@ -982,19 +1019,52 @@ function MyCowContent() { {cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - {cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit' - }) : cow.unavailableReason ? ( - - {cow.unavailableReason} - - ) : '-'} + {(() => { + // 번식능력만 있는 개체 판단 + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용 + if (analysisFilter === 'mptOnly' || hasMptOnly) { + return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-' + } + 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 '-' + })()} + + {(() => { + // 번식능력만 있는 개체 판단 + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용 + if (analysisFilter === 'mptOnly' || hasMptOnly) { + return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit' + }) : '-' + } + // 유전체 탭: unavailableReason 있으면 배지, 없으면 분석일자 + if (cow.unavailableReason) { + return ( + + {cow.unavailableReason} + + ) + } + return cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit' + }) : '-' + })()} {(cow.genomeScore !== undefined && cow.genomeScore !== null) ? ( @@ -1169,13 +1239,40 @@ function MyCowContent() {
생년월일 - {cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'} + {(() => { + // 번식능력만 있는 개체 판단 + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산 + if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) { + const testDate = new Date(cow.mptTestDt) + const birthDate = new Date(testDate) + birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge) + return birthDate.toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) + } + return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-' + })()}
- 월령 + + {(() => { + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + return (analysisFilter === 'mptOnly' || hasMptOnly) ? '월령 (검사일)' : '월령 (분석일)' + })()} + - {cow.cowBirthDt ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'} + {(() => { + // 번식능력만 있는 개체 판단 + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용 + if (analysisFilter === 'mptOnly' || hasMptOnly) { + return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-' + } + if (cow.cowBirthDt && cow.anlysDt) { + return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + } + return '-' + })()}
@@ -1195,17 +1292,36 @@ function MyCowContent() { {cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
- {cow.anlysDt ? '분석일' : '분석결과'} + + {(() => { + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + return (analysisFilter === 'mptOnly' || hasMptOnly) ? '검사일' : (cow.anlysDt ? '분석일' : '분석결과') + })()} + - {cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : cow.unavailableReason ? ( - - {cow.unavailableReason} - - ) : '-'} + {(() => { + // 번식능력만 있는 개체 판단 + const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt + // 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용 + if (analysisFilter === 'mptOnly' || hasMptOnly) { + return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-' + } + if (cow.anlysDt) { + return new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) + } + if (cow.unavailableReason) { + return ( + + {cow.unavailableReason} + + ) + } + return '-' + })()}
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 34be54e..a8975e8 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/select" import { apiClient, farmApi } from "@/lib/api" import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api" +import { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api" import { useAuthStore } from "@/store/auth-store" import { useGlobalFilter } from "@/contexts/GlobalFilterContext" import { @@ -69,6 +70,7 @@ export default function DashboardPage() { const [loading, setLoading] = useState(true) const [stats, setStats] = useState(null) const [farmRanking, setFarmRanking] = useState(null) + const [mptStats, setMptStats] = useState(null) // 모바일 감지 (반응형) const [isMobile, setIsMobile] = useState(false) @@ -167,12 +169,14 @@ export default function DashboardPage() { })) : undefined try { - const [statsData, rankingData] = await Promise.all([ + const [statsData, rankingData, mptStatsData] = await Promise.all([ genomeApi.getDashboardStats(farmNo), - genomeApi.getFarmRegionRanking(farmNo, traitConditions) + genomeApi.getFarmRegionRanking(farmNo, traitConditions), + mptApi.getMptStatistics(farmNo).catch(() => null) ]) setStats(statsData) setFarmRanking(rankingData) + setMptStats(mptStatsData) } catch (error) { console.error('대시보드 통계 로드 실패:', error) } @@ -413,45 +417,38 @@ export default function DashboardPage() { <> {/* ========== 1. 핵심 KPI 카드 (2개) ========== */}
- {/* 총 분석 두수 + 암/수 */} + {/* 총 검사 개체 수 (합집합) */}
router.push('/cow')} >
-

유전체 총 분석

- - 분석일자{' '} - {(() => { - const latestRequest = stats?.requestHistory - ?.filter(r => r.requestDt) - ?.sort((a, b) => new Date(b.requestDt!).getTime() - new Date(a.requestDt!).getTime())[0] - if (latestRequest?.requestDt) { - return new Date(latestRequest.requestDt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'numeric', day: 'numeric' }) - } - return '-' - })()} - +

총 검사 개체 수

- {stats?.summary.totalRequests || 0} + {stats?.summary.totalCows || 0}

-
-
- ♂ 수 - {stats?.summary.maleCount || 0} -
-
- ♀ 암 - {stats?.summary.femaleCount || 0} -
+
+ 유전체 {stats?.summary.genomeCowCount || 0} + · + 유전자 {stats?.summary.geneCowCount || 0} + · + 번식능력 {stats?.summary.mptCowCount || 0}
{/* 친자감별 결과 (넓게) */}

유전체 친자감별 결과

+ {(stats?.summary.genomeCowCount || 0) === 0 ? ( +
+
+ +

유전체 검사 데이터가 없습니다

+
+
+ ) : (
{/* 도넛 차트 */}
@@ -529,9 +526,104 @@ export default function DashboardPage() {
+ )}
+ {/* ========== 1-2. 번식능력검사 현황 ========== */} + {mptStats && mptStats.totalMptCows > 0 && ( +
+
+

번식능력검사 현황

+ + 검사 {mptStats.totalMptCows}두 + +
+
+ {/* 에너지 균형 */} +
+

에너지 균형

+
+
+ + 안전 + + {mptStats.categories.energy.safe} +
+ {mptStats.categories.energy.caution > 0 && ( +
+ + 주의 + + {mptStats.categories.energy.caution} +
+ )} +
+
+ {/* 단백질 상태 */} +
+

단백질 상태

+
+
+ + 안전 + + {mptStats.categories.protein.safe} +
+ {mptStats.categories.protein.caution > 0 && ( +
+ + 주의 + + {mptStats.categories.protein.caution} +
+ )} +
+
+ {/* 간 건강 */} +
+

간 건강

+
+
+ + 안전 + + {mptStats.categories.liver.safe} +
+ {mptStats.categories.liver.caution > 0 && ( +
+ + 주의 + + {mptStats.categories.liver.caution} +
+ )} +
+
+ {/* 미네랄 균형 */} +
+

미네랄 균형

+
+
+ + 안전 + + {mptStats.categories.mineral.safe} +
+ {mptStats.categories.mineral.caution > 0 && ( +
+ + 주의 + + {mptStats.categories.mineral.caution} +
+ )} +
+
+
+
+ )} + {/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */}
{/* 좌측 영역: 보은군 내 농가 위치 + 순위 통합 */} @@ -565,7 +657,7 @@ export default function DashboardPage() { 보은군
- {farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? ( + {(stats?.summary.genomeCowCount || 0) > 0 && farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? (
- -

농가 데이터 없음

+ +

유전체 검사 데이터가 없습니다

)} {/* 순위 정보 (차트 하단에 통합) - 드롭다운 선택에 따라 연동 */} - {(farmPositionData.rank !== null || farmRanking?.farmAvgScore !== null) && ( + {(stats?.summary.genomeCowCount || 0) > 0 && (farmPositionData.rank !== null || farmRanking?.farmAvgScore !== null) && (
{/* 현재 선택된 기준 표시 */}

@@ -879,7 +971,14 @@ export default function DashboardPage() {

{/* 차트 */} - {traitTrendLoading ? ( + {(stats?.summary.genomeCowCount || 0) === 0 ? ( +
+
+ +

유전체 검사 데이터가 없습니다

+
+
+ ) : traitTrendLoading ? (
@@ -1061,7 +1160,7 @@ export default function DashboardPage() { {/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */}

보은군 대비 카테고리별 육종가 평균

- {stats?.traitAverages && stats.traitAverages.length > 0 ? ( + {(stats?.summary.genomeCowCount || 0) > 0 && stats?.traitAverages && stats.traitAverages.length > 0 ? ( (() => { const categories = ['성장', '생산', '체형', '무게', '비율'] const categoryData = categories.map(cat => { @@ -1372,15 +1471,20 @@ export default function DashboardPage() { ) })() ) : ( -
- 데이터 없음 +
+
+ +

유전체 검사 데이터가 없습니다

+
)} {/* 범례 */} + {(stats?.summary.genomeCowCount || 0) > 0 && (
우리농가 보은군평균
+ )}
diff --git a/frontend/src/constants/mpt-reference.ts b/frontend/src/constants/mpt-reference.ts index 3095ca0..c4ad08e 100644 --- a/frontend/src/constants/mpt-reference.ts +++ b/frontend/src/constants/mpt-reference.ts @@ -8,7 +8,7 @@ export interface MptReferenceRange { upperLimit: number | null; lowerLimit: number | null; unit: string; - category: '에너지' | '단백질' | '간기능' | '미네랄' | '기타'; + category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도'; description?: string; // 항목 설명 (선택) } @@ -38,6 +38,15 @@ export const MPT_REFERENCE_RANGES: Record = { category: '에너지', description: '혈액 내 유리지방산 수치', }, + bcs: { + name: 'BCS', + upperLimit: 3.5, + lowerLimit: 2.5, + unit: '-', + category: '에너지', + description: '체충실지수(Body Condition Score)', + }, + // 단백질 카테고리 totalProtein: { name: '총단백질', @@ -140,13 +149,13 @@ export const MPT_REFERENCE_RANGES: Record = { description: '혈액 내 마그네슘 수치', }, - // 기타 카테고리 + // 별도 카테고리 creatine: { name: '크레아틴', upperLimit: 1.3, lowerLimit: 1.0, unit: 'mg/dL', - category: '기타', + category: '별도', description: '혈액 내 크레아틴 수치', }, }; @@ -154,7 +163,7 @@ export const MPT_REFERENCE_RANGES: Record = { /** * MPT 카테고리 목록 (표시 순서) */ -export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '기타'] as const; +export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '별도'] as const; /** * 측정값이 정상 범위 내에 있는지 확인 diff --git a/frontend/src/lib/api/genome.api.ts b/frontend/src/lib/api/genome.api.ts index 9aff58c..208cb8b 100644 --- a/frontend/src/lib/api/genome.api.ts +++ b/frontend/src/lib/api/genome.api.ts @@ -318,7 +318,11 @@ export interface DashboardStatsDto { }[]; // 요약 summary: { - totalRequests: number; + totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외) + genomeCowCount: number; // 유전체 분석 개체 수 + geneCowCount: number; // 유전자검사 개체 수 + mptCowCount: number; // 번식능력검사 개체 수 + totalRequests: number; // 유전체 의뢰 건수 (기존 호환성) analyzedCount: number; pendingCount: number; mismatchCount: number; diff --git a/frontend/src/lib/api/mpt.api.ts b/frontend/src/lib/api/mpt.api.ts index 39ff8ae..249f7c5 100644 --- a/frontend/src/lib/api/mpt.api.ts +++ b/frontend/src/lib/api/mpt.api.ts @@ -1,5 +1,26 @@ import apiClient from "../api-client"; +/** + * MPT 통계 응답 DTO + */ +export interface MptStatisticsDto { + totalMptCows: number; + latestTestDate: string | 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'; + }>; +} + /** * MPT(혈액화학검사) 결과 DTO */ @@ -60,7 +81,7 @@ export const mptApi = { }, /** - * GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과 + * GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과 (뒤 4자리) */ findByCowShortNo: async (cowShortNo: string): Promise => { return await apiClient.get("/mpt", { @@ -68,6 +89,15 @@ export const mptApi = { }); }, + /** + * GET /mpt?cowId=:cowId - 특정 개체의 검사 결과 (전체 개체번호) + */ + findByCowId: async (cowId: string): Promise => { + return await apiClient.get("/mpt", { + params: { cowId }, + }); + }, + /** * GET /mpt/:id - 검사 결과 상세 조회 */ @@ -102,4 +132,13 @@ export const mptApi = { remove: async (id: number): Promise => { return await apiClient.delete(`/mpt/${id}`); }, + + /** + * GET /mpt/statistics/:farmNo - 농장별 MPT 통계 조회 + * - 카테고리별 정상/주의/위험 개체 수 + * - 위험 개체 목록 + */ + getMptStatistics: async (farmNo: number): Promise => { + return await apiClient.get(`/mpt/statistics/${farmNo}`); + }, }; diff --git a/frontend/src/lib/utils/genome-analysis-config.ts b/frontend/src/lib/utils/genome-analysis-config.ts index 34e8cf8..326635c 100644 --- a/frontend/src/lib/utils/genome-analysis-config.ts +++ b/frontend/src/lib/utils/genome-analysis-config.ts @@ -27,6 +27,7 @@ export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재']; /** 개별 제외 개체 목록 (분석불가 등 특수 사유) */ export const EXCLUDED_COW_IDS = [ 'KOR002191642861', // 모근량 갯수 적은데 1회분은 가능 아비명도 일치하는데 유전체 분석 정보가 없음 + // 김정태님 ]; /** diff --git a/frontend/src/types/cow.types.ts b/frontend/src/types/cow.types.ts index 2d27ebc..bcb62d5 100644 --- a/frontend/src/types/cow.types.ts +++ b/frontend/src/types/cow.types.ts @@ -20,6 +20,11 @@ export interface CowDto extends BaseFields { fkFarmNo?: number; // 농장번호 FK cowStatus?: string; // 개체상태 delDt?: string; // 삭제일시 (Soft Delete) + anlysDt?: string; // 분석일자 + unavailableReason?: string; // 분석불가 사유 + hasMpt?: boolean; // 번식능력검사(MPT) 여부 + mptTestDt?: string; // MPT 검사일 + mptMonthAge?: number; // MPT 검사일 기준 월령 // Relations farm?: FarmDto; // 농장 정보 (조인)