번식 능력 검사 리스트 및 보고서 수정
This commit is contained in:
@@ -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, // 필터 엔진 모듈
|
||||
],
|
||||
|
||||
@@ -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<GeneDetailModel>,
|
||||
|
||||
// 번식능력 Repository (MPT 데이터 접근용)
|
||||
@InjectRepository(MptModel)
|
||||
private readonly mptRepository: Repository<MptModel>,
|
||||
|
||||
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||
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<CowModel[]> {
|
||||
// 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<string, { testDt: string; monthAge: number }> }> {
|
||||
// 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<string, { testDt: string; monthAge: number }>(
|
||||
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<string, { testDt: string; monthAge: number }>,
|
||||
): Promise<any> {
|
||||
// 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, // 점수 계산에 사용된 형질별 상세
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<FarmModel>,
|
||||
|
||||
// 번식능력검사 Repository
|
||||
@InjectRepository(MptModel)
|
||||
private readonly mptRepository: Repository<MptModel>,
|
||||
|
||||
// 유전자검사 상세 Repository
|
||||
@InjectRepository(GeneDetailModel)
|
||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||
) { }
|
||||
|
||||
// ============================================
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, { upper: number; lower: number; category: string }> = {
|
||||
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<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { cowId: cowId, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<MptModel> {
|
||||
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<MptStatisticsDto> {
|
||||
// 농장의 모든 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<string, MptModel>();
|
||||
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<string, 'safe' | 'caution'> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user