미사용 파일정리
This commit is contained in:
@@ -7,16 +7,12 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
import { HelpModule } from './help/help.module';
|
||||
import { JwtModule } from './common/jwt/jwt.module';
|
||||
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
||||
|
||||
// 새로 생성한 모듈들
|
||||
import { FarmModule } from './farm/farm.module';
|
||||
import { CowModule } from './cow/cow.module';
|
||||
import { GenomeModule } from './genome/genome.module';
|
||||
import { MptModule } from './mpt/mpt.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { GeneModule } from './gene/gene.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
|
||||
@@ -56,10 +52,8 @@ import { SystemModule } from './system/system.module';
|
||||
GenomeModule,
|
||||
GeneModule,
|
||||
MptModule,
|
||||
DashboardModule,
|
||||
|
||||
|
||||
// 기타
|
||||
HelpModule,
|
||||
SystemModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -77,18 +77,12 @@ export class AuthService {
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload as any);
|
||||
const refreshOptions = {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET')!,
|
||||
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d',
|
||||
};
|
||||
const refreshToken = this.jwtService.sign(payload as any, refreshOptions as any);
|
||||
|
||||
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
|
||||
|
||||
return {
|
||||
message: '로그인 성공',
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
pkUserNo: user.pkUserNo,
|
||||
userId: user.userId,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
export class LoginResponseDto {
|
||||
message: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user: {
|
||||
pkUserNo: number;
|
||||
userId: string;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonController } from './common.controller';
|
||||
import { CommonService } from './common.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtStrategy } from './jwt/jwt.strategy';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
@@ -15,7 +13,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
CommonService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
HttpExceptionFilter,
|
||||
AllExceptionsFilter,
|
||||
LoggingInterceptor,
|
||||
TransformInterceptor,
|
||||
@@ -23,7 +20,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
exports: [
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
HttpExceptionFilter,
|
||||
AllExceptionsFilter,
|
||||
LoggingInterceptor,
|
||||
TransformInterceptor,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 근친도 관련 설정 상수
|
||||
*
|
||||
* @description
|
||||
* Wright's Coefficient of Inbreeding 알고리즘 기반 근친도 계산 및 위험도 판정 기준
|
||||
*
|
||||
* @source PRD 기능요구사항20.md SFR-COW-016-3
|
||||
* @reference Wright, S. (1922). Coefficients of Inbreeding and Relationship
|
||||
*/
|
||||
export const INBREEDING_CONFIG = {
|
||||
/**
|
||||
* 위험도 판정 기준 (%)
|
||||
* - 정상: < 15%
|
||||
* - 주의: 15-20%
|
||||
* - 위험: > 20%
|
||||
*/
|
||||
RISK_LEVELS: {
|
||||
NORMAL_MAX: 15, // 정상 상한선 (< 15%)
|
||||
WARNING_MIN: 15, // 주의 하한선 (>= 15%)
|
||||
WARNING_MAX: 20, // 주의 상한선 (<= 20%)
|
||||
DANGER_MIN: 20, // 위험 하한선 (> 20%)
|
||||
},
|
||||
|
||||
/**
|
||||
* 다세대 시뮬레이션 위험도 판정 기준 (%)
|
||||
* - 정상: < 6.25%
|
||||
* - 주의: 6.25% ~ 임계값
|
||||
* - 위험: > 임계값
|
||||
*/
|
||||
MULTI_GENERATION_RISK_LEVELS: {
|
||||
SAFE_MAX: 6.25, // 안전 상한선 (< 6.25%)
|
||||
// WARNING: 6.25% ~ inbreedingThreshold (사용자 지정)
|
||||
// DANGER: > inbreedingThreshold (사용자 지정)
|
||||
},
|
||||
|
||||
/**
|
||||
* 기본 근친도 임계값 (%)
|
||||
* Wright's Coefficient 기준 안전 임계값
|
||||
*/
|
||||
DEFAULT_THRESHOLD: 12.5,
|
||||
|
||||
/**
|
||||
* 세대별 근친도 영향 감소율
|
||||
* - 1세대: 100% 영향
|
||||
* - 2세대: 50% 영향 (1/2)
|
||||
* - 3세대: 25% 영향 (1/4)
|
||||
* - 4세대: 12.5% 영향 (1/8)
|
||||
*/
|
||||
GENERATION_DECAY: {
|
||||
GEN_1: 1.0, // 100%
|
||||
GEN_2: 0.5, // 50%
|
||||
GEN_3: 0.25, // 25%
|
||||
GEN_4: 0.125, // 12.5%
|
||||
GEN_5: 0.0625, // 6.25%
|
||||
},
|
||||
|
||||
/**
|
||||
* KPN 순환 전략 설정
|
||||
*/
|
||||
ROTATION_STRATEGY: {
|
||||
CYCLE_GENERATIONS: 2, // N세대마다 순환 (기본값: 2세대)
|
||||
},
|
||||
|
||||
/**
|
||||
* 유리형 비율 평가 기준 (%)
|
||||
*/
|
||||
FAVORABLE_RATE_THRESHOLDS: {
|
||||
EXCELLENT: 75, // 매우 우수 (>= 75%)
|
||||
GOOD: 60, // 양호 (>= 60%)
|
||||
AVERAGE: 50, // 보통 (>= 50%)
|
||||
POOR: 70, // 권장사항 생성 기준 (>= 70%)
|
||||
},
|
||||
} as const;
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* MPT 혈액대사검사 정상 범위 기준값
|
||||
*
|
||||
* @description
|
||||
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
|
||||
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
|
||||
*
|
||||
* @export
|
||||
* @constant
|
||||
*/
|
||||
export const MPT_NORMAL_RANGES = {
|
||||
// ========== 에너지 카테고리 ==========
|
||||
/**
|
||||
* 혈당 (Glucose)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
glucose: { min: 40, max: 84 },
|
||||
|
||||
/**
|
||||
* 콜레스테롤 (Cholesterol)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
cholesterol: { min: 74, max: 252 },
|
||||
|
||||
/**
|
||||
* 유리지방산 (NEFA)
|
||||
* 단위: μEq/L
|
||||
*/
|
||||
nefa: { min: 115, max: 660 },
|
||||
|
||||
// ========== 단백질 카테고리 ==========
|
||||
/**
|
||||
* 총단백질 (Total Protein)
|
||||
* 단위: g/dL
|
||||
*/
|
||||
totalProtein: { min: 6.2, max: 7.7 },
|
||||
|
||||
/**
|
||||
* 알부민 (Albumin)
|
||||
* 단위: g/dL
|
||||
*/
|
||||
albumin: { min: 3.3, max: 4.3 },
|
||||
|
||||
/**
|
||||
* 총 글로불린 (Total Globulin)
|
||||
* 단위: g/dL
|
||||
*/
|
||||
globulin: { min: 9.1, max: 36.1 },
|
||||
|
||||
/**
|
||||
* A/G 비율 (Albumin/Globulin Ratio)
|
||||
* 단위: 비율
|
||||
*/
|
||||
agRatio: { min: 0.1, max: 0.4 },
|
||||
|
||||
/**
|
||||
* 혈중요소질소 (Blood Urea Nitrogen)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
bun: { min: 11.7, max: 18.9 },
|
||||
|
||||
/**
|
||||
* AST (Aspartate Aminotransferase)
|
||||
* 단위: U/L
|
||||
*/
|
||||
ast: { min: 47, max: 92 },
|
||||
|
||||
/**
|
||||
* GGT (Gamma-Glutamyl Transferase)
|
||||
* 단위: U/L
|
||||
*/
|
||||
ggt: { min: 11, max: 32 },
|
||||
|
||||
/**
|
||||
* 지방간 지수 (Fatty Liver Index)
|
||||
* 단위: 지수
|
||||
*/
|
||||
fattyLiverIdx: { min: -1.2, max: 9.9 },
|
||||
|
||||
/**
|
||||
* 칼슘 (Calcium)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
calcium: { min: 8.1, max: 10.6 },
|
||||
|
||||
/**
|
||||
* 인 (Phosphorus)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
phosphorus: { min: 6.2, max: 8.9 },
|
||||
|
||||
/**
|
||||
* Ca/P 비율 (Calcium/Phosphorus Ratio)
|
||||
* 단위: 비율
|
||||
*/
|
||||
caPRatio: { min: 1.2, max: 1.3 },
|
||||
|
||||
/**
|
||||
* 마그네슘 (Magnesium)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
magnesium: { min: 1.6, max: 3.3 },
|
||||
|
||||
// ========== 기타 카테고리 ==========
|
||||
/**
|
||||
* 크레아틴 (Creatine)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
creatine: { min: 1.0, max: 1.3 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* MPT 항목 타입
|
||||
*/
|
||||
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
|
||||
|
||||
/**
|
||||
* MPT 범위 타입
|
||||
*/
|
||||
export interface MptRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 계정 상태 Enum
|
||||
export enum AccountStatusType {
|
||||
ACTIVE = "ACTIVE", // 정상
|
||||
INACTIVE = "INACTIVE", // 비활성
|
||||
SUSPENDED = "SUSPENDED", // 정지
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// 개체 타입 Enum
|
||||
export enum AnimalType {
|
||||
COW = 'COW', // 개체
|
||||
KPN = 'KPN', // KPN
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 분석 현황 상태 값 Enum
|
||||
*
|
||||
* @export
|
||||
* @enum {number}
|
||||
*/
|
||||
export enum AnlysStatType {
|
||||
MATCH = '친자일치',
|
||||
MISMATCH = '친자불일치',
|
||||
IMPOSSIBLE = '분석불가',
|
||||
NO_HISTORY = '이력제부재',
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 사육/도태 추천 타입 Enum
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
export enum BreedingRecommendationType {
|
||||
/** 사육 추천 */
|
||||
BREED = '사육추천',
|
||||
|
||||
/** 도태 추천 */
|
||||
CULL = '도태추천',
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// 개체 번식 타입 Enum
|
||||
export enum CowReproType {
|
||||
DONOR = "공란우",
|
||||
RECIPIENT = "수란우",
|
||||
AI = "인공수정",
|
||||
CULL = "도태대상",
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// 개체 상태 Enum
|
||||
export enum CowStatusType {
|
||||
NORMAL = "정상",
|
||||
DEAD = "폐사",
|
||||
SLAUGHTER = "도축",
|
||||
SALE = "매각",
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 파일 타입 Enum
|
||||
*
|
||||
* @description
|
||||
* 엑셀 업로드 시 지원되는 파일 유형을 정의합니다.
|
||||
* 각 파일 유형별로 고유한 파싱 로직과 대상 테이블이 매핑됩니다.
|
||||
*
|
||||
* @reference SFR-ADMIN-001 (기능요구사항20.md)
|
||||
*
|
||||
* 파일 유형별 매핑:
|
||||
* - COW: 개체(암소) 정보 → tb_cow
|
||||
* - GENE: 유전자(SNP) 데이터 → tb_snp_cow
|
||||
* - GENOME: 유전체(유전능력) 데이터 → tb_genome_cow
|
||||
* - MPT: 혈액대사검사(MPT) (1행: 카테고리, 2행: 항목명, 3행~: 데이터) → tb_repro_mpt, tb_repro_mpt_item
|
||||
* - FERTILITY: 수태율 데이터 → tb_fertility_rate
|
||||
* - KPN_GENE: KPN 유전자 데이터 → tb_kpn_snp
|
||||
* - KPN_GENOME: KPN 유전체 데이터 → tb_kpn_genome
|
||||
* - KPN_MPT: KPN 혈액대사검사 → tb_kpn_mpt
|
||||
* - REGION_COW: 지역 개체 정보 → tb_region_cow
|
||||
* - REGION_GENE: 지역 유전자 데이터 → tb_region_snp
|
||||
* - REGION_GENOME: 지역 유전체 데이터 → tb_region_genome
|
||||
* - REGION_MPT: 지역 혈액대사검사 → tb_region_mpt
|
||||
* - HELP: 도움말 데이터 (유전자/유전체/번식능력 설명) → tb_help_content
|
||||
* - MARKER: 마커(유전자) 정보 (마커명, 관련형질, 목표유전자형 등) → tb_marker
|
||||
* 목표유전자형(target_genotype): KPN 추천 시 각 유전자의 우량형 기준 (AA, GG, CC 등)
|
||||
*/
|
||||
export enum FileType {
|
||||
// 개체(암소) 데이터
|
||||
COW = '개체',
|
||||
|
||||
// 유전 데이터
|
||||
GENE = '유전자',
|
||||
GENOME = '유전체',
|
||||
|
||||
// 번식 데이터
|
||||
MPT = '혈액대사검사',
|
||||
FERTILITY = '수태율',
|
||||
|
||||
// KPN 데이터
|
||||
KPN_GENE = 'KPN유전자',
|
||||
KPN_GENOME = 'KPN유전체',
|
||||
KPN_MPT = 'KPN혈액대사검사',
|
||||
|
||||
// 지역 개체 데이터 (보은군 비교용)
|
||||
REGION_COW = '지역개체',
|
||||
REGION_GENE = '지역유전자',
|
||||
REGION_GENOME = '지역유전체',
|
||||
REGION_MPT = '지역혈액대사검사',
|
||||
|
||||
// 도움말 데이터
|
||||
HELP = '도움말',
|
||||
|
||||
// 마커(유전자) 정보
|
||||
MARKER = '마커정보',
|
||||
}
|
||||
262
backend/src/common/const/MptReference.ts
Normal file
262
backend/src/common/const/MptReference.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
||||
* 백엔드 중앙 관리 파일 - 프론트엔드에서 API로 조회
|
||||
*/
|
||||
|
||||
export interface MptReferenceRange {
|
||||
key: string;
|
||||
name: string; // 한글 표시명
|
||||
upperLimit: number | null;
|
||||
lowerLimit: number | null;
|
||||
unit: string;
|
||||
category: 'energy' | 'protein' | 'liver' | 'mineral' | 'etc';
|
||||
categoryName: string; // 카테고리 한글명
|
||||
description?: string; // 항목 설명 (선택)
|
||||
}
|
||||
|
||||
export interface MptCategory {
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 참조값 범위
|
||||
*/
|
||||
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
||||
// 에너지 카테고리
|
||||
glucose: {
|
||||
key: 'glucose',
|
||||
name: '혈당',
|
||||
upperLimit: 84,
|
||||
lowerLimit: 40,
|
||||
unit: 'mg/dL',
|
||||
category: 'energy',
|
||||
categoryName: '에너지 대사',
|
||||
description: '에너지 대사 상태 지표',
|
||||
},
|
||||
cholesterol: {
|
||||
key: 'cholesterol',
|
||||
name: '콜레스테롤',
|
||||
upperLimit: 252,
|
||||
lowerLimit: 74,
|
||||
unit: 'mg/dL',
|
||||
category: 'energy',
|
||||
categoryName: '에너지 대사',
|
||||
description: '혈액 내 콜레스테롤 수치',
|
||||
},
|
||||
nefa: {
|
||||
key: 'nefa',
|
||||
name: '유리지방산(NEFA)',
|
||||
upperLimit: 660,
|
||||
lowerLimit: 115,
|
||||
unit: 'μEq/L',
|
||||
category: 'energy',
|
||||
categoryName: '에너지 대사',
|
||||
description: '혈액 내 유리지방산 수치',
|
||||
},
|
||||
bcs: {
|
||||
key: 'bcs',
|
||||
name: 'BCS',
|
||||
upperLimit: 3.5,
|
||||
lowerLimit: 2.5,
|
||||
unit: '점',
|
||||
category: 'energy',
|
||||
categoryName: '에너지 대사',
|
||||
description: '체충실지수(Body Condition Score)',
|
||||
},
|
||||
|
||||
// 단백질 카테고리
|
||||
totalProtein: {
|
||||
key: 'totalProtein',
|
||||
name: '총단백질',
|
||||
upperLimit: 7.7,
|
||||
lowerLimit: 6.2,
|
||||
unit: 'g/dL',
|
||||
category: 'protein',
|
||||
categoryName: '단백질 대사',
|
||||
description: '혈액 내 총단백질 수치',
|
||||
},
|
||||
albumin: {
|
||||
key: 'albumin',
|
||||
name: '알부민',
|
||||
upperLimit: 4.3,
|
||||
lowerLimit: 3.3,
|
||||
unit: 'g/dL',
|
||||
category: 'protein',
|
||||
categoryName: '단백질 대사',
|
||||
description: '혈액 내 알부민 수치',
|
||||
},
|
||||
globulin: {
|
||||
key: 'globulin',
|
||||
name: '총글로불린',
|
||||
upperLimit: 36.1,
|
||||
lowerLimit: 9.1,
|
||||
unit: 'g/dL',
|
||||
category: 'protein',
|
||||
categoryName: '단백질 대사',
|
||||
description: '혈액 내 총글로불린 수치',
|
||||
},
|
||||
agRatio: {
|
||||
key: 'agRatio',
|
||||
name: 'A/G 비율',
|
||||
upperLimit: 0.4,
|
||||
lowerLimit: 0.1,
|
||||
unit: '',
|
||||
category: 'protein',
|
||||
categoryName: '단백질 대사',
|
||||
description: '알부민/글로불린 비율',
|
||||
},
|
||||
bun: {
|
||||
key: 'bun',
|
||||
name: '요소태질소(BUN)',
|
||||
upperLimit: 18.9,
|
||||
lowerLimit: 11.7,
|
||||
unit: 'mg/dL',
|
||||
category: 'protein',
|
||||
categoryName: '단백질 대사',
|
||||
description: '혈액 내 요소태질소 수치',
|
||||
},
|
||||
|
||||
// 간기능 카테고리
|
||||
ast: {
|
||||
key: 'ast',
|
||||
name: 'AST',
|
||||
upperLimit: 92,
|
||||
lowerLimit: 47,
|
||||
unit: 'U/L',
|
||||
category: 'liver',
|
||||
categoryName: '간기능',
|
||||
description: '혈액 내 AST 수치',
|
||||
},
|
||||
ggt: {
|
||||
key: 'ggt',
|
||||
name: 'GGT',
|
||||
upperLimit: 32,
|
||||
lowerLimit: 11,
|
||||
unit: 'U/L',
|
||||
category: 'liver',
|
||||
categoryName: '간기능',
|
||||
description: '혈액 내 GGT 수치',
|
||||
},
|
||||
fattyLiverIdx: {
|
||||
key: 'fattyLiverIdx',
|
||||
name: '지방간 지수',
|
||||
upperLimit: 9.9,
|
||||
lowerLimit: -1.2,
|
||||
unit: '',
|
||||
category: 'liver',
|
||||
categoryName: '간기능',
|
||||
description: '혈액 내 지방간 지수 수치',
|
||||
},
|
||||
|
||||
// 미네랄 카테고리
|
||||
calcium: {
|
||||
key: 'calcium',
|
||||
name: '칼슘',
|
||||
upperLimit: 10.6,
|
||||
lowerLimit: 8.1,
|
||||
unit: 'mg/dL',
|
||||
category: 'mineral',
|
||||
categoryName: '미네랄',
|
||||
description: '혈액 내 칼슘 수치',
|
||||
},
|
||||
phosphorus: {
|
||||
key: 'phosphorus',
|
||||
name: '인',
|
||||
upperLimit: 8.9,
|
||||
lowerLimit: 6.2,
|
||||
unit: 'mg/dL',
|
||||
category: 'mineral',
|
||||
categoryName: '미네랄',
|
||||
description: '혈액 내 인 수치',
|
||||
},
|
||||
caPRatio: {
|
||||
key: 'caPRatio',
|
||||
name: '칼슘/인 비율',
|
||||
upperLimit: 1.3,
|
||||
lowerLimit: 1.2,
|
||||
unit: '',
|
||||
category: 'mineral',
|
||||
categoryName: '미네랄',
|
||||
description: '혈액 내 칼슘/인 비율',
|
||||
},
|
||||
magnesium: {
|
||||
key: 'magnesium',
|
||||
name: '마그네슘',
|
||||
upperLimit: 3.3,
|
||||
lowerLimit: 1.6,
|
||||
unit: 'mg/dL',
|
||||
category: 'mineral',
|
||||
categoryName: '미네랄',
|
||||
description: '혈액 내 마그네슘 수치',
|
||||
},
|
||||
|
||||
// 별도 카테고리
|
||||
creatine: {
|
||||
key: 'creatine',
|
||||
name: '크레아틴',
|
||||
upperLimit: 1.3,
|
||||
lowerLimit: 1.0,
|
||||
unit: 'mg/dL',
|
||||
category: 'etc',
|
||||
categoryName: '기타',
|
||||
description: '혈액 내 크레아틴 수치',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* MPT 카테고리 목록
|
||||
*/
|
||||
export const MPT_CATEGORIES: MptCategory[] = [
|
||||
{
|
||||
key: 'energy',
|
||||
name: '에너지 대사',
|
||||
color: 'bg-orange-500',
|
||||
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
||||
},
|
||||
{
|
||||
key: 'protein',
|
||||
name: '단백질 대사',
|
||||
color: 'bg-blue-500',
|
||||
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
||||
},
|
||||
{
|
||||
key: 'liver',
|
||||
name: '간기능',
|
||||
color: 'bg-green-500',
|
||||
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
||||
},
|
||||
{
|
||||
key: 'mineral',
|
||||
name: '미네랄',
|
||||
color: 'bg-purple-500',
|
||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
|
||||
},
|
||||
{
|
||||
key: 'etc',
|
||||
name: '기타',
|
||||
color: 'bg-gray-500',
|
||||
items: ['creatine'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 측정값이 정상 범위 내에 있는지 확인
|
||||
*/
|
||||
export function checkMptStatus(
|
||||
value: number | null,
|
||||
itemKey: string,
|
||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||
if (value === null || value === undefined) return 'unknown';
|
||||
|
||||
const reference = MPT_REFERENCE_RANGES[itemKey];
|
||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (value > reference.upperLimit) return 'high';
|
||||
if (value < reference.lowerLimit) return 'low';
|
||||
return 'normal';
|
||||
}
|
||||
13
backend/src/common/const/RankingCriteriaType.ts
Normal file
13
backend/src/common/const/RankingCriteriaType.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 랭킹 기준 타입 Enum
|
||||
*
|
||||
* @description
|
||||
* 개체 목록 페이지에서 사용하는 랭킹 기준
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
export enum RankingCriteriaType {
|
||||
/** 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) */
|
||||
GENOME = 'GENOME',
|
||||
}
|
||||
109
backend/src/common/const/TraitTypes.ts
Normal file
109
backend/src/common/const/TraitTypes.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 형질(Trait) 관련 상수 정의
|
||||
*
|
||||
* @description
|
||||
* 유전체 분석에서 사용하는 35개 형질 목록
|
||||
*/
|
||||
|
||||
/** 성장형질 (1개) */
|
||||
export const GROWTH_TRAITS = ['12개월령체중'] as const;
|
||||
|
||||
/** 경제형질 (4개) - 생산 카테고리 */
|
||||
export const ECONOMIC_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const;
|
||||
|
||||
/** 체형형질 (10개) */
|
||||
export const BODY_TRAITS = [
|
||||
'체고', '십자', '체장', '흉심', '흉폭',
|
||||
'고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
] as const;
|
||||
|
||||
/** 부위별 무게 (10개) */
|
||||
export const WEIGHT_TRAITS = [
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
] as const;
|
||||
|
||||
/** 부위별 비율 (10개) */
|
||||
export const RATE_TRAITS = [
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
] as const;
|
||||
|
||||
/** 전체 형질 (35개) */
|
||||
export const ALL_TRAITS = [
|
||||
...GROWTH_TRAITS,
|
||||
...ECONOMIC_TRAITS,
|
||||
...BODY_TRAITS,
|
||||
...WEIGHT_TRAITS,
|
||||
...RATE_TRAITS,
|
||||
] as const;
|
||||
|
||||
/** 낮을수록 좋은 형질 (부호 반전 필요) */
|
||||
export const NEGATIVE_TRAITS: string[] = ['등지방두께'];
|
||||
|
||||
/** 형질 타입 */
|
||||
export type TraitName = typeof ALL_TRAITS[number];
|
||||
|
||||
/** 카테고리 타입 */
|
||||
export type TraitCategory = '성장' | '생산' | '체형' | '무게' | '비율';
|
||||
|
||||
/**
|
||||
* 형질별 카테고리 매핑
|
||||
* - 형질명 → 카테고리 조회용
|
||||
*/
|
||||
export const TRAIT_CATEGORY_MAP: Record<string, TraitCategory> = {
|
||||
// 성장 카테고리 - 월령별 체중
|
||||
'12개월령체중': '성장',
|
||||
|
||||
// 생산 카테고리 - 도체(도축 후 고기) 품질
|
||||
'도체중': '생산',
|
||||
'등심단면적': '생산',
|
||||
'등지방두께': '생산',
|
||||
'근내지방도': '생산',
|
||||
|
||||
// 체형 카테고리 - 신체 구조
|
||||
'체고': '체형',
|
||||
'십자': '체형',
|
||||
'체장': '체형',
|
||||
'흉심': '체형',
|
||||
'흉폭': '체형',
|
||||
'고장': '체형',
|
||||
'요각폭': '체형',
|
||||
'좌골폭': '체형',
|
||||
'곤폭': '체형',
|
||||
'흉위': '체형',
|
||||
|
||||
// 무게 카테고리 - 부위별 실제 무게 (kg)
|
||||
'안심weight': '무게',
|
||||
'등심weight': '무게',
|
||||
'채끝weight': '무게',
|
||||
'목심weight': '무게',
|
||||
'앞다리weight': '무게',
|
||||
'우둔weight': '무게',
|
||||
'설도weight': '무게',
|
||||
'사태weight': '무게',
|
||||
'양지weight': '무게',
|
||||
'갈비weight': '무게',
|
||||
|
||||
// 비율 카테고리 - 부위별 비율 (%)
|
||||
'안심rate': '비율',
|
||||
'등심rate': '비율',
|
||||
'채끝rate': '비율',
|
||||
'목심rate': '비율',
|
||||
'앞다리rate': '비율',
|
||||
'우둔rate': '비율',
|
||||
'설도rate': '비율',
|
||||
'사태rate': '비율',
|
||||
'양지rate': '비율',
|
||||
'갈비rate': '비율',
|
||||
};
|
||||
|
||||
/**
|
||||
* 형질명으로 카테고리 조회
|
||||
*
|
||||
* @param traitName - 형질명
|
||||
* @returns 카테고리명 (없으면 '기타')
|
||||
*/
|
||||
export function getTraitCategory(traitName: string): string {
|
||||
return TRAIT_CATEGORY_MAP[traitName] ?? '기타';
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자 정보를 가져오는 Decorator
|
||||
*
|
||||
* @description
|
||||
* 인증 미들웨어(JWT, Passport 등)가 req.user에 추가한 사용자 정보를 추출합니다.
|
||||
* 인증되지 않은 경우 기본값을 반환합니다.
|
||||
*
|
||||
* @example
|
||||
* // 전체 user 객체 가져오기
|
||||
* async method(@CurrentUser() user: any) {
|
||||
* console.log(user.userId, user.email);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 특정 속성만 가져오기
|
||||
* async method(@CurrentUser('userId') userId: string) {
|
||||
* console.log(userId); // 'user123' or 'system'
|
||||
* }
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 사용자 정보가 없으면 기본값 반환
|
||||
if (!user) {
|
||||
// userId를 요청한 경우 'system' 반환
|
||||
if (data === 'userId') {
|
||||
return 'system';
|
||||
}
|
||||
// 전체 user 객체를 요청한 경우 null 반환
|
||||
return null;
|
||||
}
|
||||
|
||||
// 특정 속성을 요청한 경우 해당 속성 반환
|
||||
// 전체 user 객체를 요청한 경우 user 반환
|
||||
return data ? user[data] : user;
|
||||
},
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* User 데코레이터
|
||||
*
|
||||
* @description
|
||||
* JWT 인증 후 Request 객체에서 사용자 정보를 추출하는 데코레이터입니다.
|
||||
* @Req() req 대신 사용하여 더 간결하게 사용자 정보를 가져올 수 있습니다.
|
||||
*
|
||||
* @example
|
||||
* // 전체 사용자 정보 가져오기
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getProfile(@User() user: any) {
|
||||
* return user; // { userId: '...', userNo: 1, role: 'user' }
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 특정 필드만 가져오기
|
||||
* @Get('my-data')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getMyData(@User('userId') userId: string) {
|
||||
* return `Your ID is ${userId}`;
|
||||
* }
|
||||
*
|
||||
* @export
|
||||
* @constant User
|
||||
*/
|
||||
export const User = createParamDecorator(
|
||||
(data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 특정 필드만 반환
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* HTTP 예외 필터
|
||||
*
|
||||
* @description
|
||||
* 모든 HTTP 예외를 잡아서 일관된 형식으로 응답을 반환합니다.
|
||||
*
|
||||
* @example
|
||||
* // main.ts에서 전역 적용
|
||||
* app.useGlobalFilters(new HttpExceptionFilter());
|
||||
*
|
||||
* @export
|
||||
* @class HttpExceptionFilter
|
||||
* @implements {ExceptionFilter}
|
||||
*/
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
// 에러 메시지 추출
|
||||
let message: string | string[];
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
message = (exceptionResponse as any).message || exception.message;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// 일관된 에러 응답 형식
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
error: this.getErrorName(status),
|
||||
};
|
||||
|
||||
// 개발 환경에서는 스택 트레이스 포함
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(errorResponse as any).stack = exception.stack;
|
||||
}
|
||||
|
||||
// 로깅
|
||||
console.error(
|
||||
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
||||
message,
|
||||
);
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 상태 코드에 따른 에러 이름 반환
|
||||
*
|
||||
* @private
|
||||
* @param {number} status - HTTP 상태 코드
|
||||
* @returns {string}
|
||||
*/
|
||||
private getErrorName(status: number): string {
|
||||
switch (status) {
|
||||
case HttpStatus.BAD_REQUEST:
|
||||
return 'Bad Request';
|
||||
case HttpStatus.UNAUTHORIZED:
|
||||
return 'Unauthorized';
|
||||
case HttpStatus.FORBIDDEN:
|
||||
return 'Forbidden';
|
||||
case HttpStatus.NOT_FOUND:
|
||||
return 'Not Found';
|
||||
case HttpStatus.CONFLICT:
|
||||
return 'Conflict';
|
||||
case HttpStatus.INTERNAL_SERVER_ERROR:
|
||||
return 'Internal Server Error';
|
||||
default:
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* 클라이언트의 실제 IP 주소를 추출합니다.
|
||||
*
|
||||
* @description
|
||||
* Proxy, Load Balancer, CDN 뒤에 있어도 실제 클라이언트 IP를 정확하게 가져옵니다.
|
||||
* 다음 순서로 IP를 확인합니다:
|
||||
* 1. X-Forwarded-For 헤더 (Proxy/Load Balancer)
|
||||
* 2. X-Real-IP 헤더 (Nginx)
|
||||
* 3. req.ip (Express 기본)
|
||||
* 4. req.socket.remoteAddress (직접 연결)
|
||||
* 5. 'unknown' (IP를 찾을 수 없는 경우)
|
||||
*
|
||||
* @param req - Express Request 객체
|
||||
* @returns 클라이언트 IP 주소
|
||||
*
|
||||
* @example
|
||||
* const ip = getClientIp(req);
|
||||
* console.log(ip); // '203.123.45.67' or 'unknown'
|
||||
*/
|
||||
export function getClientIp(req: Request): string {
|
||||
// 1. X-Forwarded-For 헤더 확인 (Proxy/Load Balancer 환경)
|
||||
// 형식: "client IP, proxy1 IP, proxy2 IP"
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// 배열이면 첫 번째 요소, 문자열이면 콤마로 split
|
||||
const ips = Array.isArray(forwardedFor)
|
||||
? forwardedFor[0]
|
||||
: forwardedFor.split(',')[0];
|
||||
return ips.trim();
|
||||
}
|
||||
|
||||
// 2. X-Real-IP 헤더 확인 (Nginx 환경)
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
if (realIp && typeof realIp === 'string') {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
// 3. Express가 제공하는 req.ip
|
||||
if (req.ip) {
|
||||
return req.ip;
|
||||
}
|
||||
|
||||
// 4. Socket의 remoteAddress
|
||||
if (req.socket?.remoteAddress) {
|
||||
return req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
// 5. IP를 찾을 수 없는 경우
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -3,37 +3,22 @@
|
||||
* 개체(Cow) 컨트롤러
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
||||
*
|
||||
* 엔드포인트:
|
||||
* - GET /cow - 기본 개체 목록 조회
|
||||
* - GET /cow/:id - 개체 상세 조회
|
||||
* - GET /cow/:cowId - 개체 상세 조회
|
||||
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
||||
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||
import { CowService } from './cow.service';
|
||||
import { CowModel } from './entities/cow.entity';
|
||||
import { RankingRequestDto } from './dto/ranking-request.dto';
|
||||
|
||||
@Controller('cow')
|
||||
export class CowController {
|
||||
constructor(private readonly cowService: CowService) {}
|
||||
|
||||
/**
|
||||
* GET /cow
|
||||
* 기본 개체 목록 조회
|
||||
*/
|
||||
@Get()
|
||||
findAll(@Query('farmId') farmId?: string) {
|
||||
if (farmId) {
|
||||
return this.cowService.findByFarmId(+farmId);
|
||||
}
|
||||
return this.cowService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cow/ranking
|
||||
* 랭킹이 적용된 개체 목록 조회
|
||||
@@ -45,25 +30,6 @@ export class CowController {
|
||||
return this.cowService.findAllWithRanking(rankingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cow/ranking/global
|
||||
* 전체 개체 랭킹 조회 (모든 농장 포함)
|
||||
*
|
||||
* 사용 페이지: 대시보드 (농장 순위 비교)
|
||||
*/
|
||||
@Post('ranking/global')
|
||||
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||
// farmNo 필터 없이 전체 개체 랭킹 조회
|
||||
const globalRequest = {
|
||||
...rankingRequest,
|
||||
filterOptions: {
|
||||
...rankingRequest.filterOptions,
|
||||
farmNo: undefined,
|
||||
},
|
||||
};
|
||||
return this.cowService.findAllWithRanking(globalRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cow/:cowId
|
||||
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
||||
@@ -72,19 +38,4 @@ export class CowController {
|
||||
findOne(@Param('cowId') cowId: string) {
|
||||
return this.cowService.findByCowId(cowId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<CowModel>) {
|
||||
return this.cowService.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
|
||||
return this.cowService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.cowService.remove(+id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
* 개체(Cow) 서비스
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
||||
*
|
||||
* 주요 기능:
|
||||
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
||||
* 2. 개체 단건 조회 (findOne, findByCowId)
|
||||
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||
* 1. 개체 단건 조회 (findByCowId)
|
||||
* 2. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||
* - GENOME: 35개 형질 EBV 가중 평균
|
||||
* 4. 개체 CRUD (create, update, remove)
|
||||
* ============================================================
|
||||
*/
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
@@ -25,16 +23,10 @@ import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||
import {
|
||||
RankingRequestDto,
|
||||
RankingCriteriaType,
|
||||
TraitRankingCondition,
|
||||
TraitRankingConditionDto,
|
||||
} from './dto/ranking-request.dto';
|
||||
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
/**
|
||||
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
||||
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
||||
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
||||
*/
|
||||
const NEGATIVE_TRAITS = ['등지방두께'];
|
||||
import { ALL_TRAITS, NEGATIVE_TRAITS } from '../common/const/TraitTypes';
|
||||
|
||||
/**
|
||||
* 개체(소) 관리 서비스
|
||||
@@ -72,56 +64,9 @@ export class CowService {
|
||||
) { }
|
||||
|
||||
// ============================================================
|
||||
// 기본 조회 메서드
|
||||
// 개체 조회 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 전체 개체 목록 조회
|
||||
*
|
||||
* @returns 삭제되지 않은 모든 개체 목록
|
||||
* - farm 관계 데이터 포함
|
||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||
*/
|
||||
async findAll(): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||
relations: ['farm'], // 농장 정보 JOIN
|
||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장별 개체 목록 조회
|
||||
*
|
||||
* @param farmNo - 농장 PK 번호
|
||||
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
||||
*/
|
||||
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 PK로 단건 조회
|
||||
*
|
||||
* @param id - 개체 PK 번호 (pkCowNo)
|
||||
* @returns 개체 정보 (farm 포함)
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async findOne(id: number): Promise<CowModel> {
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { pkCowNo: id, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!cow) {
|
||||
throw new NotFoundException(`Cow #${id} not found`);
|
||||
}
|
||||
return cow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체식별번호(cowId)로 단건 조회
|
||||
*
|
||||
@@ -187,6 +132,8 @@ export class CowService {
|
||||
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||
switch (criteriaType) {
|
||||
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||
// 지금은 유전체 형질만 기반으로 랭킹을 매기고 있음 추후 유전자와 유전체 복합 랭킹 변경될수있음
|
||||
// case 추가 예정
|
||||
case RankingCriteriaType.GENOME:
|
||||
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
|
||||
|
||||
@@ -331,20 +278,9 @@ export class CowService {
|
||||
*/
|
||||
private async applyGenomeRanking(
|
||||
cows: CowModel[],
|
||||
inputTraitConditions: TraitRankingCondition[],
|
||||
inputTraitConditions: TraitRankingConditionDto[],
|
||||
mptCowIdMap: Map<string, { testDt: string; monthAge: number }>,
|
||||
): Promise<any> {
|
||||
// 35개 전체 형질 (기본값)
|
||||
const ALL_TRAITS = [
|
||||
'12개월령체중',
|
||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
];
|
||||
|
||||
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
|
||||
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
||||
? inputTraitConditions
|
||||
@@ -356,7 +292,9 @@ export class CowService {
|
||||
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||||
order: {
|
||||
requestDt: 'DESC',
|
||||
regDt: 'DESC' },
|
||||
});
|
||||
|
||||
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
||||
@@ -418,7 +356,7 @@ export class CowService {
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: 가중 합계 계산
|
||||
// Step 4: 가중 합계 계산 ====================================================
|
||||
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||
let totalWeight = 0; // 총 가중치
|
||||
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||
@@ -457,7 +395,7 @@ export class CowService {
|
||||
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
|
||||
: null;
|
||||
|
||||
// Step 7: 응답 데이터 구성
|
||||
// Step 7: 응답 데이터 구성 (반환 값)
|
||||
const mptData = mptCowIdMap.get(cow.cowId);
|
||||
return {
|
||||
entity: {
|
||||
@@ -526,45 +464,4 @@ export class CowService {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRUD 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 새로운 개체 생성
|
||||
*
|
||||
* @param data - 생성할 개체 데이터
|
||||
* @returns 생성된 개체 엔티티
|
||||
*/
|
||||
async create(data: Partial<CowModel>): Promise<CowModel> {
|
||||
const cow = this.cowRepository.create(data);
|
||||
return this.cowRepository.save(cow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 정보 수정
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @param data - 수정할 데이터
|
||||
* @returns 수정된 개체 엔티티
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
||||
await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.update(id, data);
|
||||
return this.findOne(id); // 수정된 데이터 반환
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 삭제 (Soft Delete)
|
||||
*
|
||||
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const cow = await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.softRemove(cow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,69 +12,30 @@
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* 랭킹 기준 타입
|
||||
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||
*/
|
||||
export enum RankingCriteriaType {
|
||||
GENOME = 'GENOME',
|
||||
}
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsString,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
FilterCondition,
|
||||
SortOption,
|
||||
PaginationOption,
|
||||
FilterEngineOptions,
|
||||
} from '../../shared/filter/interfaces/filter.interface';
|
||||
import { RankingCriteriaType } from '../../common/const/RankingCriteriaType';
|
||||
|
||||
// Re-export for convenience
|
||||
export { RankingCriteriaType };
|
||||
|
||||
// ============================================================
|
||||
// 필터 관련 타입 (FilterEngine에서 사용)
|
||||
// ============================================================
|
||||
|
||||
export type FilterOperator =
|
||||
| 'eq' // 같음
|
||||
| 'ne' // 같지 않음
|
||||
| 'gt' // 초과
|
||||
| 'gte' // 이상
|
||||
| 'lt' // 미만
|
||||
| 'lte' // 이하
|
||||
| 'like' // 포함 (문자열)
|
||||
| 'in' // 배열 내 포함
|
||||
| 'between'; // 범위
|
||||
|
||||
export type SortOrder = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* 필터 조건
|
||||
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 옵션
|
||||
*/
|
||||
export interface SortOption {
|
||||
field: string;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 옵션
|
||||
*/
|
||||
export interface PaginationOption {
|
||||
page: number; // 페이지 번호 (1부터 시작)
|
||||
limit: number; // 페이지당 개수
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 엔진 옵션
|
||||
* - 개체 목록 필터링에 사용
|
||||
*/
|
||||
export interface FilterEngineOptions {
|
||||
filters?: FilterCondition[];
|
||||
sorts?: SortOption[];
|
||||
pagination?: PaginationOption;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 랭킹 조건 타입
|
||||
// 랭킹 조건 DTO
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
@@ -84,21 +45,62 @@ export interface FilterEngineOptions {
|
||||
*
|
||||
* 예: { traitNm: '도체중', weight: 8 }
|
||||
*/
|
||||
export interface TraitRankingCondition {
|
||||
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||
export class TraitRankingConditionDto {
|
||||
@IsString()
|
||||
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 옵션
|
||||
* 랭킹 옵션 DTO
|
||||
*/
|
||||
export interface RankingOptions {
|
||||
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
|
||||
export class RankingOptionsDto {
|
||||
@IsEnum(RankingCriteriaType)
|
||||
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TraitRankingConditionDto)
|
||||
traitConditions?: TraitRankingConditionDto[]; // GENOME용: 형질별 가중치
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 필터 옵션 DTO (FilterEngine용)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 필터 엔진 옵션 DTO
|
||||
* - 개체 목록 필터링에 사용
|
||||
*/
|
||||
export class FilterEngineOptionsDto implements FilterEngineOptions {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
filters?: FilterCondition[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
sorts?: SortOption[];
|
||||
|
||||
@IsOptional()
|
||||
pagination?: PaginationOption;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 요청 DTO
|
||||
// ============================================================
|
||||
@@ -123,7 +125,13 @@ export interface RankingOptions {
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface RankingRequestDto {
|
||||
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
|
||||
rankingOptions: RankingOptions; // 랭킹 조건
|
||||
export class RankingRequestDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => FilterEngineOptionsDto)
|
||||
filterOptions?: FilterEngineOptionsDto; // 필터/정렬/페이지네이션
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => RankingOptionsDto)
|
||||
rankingOptions: RankingOptionsDto; // 랭킹 조건
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
/**
|
||||
* GET /dashboard/summary/:farmNo - 농장 현황 요약
|
||||
*/
|
||||
@Get('summary/:farmNo')
|
||||
getFarmSummary(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getFarmSummary(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-completion/:farmNo - 분석 완료 현황
|
||||
*/
|
||||
@Get('analysis-completion/:farmNo')
|
||||
getAnalysisCompletion(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getAnalysisCompletion(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/evaluation/:farmNo - 농장 종합 평가
|
||||
*/
|
||||
@Get('evaluation/:farmNo')
|
||||
getFarmEvaluation(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getFarmEvaluation(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/region-comparison/:farmNo - 보은군 비교 분석
|
||||
*/
|
||||
@Get('region-comparison/:farmNo')
|
||||
getRegionComparison(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getRegionComparison(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cow-distribution/:farmNo - 개체 분포 분석
|
||||
*/
|
||||
@Get('cow-distribution/:farmNo')
|
||||
getCowDistribution(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCowDistribution(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/kpn-aggregation/:farmNo - KPN 추천 집계
|
||||
*/
|
||||
@Get('kpn-aggregation/:farmNo')
|
||||
getKpnRecommendationAggregation(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getKpnRecommendationAggregation(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/farm-kpn-inventory/:farmNo - 농장 보유 KPN 목록
|
||||
*/
|
||||
@Get('farm-kpn-inventory/:farmNo')
|
||||
getFarmKpnInventory(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getFarmKpnInventory(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo - 농장 분석 이력 연도 목록
|
||||
*/
|
||||
@Get('analysis-years/:farmNo')
|
||||
getAnalysisYears(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getAnalysisYears(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo/latest - 최신 분석 연도
|
||||
*/
|
||||
@Get('analysis-years/:farmNo/latest')
|
||||
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getLatestAnalysisYear(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/year-comparison/:farmNo - 3개년 비교 분석
|
||||
*/
|
||||
@Get('year-comparison/:farmNo')
|
||||
getYearComparison(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getYearComparison(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/repro-efficiency/:farmNo - 번식 효율성 분석
|
||||
*/
|
||||
@Get('repro-efficiency/:farmNo')
|
||||
getReproEfficiency(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getReproEfficiency(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/excellent-cows/:farmNo - 우수개체 추천
|
||||
*/
|
||||
@Get('excellent-cows/:farmNo')
|
||||
getExcellentCows(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getExcellentCows(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cull-cows/:farmNo - 도태개체 추천
|
||||
*/
|
||||
@Get('cull-cows/:farmNo')
|
||||
getCullCows(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCullCows(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cattle-ranking/:farmNo - 보은군 내 소 개별 순위
|
||||
*/
|
||||
@Get('cattle-ranking/:farmNo')
|
||||
getCattleRankingInRegion(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCattleRankingInRegion(+farmNo, filter);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CowModel,
|
||||
FarmModel,
|
||||
GenomeRequestModel,
|
||||
GenomeTraitDetailModel,
|
||||
]),
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -1,548 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
|
||||
@InjectRepository(FarmModel)
|
||||
private readonly farmRepository: Repository<FarmModel>,
|
||||
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
@InjectRepository(GenomeTraitDetailModel)
|
||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 농장 현황 요약
|
||||
*/
|
||||
async getFarmSummary(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 농장 정보 조회
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 농장 소 목록 조회
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const totalCowCount = cows.length;
|
||||
const maleCowCount = cows.filter(cow => cow.cowSex === 'M').length;
|
||||
const femaleCowCount = cows.filter(cow => cow.cowSex === 'F').length;
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmName: farm?.farmerName || '농장',
|
||||
totalCowCount,
|
||||
maleCowCount,
|
||||
femaleCowCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 완료 현황
|
||||
*/
|
||||
async getAnalysisCompletion(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 농장의 모든 유전체 분석 의뢰 조회
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
});
|
||||
|
||||
const farmAnlysCnt = requests.length;
|
||||
const matchCnt = requests.filter(r => r.chipSireName === '일치').length;
|
||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||
|
||||
return {
|
||||
farmAnlysCnt,
|
||||
matchCnt,
|
||||
failCnt,
|
||||
noHistCnt,
|
||||
paternities: requests.map(r => ({
|
||||
cowNo: r.fkCowNo,
|
||||
cowId: r.cow?.cowId,
|
||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||
requestDt: r.requestDt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 종합 평가
|
||||
*/
|
||||
async getFarmEvaluation(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 각 개체의 유전체 점수 계산
|
||||
const scores: number[] = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
// 모든 형질의 EBV 평균 계산
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
scores.push(avgEbv);
|
||||
}
|
||||
}
|
||||
|
||||
const farmAverage = scores.length > 0
|
||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||
: 0;
|
||||
|
||||
// 등급 산정 (표준화육종가 기준)
|
||||
let grade = 'C';
|
||||
if (farmAverage >= 1.0) grade = 'A';
|
||||
else if (farmAverage >= 0.5) grade = 'B';
|
||||
else if (farmAverage >= -0.5) grade = 'C';
|
||||
else if (farmAverage >= -1.0) grade = 'D';
|
||||
else grade = 'E';
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmAverage: Math.round(farmAverage * 100) / 100,
|
||||
grade,
|
||||
analyzedCount: scores.length,
|
||||
totalCount: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보은군 비교 분석
|
||||
*/
|
||||
async getRegionComparison(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 내 농장 평균 계산
|
||||
const farmEval = await this.getFarmEvaluation(farmNo, filter);
|
||||
|
||||
// 전체 농장 평균 계산 (보은군 대비)
|
||||
const allFarms = await this.farmRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
});
|
||||
|
||||
const farmScores: { farmNo: number; avgScore: number }[] = [];
|
||||
|
||||
for (const farm of allFarms) {
|
||||
const farmCows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farm.pkFarmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const scores: number[] = [];
|
||||
for (const cow of farmCows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
scores.push(avgEbv);
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length > 0) {
|
||||
farmScores.push({
|
||||
farmNo: farm.pkFarmNo,
|
||||
avgScore: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 내 농장 순위 계산
|
||||
farmScores.sort((a, b) => b.avgScore - a.avgScore);
|
||||
const myFarmRank = farmScores.findIndex(f => f.farmNo === farmNo) + 1;
|
||||
const totalFarmCount = farmScores.length;
|
||||
const topPercent = totalFarmCount > 0 ? Math.round((myFarmRank / totalFarmCount) * 100) : 0;
|
||||
|
||||
// 지역 평균
|
||||
const regionAverage = farmScores.length > 0
|
||||
? farmScores.reduce((sum, f) => sum + f.avgScore, 0) / farmScores.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmAverage: farmEval.farmAverage,
|
||||
regionAverage: Math.round(regionAverage * 100) / 100,
|
||||
farmRank: myFarmRank || 1,
|
||||
totalFarmCount: totalFarmCount || 1,
|
||||
topPercent: topPercent || 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 분포 분석
|
||||
*/
|
||||
async getCowDistribution(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const distribution = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
C: 0,
|
||||
D: 0,
|
||||
E: 0,
|
||||
};
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
|
||||
if (avgEbv >= 1.0) distribution.A++;
|
||||
else if (avgEbv >= 0.5) distribution.B++;
|
||||
else if (avgEbv >= -0.5) distribution.C++;
|
||||
else if (avgEbv >= -1.0) distribution.D++;
|
||||
else distribution.E++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
distribution,
|
||||
total: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KPN 추천 집계
|
||||
*/
|
||||
async getKpnRecommendationAggregation(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 타겟 유전자 기반 KPN 추천 로직
|
||||
const targetGenes = filter?.targetGenes || [];
|
||||
|
||||
// 농장 소 목록 조회
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 간단한 KPN 추천 집계 (실제 로직은 더 복잡할 수 있음)
|
||||
const kpnAggregations = [
|
||||
{
|
||||
kpnNumber: 'KPN001',
|
||||
kpnName: '한우왕',
|
||||
avgMatchingScore: 85.5,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.3),
|
||||
percentage: 30,
|
||||
rank: 1,
|
||||
isOwned: false,
|
||||
sampleCowIds: cows.slice(0, 3).map(c => c.cowId),
|
||||
},
|
||||
{
|
||||
kpnNumber: 'KPN002',
|
||||
kpnName: '육량대왕',
|
||||
avgMatchingScore: 82.3,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.25),
|
||||
percentage: 25,
|
||||
rank: 2,
|
||||
isOwned: true,
|
||||
sampleCowIds: cows.slice(3, 6).map(c => c.cowId),
|
||||
},
|
||||
{
|
||||
kpnNumber: 'KPN003',
|
||||
kpnName: '품질명가',
|
||||
avgMatchingScore: 79.1,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.2),
|
||||
percentage: 20,
|
||||
rank: 3,
|
||||
isOwned: false,
|
||||
sampleCowIds: cows.slice(6, 9).map(c => c.cowId),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
targetGenes,
|
||||
kpnAggregations,
|
||||
totalCows: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 보유 KPN 목록
|
||||
*/
|
||||
async getFarmKpnInventory(farmNo: number) {
|
||||
// 실제 구현에서는 별도의 KPN 보유 테이블을 조회
|
||||
return {
|
||||
farmNo,
|
||||
kpnList: [
|
||||
{ kpnNumber: 'KPN002', kpnName: '육량대왕', stockCount: 10 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 이력 연도 목록
|
||||
*/
|
||||
async getAnalysisYears(farmNo: number): Promise<number[]> {
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
select: ['requestDt'],
|
||||
});
|
||||
|
||||
const years = new Set<number>();
|
||||
for (const req of requests) {
|
||||
if (req.requestDt) {
|
||||
years.add(new Date(req.requestDt).getFullYear());
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 분석 연도
|
||||
*/
|
||||
async getLatestAnalysisYear(farmNo: number): Promise<number> {
|
||||
const years = await this.getAnalysisYears(farmNo);
|
||||
return years[0] || new Date().getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3개년 비교 분석
|
||||
*/
|
||||
async getYearComparison(farmNo: number) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [currentYear, currentYear - 1, currentYear - 2];
|
||||
|
||||
const comparison = [];
|
||||
for (const year of years) {
|
||||
// 해당 연도의 분석 데이터 집계
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const yearRequests = requests.filter(r => {
|
||||
if (!r.requestDt) return false;
|
||||
return new Date(r.requestDt).getFullYear() === year;
|
||||
});
|
||||
|
||||
comparison.push({
|
||||
year,
|
||||
analysisCount: yearRequests.length,
|
||||
matchCount: yearRequests.filter(r => r.chipSireName === '일치').length,
|
||||
});
|
||||
}
|
||||
|
||||
return { farmNo, comparison };
|
||||
}
|
||||
|
||||
/**
|
||||
* 번식 효율성 분석 (더미 데이터)
|
||||
*/
|
||||
async getReproEfficiency(farmNo: number, filter?: DashboardFilterDto) {
|
||||
return {
|
||||
farmNo,
|
||||
avgCalvingInterval: 12.5,
|
||||
avgFirstCalvingAge: 24,
|
||||
conceptionRate: 65.5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 우수개체 추천
|
||||
*/
|
||||
async getExcellentCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const limit = filter?.limit || 5;
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({ cow, score: avgEbv });
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 내림차순 정렬
|
||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
excellentCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||
rank: index + 1,
|
||||
cowNo: item.cow.pkCowNo,
|
||||
cowId: item.cow.cowId,
|
||||
score: Math.round(item.score * 100) / 100,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 도태개체 추천
|
||||
*/
|
||||
async getCullCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const limit = filter?.limit || 5;
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({ cow, score: avgEbv });
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 오름차순 정렬 (낮은 점수가 도태 대상)
|
||||
cowsWithScore.sort((a, b) => a.score - b.score);
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
cullCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||
rank: index + 1,
|
||||
cowNo: item.cow.pkCowNo,
|
||||
cowId: item.cow.cowId,
|
||||
score: Math.round(item.score * 100) / 100,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보은군 내 소 개별 순위
|
||||
*/
|
||||
async getCattleRankingInRegion(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 전체 소 목록과 점수 계산
|
||||
const allCows = await this.cowRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{
|
||||
cow: CowModel;
|
||||
score: number;
|
||||
farmNo: number;
|
||||
}> = [];
|
||||
|
||||
for (const cow of allCows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({
|
||||
cow,
|
||||
score: avgEbv,
|
||||
farmNo: cow.fkFarmNo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 내림차순 정렬
|
||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 순위 부여
|
||||
const rankedCows = cowsWithScore.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
percentile: Math.round(((index + 1) / cowsWithScore.length) * 100),
|
||||
}));
|
||||
|
||||
// 내 농장 소만 필터링
|
||||
const myFarmCows = rankedCows.filter(item => item.farmNo === farmNo);
|
||||
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmName: farm?.farmerName || '농장',
|
||||
regionName: farm?.regionSi || '보은군',
|
||||
totalCattle: cowsWithScore.length,
|
||||
farmCattleCount: myFarmCows.length,
|
||||
rankings: myFarmCows.map(item => ({
|
||||
cowNo: item.cow.cowId,
|
||||
cowName: `KOR ${item.cow.cowId}`,
|
||||
genomeScore: Math.round(item.score * 100) / 100,
|
||||
rank: item.rank,
|
||||
totalCattle: cowsWithScore.length,
|
||||
percentile: item.percentile,
|
||||
})),
|
||||
statistics: {
|
||||
bestRank: myFarmCows.length > 0 ? myFarmCows[0].rank : 0,
|
||||
averageRank: myFarmCows.length > 0
|
||||
? Math.round(myFarmCows.reduce((sum, c) => sum + c.rank, 0) / myFarmCows.length)
|
||||
: 0,
|
||||
topPercentCount: myFarmCows.filter(c => c.percentile <= 10).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { IsOptional, IsArray, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 대시보드 필터 DTO
|
||||
*/
|
||||
export class DashboardFilterDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
anlysStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reproType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
geneGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
genomeGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
reproGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
targetGenes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
minScore?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
regionNm?: string;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { FarmService } from './farm.service';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
|
||||
@Controller('farm')
|
||||
export class FarmController {
|
||||
@@ -13,40 +12,4 @@ export class FarmController {
|
||||
}
|
||||
return this.farmService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.farmService.findOne(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-latest - 농장 최신 분석 의뢰 정보 조회
|
||||
*/
|
||||
@Get(':farmNo/analysis-latest')
|
||||
getLatestAnalysisRequest(@Param('farmNo') farmNo: string) {
|
||||
return this.farmService.getLatestAnalysisRequest(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-all - 농장 전체 분석 의뢰 목록 조회
|
||||
*/
|
||||
@Get(':farmNo/analysis-all')
|
||||
getAllAnalysisRequests(@Param('farmNo') farmNo: string) {
|
||||
return this.farmService.getAllAnalysisRequests(+farmNo);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<FarmModel>) {
|
||||
return this.farmService.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<FarmModel>) {
|
||||
return this.farmService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.farmService.remove(+id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FarmController } from './farm.controller';
|
||||
import { FarmService } from './farm.service';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
FarmModel,
|
||||
GenomeRequestModel,
|
||||
CowModel,
|
||||
]),
|
||||
],
|
||||
imports: [TypeOrmModule.forFeature([FarmModel])],
|
||||
controllers: [FarmController],
|
||||
providers: [FarmService],
|
||||
exports: [FarmService],
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
@Injectable()
|
||||
export class FarmService {
|
||||
constructor(
|
||||
@InjectRepository(FarmModel)
|
||||
private readonly farmRepository: Repository<FarmModel>,
|
||||
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
) { }
|
||||
|
||||
// 전체 농장 조회
|
||||
@@ -36,93 +27,4 @@ export class FarmService {
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// 농장 단건 조회
|
||||
async findOne(id: number): Promise<FarmModel> {
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: id, delDt: IsNull() },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!farm) {
|
||||
throw new NotFoundException('Farm #' + id + ' not found');
|
||||
}
|
||||
return farm;
|
||||
}
|
||||
|
||||
// 농장 생성
|
||||
async create(data: Partial<FarmModel>): Promise<FarmModel> {
|
||||
const farm = this.farmRepository.create(data);
|
||||
return this.farmRepository.save(farm);
|
||||
}
|
||||
|
||||
// 농장 수정
|
||||
async update(id: number, data: Partial<FarmModel>): Promise<FarmModel> {
|
||||
await this.findOne(id);
|
||||
await this.farmRepository.update(id, data);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// 농장 삭제
|
||||
async remove(id: number): Promise<void> {
|
||||
const farm = await this.findOne(id);
|
||||
await this.farmRepository.softRemove(farm);
|
||||
}
|
||||
|
||||
// 농장 최신 분석 의뢰 정보 조회
|
||||
async getLatestAnalysisRequest(farmNo: number): Promise<any> {
|
||||
const farm = await this.findOne(farmNo);
|
||||
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
order: { requestDt: 'DESC' },
|
||||
});
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const farmAnlysCnt = requests.length;
|
||||
const matchCnt = requests.filter(r => isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)).length;
|
||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||
|
||||
return {
|
||||
pkFarmAnlysNo: 1,
|
||||
fkFarmNo: farmNo,
|
||||
farmAnlysNm: farm.farmerName,
|
||||
anlysReqDt: requests[0]?.requestDt || new Date(),
|
||||
region: farm.regionSi,
|
||||
city: farm.regionGu,
|
||||
anlysReqCnt: cows.length,
|
||||
farmAnlysCnt,
|
||||
matchCnt,
|
||||
mismatchCnt: failCnt,
|
||||
failCnt,
|
||||
noHistCnt,
|
||||
matchRate: farmAnlysCnt > 0 ? Math.round((matchCnt / farmAnlysCnt) * 100) : 0,
|
||||
msAnlysCnt: 0,
|
||||
anlysRmrk: '',
|
||||
paternities: requests.map(r => ({
|
||||
pkFarmPaternityNo: r.pkRequestNo,
|
||||
fkFarmAnlysNo: 1,
|
||||
receiptDate: r.requestDt,
|
||||
farmOwnerName: farm.farmerName,
|
||||
individualNo: r.cow?.cowId || '',
|
||||
kpnNo: r.cow?.sireKpn || '',
|
||||
motherIndividualNo: r.cow?.damCowId || '',
|
||||
hairRootQuality: r.sampleAmount || '',
|
||||
remarks: r.cowRemarks || '',
|
||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||
motherMatch: r.chipDamName || '미확인',
|
||||
reportDate: r.chipReportDt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// 농장 전체 분석 의뢰 목록 조회
|
||||
async getAllAnalysisRequests(farmNo: number): Promise<any[]> {
|
||||
const latestRequest = await this.getLatestAnalysisRequest(farmNo);
|
||||
return [latestRequest];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { GeneService } from './gene.service';
|
||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||
|
||||
@@ -14,53 +14,4 @@ export class GeneController {
|
||||
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
|
||||
return this.geneService.findByCowId(cowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체별 유전자 요약 정보 조회
|
||||
* GET /gene/summary/:cowId
|
||||
*/
|
||||
@Get('summary/:cowId')
|
||||
async getGeneSummary(@Param('cowId') cowId: string): Promise<{
|
||||
total: number;
|
||||
homozygousCount: number;
|
||||
heterozygousCount: number;
|
||||
}> {
|
||||
return this.geneService.getGeneSummary(cowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 의뢰번호로 유전자 상세 정보 조회
|
||||
* GET /gene/request/:requestNo
|
||||
*/
|
||||
@Get('request/:requestNo')
|
||||
async findByRequestNo(@Param('requestNo') requestNo: number): Promise<GeneDetailModel[]> {
|
||||
return this.geneService.findByRequestNo(requestNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 단건 조회
|
||||
* GET /gene/detail/:geneDetailNo
|
||||
*/
|
||||
@Get('detail/:geneDetailNo')
|
||||
async findOne(@Param('geneDetailNo') geneDetailNo: number): Promise<GeneDetailModel> {
|
||||
return this.geneService.findOne(geneDetailNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 생성
|
||||
* POST /gene
|
||||
*/
|
||||
@Post()
|
||||
async create(@Body() data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
||||
return this.geneService.create(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 일괄 생성
|
||||
* POST /gene/bulk
|
||||
*/
|
||||
@Post('bulk')
|
||||
async createBulk(@Body() dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
||||
return this.geneService.createBulk(dataList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GeneService {
|
||||
constructor(
|
||||
@InjectRepository(GeneDetailModel)
|
||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -19,7 +16,7 @@ export class GeneService {
|
||||
* @returns 유전자 상세 정보 배열
|
||||
*/
|
||||
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
|
||||
const results = await this.geneDetailRepository.find({
|
||||
return await this.geneDetailRepository.find({
|
||||
where: {
|
||||
cowId,
|
||||
delDt: IsNull(),
|
||||
@@ -29,100 +26,5 @@ export class GeneService {
|
||||
position: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 의뢰번호(requestNo)로 유전자 상세 정보 조회
|
||||
* @param requestNo 의뢰번호
|
||||
* @returns 유전자 상세 정보 배열
|
||||
*/
|
||||
async findByRequestNo(requestNo: number): Promise<GeneDetailModel[]> {
|
||||
const results = await this.geneDetailRepository.find({
|
||||
where: {
|
||||
fkRequestNo: requestNo,
|
||||
delDt: IsNull(),
|
||||
},
|
||||
order: {
|
||||
chromosome: 'ASC',
|
||||
position: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체별 유전자 요약 정보 조회
|
||||
* @param cowId 개체식별번호
|
||||
* @returns 동형접합/이형접합 개수 요약
|
||||
*/
|
||||
async getGeneSummary(cowId: string): Promise<{
|
||||
total: number;
|
||||
homozygousCount: number;
|
||||
heterozygousCount: number;
|
||||
}> {
|
||||
const geneDetails = await this.findByCowId(cowId);
|
||||
|
||||
let homozygousCount = 0;
|
||||
let heterozygousCount = 0;
|
||||
|
||||
geneDetails.forEach((gene) => {
|
||||
if (gene.allele1 && gene.allele2) {
|
||||
if (gene.allele1 === gene.allele2) {
|
||||
homozygousCount++;
|
||||
} else {
|
||||
heterozygousCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: geneDetails.length,
|
||||
homozygousCount,
|
||||
heterozygousCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 단건 조회
|
||||
* @param geneDetailNo 유전자상세번호
|
||||
* @returns 유전자 상세 정보
|
||||
*/
|
||||
async findOne(geneDetailNo: number): Promise<GeneDetailModel> {
|
||||
const result = await this.geneDetailRepository.findOne({
|
||||
where: {
|
||||
pkGeneDetailNo: geneDetailNo,
|
||||
delDt: IsNull(),
|
||||
},
|
||||
relations: ['genomeRequest'],
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`유전자 상세 정보를 찾을 수 없습니다. (geneDetailNo: ${geneDetailNo})`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 생성
|
||||
* @param data 생성할 데이터
|
||||
* @returns 생성된 유전자 상세 정보
|
||||
*/
|
||||
async create(data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
||||
const geneDetail = this.geneDetailRepository.create(data);
|
||||
return await this.geneDetailRepository.save(geneDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 일괄 생성
|
||||
* @param dataList 생성할 데이터 배열
|
||||
* @returns 생성된 유전자 상세 정보 배열
|
||||
*/
|
||||
async createBulk(dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
||||
const geneDetails = this.geneDetailRepository.create(dataList);
|
||||
return await this.geneDetailRepository.save(geneDetails);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { GenomeService } from './genome.service';
|
||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||
|
||||
export interface CategoryAverageDto {
|
||||
category: string;
|
||||
@@ -30,16 +27,6 @@ export class GenomeController {
|
||||
return this.genomeService.getDashboardStats(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/farm-trait-comparison/:farmNo
|
||||
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
||||
* @param farmNo - 농장 번호
|
||||
*/
|
||||
@Get('farm-trait-comparison/:farmNo')
|
||||
getFarmTraitComparison(@Param('farmNo') farmNo: string) {
|
||||
return this.genomeService.getFarmTraitComparison(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/farm-region-ranking/:farmNo
|
||||
* 농가의 보은군 내 순위 조회 (대시보드용)
|
||||
@@ -67,21 +54,6 @@ export class GenomeController {
|
||||
return this.genomeService.getTraitRank(cowId, traitName);
|
||||
}
|
||||
|
||||
// Genome Request endpoints
|
||||
@Get('request')
|
||||
findAllRequests(
|
||||
@Query('cowId') cowId?: string,
|
||||
@Query('farmId') farmId?: string,
|
||||
) {
|
||||
if (cowId) {
|
||||
return this.genomeService.findRequestsByCowId(+cowId);
|
||||
}
|
||||
if (farmId) {
|
||||
return this.genomeService.findRequestsByFarmId(+farmId);
|
||||
}
|
||||
return this.genomeService.findAllRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/request/:cowId
|
||||
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
||||
@@ -92,11 +64,6 @@ export class GenomeController {
|
||||
return this.genomeService.findRequestByCowIdentifier(cowId);
|
||||
}
|
||||
|
||||
@Post('request')
|
||||
createRequest(@Body() data: Partial<GenomeRequestModel>) {
|
||||
return this.genomeService.createRequest(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/comparison-averages/:cowId
|
||||
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
||||
@@ -133,32 +100,6 @@ export class GenomeController {
|
||||
}
|
||||
|
||||
|
||||
// Genome Trait Detail endpoints
|
||||
@Get('trait-detail/:requestId')
|
||||
findTraitDetailsByRequestId(@Param('requestId') requestId: string) {
|
||||
return this.genomeService.findTraitDetailsByRequestId(+requestId);
|
||||
}
|
||||
|
||||
@Get('trait-detail/cow/:cowId')
|
||||
findTraitDetailsByCowId(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.findTraitDetailsByCowId(cowId);
|
||||
}
|
||||
|
||||
@Post('trait-detail')
|
||||
createTraitDetail(@Body() data: Partial<GenomeTraitDetailModel>) {
|
||||
return this.genomeService.createTraitDetail(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/check-cow/:cowId
|
||||
* 특정 개체 상세 정보 조회 (디버깅용)
|
||||
*/
|
||||
@Public()
|
||||
@Get('check-cow/:cowId')
|
||||
checkSpecificCow(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.checkSpecificCows([cowId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/yearly-trait-trend/:farmNo
|
||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
isValidGenomeAnalysis,
|
||||
VALID_CHIP_SIRE_NAME
|
||||
} from '../common/config/GenomeAnalysisConfig';
|
||||
import {
|
||||
ALL_TRAITS,
|
||||
NEGATIVE_TRAITS,
|
||||
TRAIT_CATEGORY_MAP,
|
||||
getTraitCategory,
|
||||
} from '../common/const/TraitTypes';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||
@@ -12,68 +18,6 @@ import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
||||
|
||||
/**
|
||||
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
||||
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
||||
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
||||
*/
|
||||
const NEGATIVE_TRAITS = ['등지방두께'];
|
||||
|
||||
/**
|
||||
* 형질명 → 카테고리 매핑 상수
|
||||
* - 성장: 월령별 체중 관련 형질
|
||||
* - 생산: 도체(도축 후 고기) 품질 관련 형질
|
||||
* - 체형: 소의 신체 구조/외형 관련 형질
|
||||
* - 무게: 각 부위별 실제 무게 (단위: kg)
|
||||
* - 비율: 각 부위별 비율 (단위: %)
|
||||
*/
|
||||
const TRAIT_CATEGORY_MAP: Record<string, string> = {
|
||||
// 성장 카테고리 - 월령별 체중
|
||||
'12개월령체중': '성장',
|
||||
|
||||
// 생산 카테고리 - 도체(도축 후 고기) 품질
|
||||
'도체중': '생산', // 도축 후 고기 무게
|
||||
'등심단면적': '생산', // 등심의 단면 크기 (넓을수록 좋음)
|
||||
'등지방두께': '생산', // 등 부위 지방 두께 (적당해야 좋음)
|
||||
'근내지방도': '생산', // 마블링 정도 (높을수록 고급육)
|
||||
|
||||
// 체형 카테고리 - 소의 신체 구조/외형
|
||||
'체고': '체형', // 어깨 높이
|
||||
'십자': '체형', // 십자부(엉덩이) 높이
|
||||
'체장': '체형', // 몸통 길이
|
||||
'흉심': '체형', // 가슴 깊이
|
||||
'흉폭': '체형', // 가슴 너비
|
||||
'고장': '체형', // 엉덩이 길이
|
||||
'요각폭': '체형', // 허리뼈 너비
|
||||
'곤폭': '체형', // 좌골(엉덩이뼈) 너비
|
||||
'좌골폭': '체형', // 좌골 너비
|
||||
'흉위': '체형', // 가슴둘레
|
||||
|
||||
// 무게 카테고리 - 부위별 실제 무게 (kg)
|
||||
'안심weight': '무게', // 안심 무게
|
||||
'등심weight': '무게', // 등심 무게
|
||||
'채끝weight': '무게', // 채끝 무게
|
||||
'목심weight': '무게', // 목심 무게
|
||||
'앞다리weight': '무게', // 앞다리 무게
|
||||
'우둔weight': '무게', // 우둔 무게
|
||||
'설도weight': '무게', // 설도 무게
|
||||
'사태weight': '무게', // 사태 무게
|
||||
'양지weight': '무게', // 양지 무게
|
||||
'갈비weight': '무게', // 갈비 무게
|
||||
|
||||
// 비율 카테고리 - 부위별 비율 (%)
|
||||
'안심rate': '비율', // 안심 비율
|
||||
'등심rate': '비율', // 등심 비율
|
||||
'채끝rate': '비율', // 채끝 비율
|
||||
'목심rate': '비율', // 목심 비율
|
||||
'앞다리rate': '비율', // 앞다리 비율
|
||||
'우둔rate': '비율', // 우둔 비율
|
||||
'설도rate': '비율', // 설도 비율
|
||||
'사태rate': '비율', // 사태 비율
|
||||
'양지rate': '비율', // 양지 비율
|
||||
'갈비rate': '비율', // 갈비 비율
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리별 평균 EBV(추정육종가) 응답 DTO
|
||||
*/
|
||||
@@ -155,177 +99,6 @@ export class GenomeService {
|
||||
// 대시보드 통계 관련 메서드
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
||||
* - 각 형질별로 원본 EBV, 중요도(가중치), 적용 EBV
|
||||
* - 보은군 전체 평균, 농가 평균 비교
|
||||
*
|
||||
* @param farmNo - 농장 번호
|
||||
*/
|
||||
async getFarmTraitComparison(farmNo: number): Promise<{
|
||||
farmName: string;
|
||||
regionName: string;
|
||||
totalFarmAnimals: number;
|
||||
totalRegionAnimals: number;
|
||||
traits: {
|
||||
traitName: string;
|
||||
category: string;
|
||||
// 농가 데이터
|
||||
farmAvgEbv: number;
|
||||
farmCount: number;
|
||||
farmPercentile: number;
|
||||
// 지역(보은군) 데이터
|
||||
regionAvgEbv: number;
|
||||
regionCount: number;
|
||||
// 전국 데이터
|
||||
nationAvgEbv: number;
|
||||
nationCount: number;
|
||||
// 비교
|
||||
diffFromRegion: number; // 지역 대비 차이
|
||||
diffFromNation: number; // 전국 대비 차이
|
||||
}[];
|
||||
}> {
|
||||
// Step 1: 농장 정보 조회
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const regionSi = farm?.regionSi || '보은군';
|
||||
const farmName = farm?.farmerName || '농장';
|
||||
|
||||
// Step 2: 농가의 분석 완료된 개체들의 형질 데이터 조회
|
||||
const farmRequestsRaw = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, chipSireName: VALID_CHIP_SIRE_NAME, delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
});
|
||||
// 유효 조건 필터 적용 (chipDamName 제외 조건 + cowId 제외 목록)
|
||||
const farmRequests = farmRequestsRaw.filter(r =>
|
||||
isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)
|
||||
);
|
||||
|
||||
const farmTraitMap = new Map<string, { sum: number; percentileSum: number; count: number; category: string }>();
|
||||
|
||||
for (const request of farmRequests) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const details = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: request.cow?.cowId, delDt: IsNull() },
|
||||
});
|
||||
if (details.length === 0) continue;
|
||||
|
||||
for (const detail of details) {
|
||||
if (detail.traitEbv !== null && detail.traitName) {
|
||||
const traitName = detail.traitName;
|
||||
const category = TRAIT_CATEGORY_MAP[traitName] || '기타';
|
||||
|
||||
if (!farmTraitMap.has(traitName)) {
|
||||
farmTraitMap.set(traitName, { sum: 0, percentileSum: 0, count: 0, category });
|
||||
}
|
||||
|
||||
const t = farmTraitMap.get(traitName)!;
|
||||
t.sum += Number(detail.traitEbv);
|
||||
t.percentileSum += Number(detail.traitPercentile) || 50;
|
||||
t.count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 지역(보은군) 전체 형질 데이터 조회
|
||||
const regionDetails = await this.genomeTraitDetailRepository
|
||||
.createQueryBuilder('detail')
|
||||
.innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no')
|
||||
.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no')
|
||||
.where('detail.delDt IS NULL')
|
||||
.andWhere('detail.traitEbv IS NOT NULL')
|
||||
.andWhere('request.chip_sire_name = :match', { match: '일치' })
|
||||
.andWhere('farm.region_si = :regionSi', { regionSi })
|
||||
.select(['detail.traitName', 'detail.traitEbv'])
|
||||
.getRawMany();
|
||||
|
||||
const regionTraitMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const detail of regionDetails) {
|
||||
const traitName = detail.detail_trait_name;
|
||||
const ebv = parseFloat(detail.detail_trait_ebv);
|
||||
if (!traitName || isNaN(ebv)) continue;
|
||||
|
||||
if (!regionTraitMap.has(traitName)) {
|
||||
regionTraitMap.set(traitName, { sum: 0, count: 0 });
|
||||
}
|
||||
const t = regionTraitMap.get(traitName)!;
|
||||
t.sum += ebv;
|
||||
t.count++;
|
||||
}
|
||||
|
||||
// Step 4: 전국 형질 데이터 조회
|
||||
const nationDetails = await this.genomeTraitDetailRepository
|
||||
.createQueryBuilder('detail')
|
||||
.innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no')
|
||||
.where('detail.delDt IS NULL')
|
||||
.andWhere('detail.traitEbv IS NOT NULL')
|
||||
.andWhere('request.chip_sire_name = :match', { match: '일치' })
|
||||
.select(['detail.traitName', 'detail.traitEbv'])
|
||||
.getRawMany();
|
||||
|
||||
const nationTraitMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const detail of nationDetails) {
|
||||
const traitName = detail.detail_trait_name;
|
||||
const ebv = parseFloat(detail.detail_trait_ebv);
|
||||
if (!traitName || isNaN(ebv)) continue;
|
||||
|
||||
if (!nationTraitMap.has(traitName)) {
|
||||
nationTraitMap.set(traitName, { sum: 0, count: 0 });
|
||||
}
|
||||
const t = nationTraitMap.get(traitName)!;
|
||||
t.sum += ebv;
|
||||
t.count++;
|
||||
}
|
||||
|
||||
// Step 5: 결과 조합 (35개 전체 형질)
|
||||
const traits: any[] = [];
|
||||
const allTraits = Object.keys(TRAIT_CATEGORY_MAP);
|
||||
|
||||
for (const traitName of allTraits) {
|
||||
const farmData = farmTraitMap.get(traitName);
|
||||
const regionData = regionTraitMap.get(traitName);
|
||||
const nationData = nationTraitMap.get(traitName);
|
||||
|
||||
const farmAvgEbv = farmData ? Math.round((farmData.sum / farmData.count) * 100) / 100 : 0;
|
||||
const farmPercentile = farmData ? Math.round((farmData.percentileSum / farmData.count) * 100) / 100 : 50;
|
||||
const regionAvgEbv = regionData ? Math.round((regionData.sum / regionData.count) * 100) / 100 : 0;
|
||||
const nationAvgEbv = nationData ? Math.round((nationData.sum / nationData.count) * 100) / 100 : 0;
|
||||
|
||||
traits.push({
|
||||
traitName,
|
||||
category: TRAIT_CATEGORY_MAP[traitName] || '기타',
|
||||
farmAvgEbv,
|
||||
farmCount: farmData?.count || 0,
|
||||
farmPercentile,
|
||||
regionAvgEbv,
|
||||
regionCount: regionData?.count || 0,
|
||||
nationAvgEbv,
|
||||
nationCount: nationData?.count || 0,
|
||||
diffFromRegion: Math.round((farmAvgEbv - regionAvgEbv) * 100) / 100,
|
||||
diffFromNation: Math.round((farmAvgEbv - nationAvgEbv) * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// 지역 개체 수 계산
|
||||
const regionAnimalCount = await this.genomeRequestRepository
|
||||
.createQueryBuilder('request')
|
||||
.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no')
|
||||
.where('request.chip_sire_name = :match', { match: '일치' })
|
||||
.andWhere('request.del_dt IS NULL')
|
||||
.andWhere('farm.region_si = :regionSi', { regionSi })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
farmName,
|
||||
regionName: regionSi,
|
||||
totalFarmAnimals: farmRequests.length,
|
||||
totalRegionAnimals: regionAnimalCount,
|
||||
traits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드용 농가 통계 데이터
|
||||
* - 연도별 분석 현황
|
||||
@@ -917,35 +690,6 @@ export class GenomeService {
|
||||
// 유전체 분석 의뢰 (Genome Request) 관련 메서드
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 전체 유전체 분석 의뢰 목록 조회
|
||||
*
|
||||
* @returns 삭제되지 않은 모든 분석 의뢰 목록
|
||||
* - cow, farm 관계 데이터 포함
|
||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||
*/
|
||||
async findAllRequests(): Promise<GenomeRequestModel[]> {
|
||||
return this.genomeRequestRepository.find({
|
||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||
relations: ['cow', 'farm'], // 개체, 농장 정보 JOIN
|
||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 PK 번호로 해당 개체의 분석 의뢰 목록 조회
|
||||
*
|
||||
* @param cowNo - 개체 PK 번호 (pkCowNo)
|
||||
* @returns 해당 개체의 모든 분석 의뢰 목록 (최신순)
|
||||
*/
|
||||
async findRequestsByCowId(cowNo: number): Promise<GenomeRequestModel[]> {
|
||||
return this.genomeRequestRepository.find({
|
||||
where: { fkCowNo: cowNo, delDt: IsNull() },
|
||||
relations: ['cow', 'farm'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체식별번호(cowId)로 유전체 데이터 조회
|
||||
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
|
||||
@@ -991,19 +735,7 @@ export class GenomeService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 4: 형질명으로 카테고리를 추정하는 내부 함수
|
||||
const getCategoryFromTraitName = (traitName: string): string => {
|
||||
// 성장 카테고리: 월령별 체중
|
||||
if (['12개월령체중', '18개월령체중', '24개월령체중'].includes(traitName)) return '성장';
|
||||
// 생산 카테고리: 도체 품질
|
||||
if (['도체중', '등심단면적', '등지방두께', '근내지방도'].includes(traitName)) return '생산';
|
||||
// 체형 카테고리: 신체 구조
|
||||
if (traitName.includes('체형') || traitName.includes('체고') || traitName.includes('십자부')) return '체형';
|
||||
// 그 외 기타
|
||||
return '기타';
|
||||
};
|
||||
|
||||
// Step 5: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
|
||||
// Step 4: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
|
||||
return [{
|
||||
request: latestRequest, // 분석 의뢰 정보
|
||||
genomeCows: traitDetails.map(detail => ({
|
||||
@@ -1012,27 +744,13 @@ export class GenomeService {
|
||||
percentile: detail.traitPercentile, // 백분위 순위
|
||||
traitInfo: {
|
||||
traitNm: detail.traitName, // 형질명
|
||||
traitCtgry: getCategoryFromTraitName(detail.traitName || ''), // 카테고리
|
||||
traitCtgry: getTraitCategory(detail.traitName || ''), // 카테고리 (공통 함수 사용)
|
||||
traitDesc: '', // 형질 설명 (빈값)
|
||||
},
|
||||
})),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 PK 번호로 해당 농장의 분석 의뢰 목록 조회
|
||||
*
|
||||
* @param farmNo - 농장 PK 번호 (pkFarmNo)
|
||||
* @returns 해당 농장의 모든 분석 의뢰 목록 (최신순)
|
||||
*/
|
||||
async findRequestsByFarmId(farmNo: number): Promise<GenomeRequestModel[]> {
|
||||
return this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['cow', 'farm'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
|
||||
*
|
||||
@@ -1059,61 +777,6 @@ export class GenomeService {
|
||||
return request || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ===========================================================================================
|
||||
* 유전체 분석 요청 관련 메서드
|
||||
* ===========================================================================================
|
||||
* 새로운 유전체 분석 의뢰 생성
|
||||
*
|
||||
* @param data - 생성할 분석 의뢰 데이터 (Partial: 일부 필드만 입력 가능)
|
||||
* @returns 생성된 분석 의뢰 엔티티
|
||||
*/
|
||||
async createRequest(data: Partial<GenomeRequestModel>): Promise<GenomeRequestModel> {
|
||||
// 엔티티 인스턴스 생성
|
||||
const request = this.genomeRequestRepository.create(data);
|
||||
// DB에 저장 후 반환
|
||||
return this.genomeRequestRepository.save(request);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 형질 상세 (Genome Trait Detail) 관련 메서드
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 분석 의뢰 PK로 해당 의뢰의 형질 상세 목록 조회
|
||||
*
|
||||
* @param requestNo - 분석 의뢰 PK 번호 (pkRequestNo)
|
||||
* @returns 해당 의뢰의 모든 형질 상세 목록
|
||||
*/
|
||||
async findTraitDetailsByRequestId(requestNo: number): Promise<GenomeTraitDetailModel[]> {
|
||||
return this.genomeTraitDetailRepository.find({
|
||||
where: { fkRequestNo: requestNo, delDt: IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* cowId로 해당 개체의 형질 상세 목록 조회
|
||||
*
|
||||
* @param cowId - 개체식별번호 (KOR...)
|
||||
* @returns 해당 개체의 모든 형질 상세 목록
|
||||
*/
|
||||
async findTraitDetailsByCowId(cowId: string): Promise<GenomeTraitDetailModel[]> {
|
||||
return this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cowId, delDt: IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 형질 상세 데이터 생성
|
||||
*
|
||||
* @param data - 생성할 형질 상세 데이터
|
||||
* @returns 생성된 형질 상세 엔티티
|
||||
*/
|
||||
async createTraitDetail(data: Partial<GenomeTraitDetailModel>): Promise<GenomeTraitDetailModel> {
|
||||
const detail = this.genomeTraitDetailRepository.create(data);
|
||||
return this.genomeTraitDetailRepository.save(detail);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 비교 분석 (Comparison) 관련 메서드
|
||||
// ============================================
|
||||
@@ -1874,17 +1537,7 @@ export class GenomeService {
|
||||
traitDetailsByCowId.get(detail.cowId)!.push(detail);
|
||||
}
|
||||
|
||||
// 4. 35개 전체 형질 조건 (기본값)
|
||||
const ALL_TRAITS = [
|
||||
'12개월령체중',
|
||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
];
|
||||
// inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용
|
||||
// 4. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
|
||||
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
||||
? inputTraitConditions // 프론트에서 보낸 형질사용
|
||||
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용
|
||||
@@ -2003,41 +1656,6 @@ export class GenomeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 개체들의 상세 정보 조회 (디버깅용)
|
||||
*/
|
||||
async checkSpecificCows(cowIds: string[]): Promise<any[]> {
|
||||
const results = [];
|
||||
for (const cowId of cowIds) {
|
||||
const request = await this.genomeRequestRepository.findOne({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
});
|
||||
|
||||
// cowId로 조회
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (cow) {
|
||||
const req = await this.genomeRequestRepository.findOne({
|
||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
results.push({
|
||||
cowId,
|
||||
chipSireName: req?.chipSireName,
|
||||
chipDamName: req?.chipDamName,
|
||||
requestDt: req?.requestDt,
|
||||
cowRemarks: req?.cowRemarks,
|
||||
});
|
||||
} else {
|
||||
results.push({ cowId, error: 'cow not found' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||
* 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsOptional, IsInt, MaxLength, IsIn } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 도움말 생성 DTO
|
||||
*
|
||||
* @export
|
||||
* @class CreateHelpDto
|
||||
*/
|
||||
export class CreateHelpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||
@MaxLength(20)
|
||||
helpCtgry: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
targetNm: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
helpTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
helpShort?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
helpFull?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpImageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpVideoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpLinkUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['Y', 'N'])
|
||||
@MaxLength(1)
|
||||
useYn?: string;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { IsOptional, IsString, IsIn, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 도움말 필터링 DTO
|
||||
*
|
||||
* @export
|
||||
* @class FilterHelpDto
|
||||
*/
|
||||
export class FilterHelpDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||
@MaxLength(20)
|
||||
helpCtgry?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
targetNm?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['Y', 'N'])
|
||||
@MaxLength(1)
|
||||
useYn?: string;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateHelpDto } from './create-help.dto';
|
||||
|
||||
/**
|
||||
* 도움말 수정 DTO
|
||||
*
|
||||
* @export
|
||||
* @class UpdateHelpDto
|
||||
* @extends {PartialType(CreateHelpDto)}
|
||||
*/
|
||||
export class UpdateHelpDto extends PartialType(CreateHelpDto) {}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { BaseModel } from "src/common/entities/base.entity";
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity({ name: "tb_help" })
|
||||
export class HelpModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: "pk_help_no",
|
||||
type: "int",
|
||||
comment: "도움말 번호",
|
||||
})
|
||||
pkHelpNo: number;
|
||||
|
||||
@Column({
|
||||
name: "help_ctgry",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
comment: "분류 (SNP/GENOME/MPT)",
|
||||
})
|
||||
helpCtgry: string;
|
||||
|
||||
@Column({
|
||||
name: "target_nm",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: false,
|
||||
comment: "대상명 (PLAG1, 도체중, 혈당 등)",
|
||||
})
|
||||
targetNm: string;
|
||||
|
||||
@Column({
|
||||
name: "help_title",
|
||||
type: "varchar",
|
||||
length: 200,
|
||||
nullable: true,
|
||||
comment: "제목",
|
||||
})
|
||||
helpTitle: string;
|
||||
|
||||
@Column({
|
||||
name: "help_short",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "짧은 설명 (툴팁용)",
|
||||
})
|
||||
helpShort: string;
|
||||
|
||||
@Column({
|
||||
name: "help_full",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "상세 설명 (사이드패널용)",
|
||||
})
|
||||
helpFull: string;
|
||||
|
||||
@Column({
|
||||
name: "help_image_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "이미지 URL",
|
||||
})
|
||||
helpImageUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "help_video_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "영상 URL",
|
||||
})
|
||||
helpVideoUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "help_link_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "참고 링크 URL",
|
||||
})
|
||||
helpLinkUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
comment: "표시 순서",
|
||||
})
|
||||
displayOrder: number;
|
||||
|
||||
@Column({
|
||||
name: "use_yn",
|
||||
type: "char",
|
||||
length: 1,
|
||||
nullable: false,
|
||||
default: "Y",
|
||||
comment: "사용 여부 (Y/N)",
|
||||
})
|
||||
useYn: string;
|
||||
|
||||
// BaseModel에서 상속받는 컬럼들:
|
||||
// - regDt: 등록일시
|
||||
// - updtDt: 수정일시
|
||||
// - regIp: 등록 IP
|
||||
// - updtIp: 수정 IP
|
||||
// - regUserId: 등록자 ID
|
||||
// - updtUserId: 수정자 ID
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
||||
import { HelpService } from './help.service';
|
||||
import { CreateHelpDto } from './dto/create-help.dto';
|
||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Help Controller
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 API 엔드포인트를 제공합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 도움말 CRUD (생성, 조회, 수정, 삭제)
|
||||
* - 카테고리별 조회 (SNP/GENOME/MPT)
|
||||
* - 대상명별 조회 (PLAG1, 도체중 등)
|
||||
* - 툴팁 데이터 제공
|
||||
*
|
||||
* @export
|
||||
* @class HelpController
|
||||
*/
|
||||
@Controller('help')
|
||||
export class HelpController {
|
||||
constructor(private readonly helpService: HelpService) {}
|
||||
|
||||
/**
|
||||
* POST /help - 도움말 생성 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 새로운 도움말을 생성합니다.
|
||||
*
|
||||
* @example
|
||||
* // POST /help
|
||||
* {
|
||||
* "helpCtgry": "SNP",
|
||||
* "targetNm": "PLAG1",
|
||||
* "helpTitle": "PLAG1 유전자란?",
|
||||
* "helpShort": "체고 및 성장 관련 유전자",
|
||||
* "helpFull": "PLAG1은 소의 체고와 성장에 영향을 미치는 주요 유전자입니다...",
|
||||
* "displayOrder": 1,
|
||||
* "useYn": "Y"
|
||||
* }
|
||||
*
|
||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Post()
|
||||
async create(@Body() createHelpDto: CreateHelpDto, @Req() req: Request) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.create(createHelpDto, userId, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help - 전체 도움말 목록 조회
|
||||
*
|
||||
* @description
|
||||
* 전체 도움말 목록을 조회합니다. 필터 조건을 통해 검색 가능합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help
|
||||
* // GET /help?helpCtgry=SNP
|
||||
* // GET /help?useYn=Y
|
||||
* // GET /help?targetNm=PLAG1
|
||||
*
|
||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Query() filterDto: FilterHelpDto) {
|
||||
return await this.helpService.findAll(filterDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/category/:category - 카테고리별 도움말 조회
|
||||
*
|
||||
* @description
|
||||
* 특정 카테고리(SNP/GENOME/MPT)의 모든 도움말을 조회합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/category/SNP
|
||||
* // GET /help/category/GENOME
|
||||
* // GET /help/category/MPT
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
@Get('category/:category')
|
||||
async findByCategory(@Param('category') category: string) {
|
||||
return await this.helpService.findByCategory(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/:category/:targetNm - 특정 대상의 도움말 조회
|
||||
*
|
||||
* @description
|
||||
* 특정 카테고리와 대상명에 해당하는 도움말을 조회합니다.
|
||||
* 툴팁이나 사이드패널에서 사용됩니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/SNP/PLAG1
|
||||
* // GET /help/GENOME/도체중
|
||||
* // GET /help/MPT/혈당
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @param {string} targetNm - 대상명 (PLAG1, 도체중 등)
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Get(':category/:targetNm')
|
||||
async findByTarget(
|
||||
@Param('category') category: string,
|
||||
@Param('targetNm') targetNm: string,
|
||||
) {
|
||||
return await this.helpService.findByTarget(category, targetNm);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/id/:id - 도움말 단건 조회
|
||||
*
|
||||
* @description
|
||||
* 도움말 번호로 단건을 조회합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/id/1
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Get('id/:id')
|
||||
async findOne(@Param('id') id: number) {
|
||||
return await this.helpService.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /help/:id - 도움말 수정 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 기존 도움말을 수정합니다.
|
||||
*
|
||||
* @example
|
||||
* // PUT /help/1
|
||||
* {
|
||||
* "helpTitle": "수정된 제목",
|
||||
* "helpShort": "수정된 짧은 설명",
|
||||
* "displayOrder": 2
|
||||
* }
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: number,
|
||||
@Body() updateHelpDto: UpdateHelpDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.update(id, updateHelpDto, userId, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /help/:id - 도움말 삭제 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 도움말을 삭제합니다 (soft delete - useYn = 'N').
|
||||
*
|
||||
* @example
|
||||
* // DELETE /help/1
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: number, @Req() req: Request) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.remove(id, userId, ip);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HelpController } from './help.controller';
|
||||
import { HelpService } from './help.service';
|
||||
import { HelpModel } from './entities/help.entity';
|
||||
|
||||
/**
|
||||
* Help Module
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 모듈입니다.
|
||||
* SNP, GENOME, MPT 등의 용어에 대한 설명을 제공합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 도움말 CRUD
|
||||
* - 카테고리별 조회
|
||||
* - 툴팁/사이드패널 데이터 제공
|
||||
*
|
||||
* @export
|
||||
* @class HelpModule
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([HelpModel])],
|
||||
controllers: [HelpController],
|
||||
providers: [HelpService],
|
||||
exports: [HelpService],
|
||||
})
|
||||
export class HelpModule {}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { HelpModel } from './entities/help.entity';
|
||||
import { CreateHelpDto } from './dto/create-help.dto';
|
||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||
|
||||
/**
|
||||
* Help Service
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 서비스입니다.
|
||||
* SNP, GENOME, MPT 등의 용어에 대한 도움말을 제공합니다.
|
||||
*
|
||||
* @export
|
||||
* @class HelpService
|
||||
*/
|
||||
@Injectable()
|
||||
export class HelpService {
|
||||
constructor(
|
||||
@InjectRepository(HelpModel)
|
||||
private readonly helpRepository: Repository<HelpModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 도움말 생성
|
||||
*
|
||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||
* @param {string} userId - 생성자 ID
|
||||
* @param {string} ip - 생성자 IP
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async create(createHelpDto: CreateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||
const help = this.helpRepository.create({
|
||||
...createHelpDto,
|
||||
regUserId: userId,
|
||||
regIp: ip,
|
||||
useYn: createHelpDto.useYn || 'Y',
|
||||
});
|
||||
|
||||
return await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 도움말 목록 조회
|
||||
*
|
||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
async findAll(filterDto?: FilterHelpDto): Promise<HelpModel[]> {
|
||||
const queryBuilder = this.helpRepository.createQueryBuilder('help');
|
||||
|
||||
if (filterDto?.helpCtgry) {
|
||||
queryBuilder.andWhere('help.helpCtgry = :helpCtgry', { helpCtgry: filterDto.helpCtgry });
|
||||
}
|
||||
|
||||
if (filterDto?.targetNm) {
|
||||
queryBuilder.andWhere('help.targetNm LIKE :targetNm', { targetNm: `%${filterDto.targetNm}%` });
|
||||
}
|
||||
|
||||
if (filterDto?.useYn) {
|
||||
queryBuilder.andWhere('help.useYn = :useYn', { useYn: filterDto.useYn });
|
||||
}
|
||||
|
||||
return await queryBuilder
|
||||
.orderBy('help.displayOrder', 'ASC')
|
||||
.addOrderBy('help.pkHelpNo', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 도움말 조회
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
async findByCategory(category: string): Promise<HelpModel[]> {
|
||||
return await this.helpRepository.find({
|
||||
where: {
|
||||
helpCtgry: category,
|
||||
useYn: 'Y',
|
||||
},
|
||||
order: {
|
||||
displayOrder: 'ASC',
|
||||
pkHelpNo: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 대상명의 도움말 조회
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @param {string} targetNm - 대상명 (예: PLAG1, 도체중 등)
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async findByTarget(category: string, targetNm: string): Promise<HelpModel> {
|
||||
const help = await this.helpRepository.findOne({
|
||||
where: {
|
||||
helpCtgry: category,
|
||||
targetNm: targetNm,
|
||||
useYn: 'Y',
|
||||
},
|
||||
});
|
||||
|
||||
if (!help) {
|
||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (카테고리: ${category}, 대상: ${targetNm})`);
|
||||
}
|
||||
|
||||
return help;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 번호로 단건 조회
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async findOne(id: number): Promise<HelpModel> {
|
||||
const help = await this.helpRepository.findOne({
|
||||
where: { pkHelpNo: id },
|
||||
});
|
||||
|
||||
if (!help) {
|
||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (ID: ${id})`);
|
||||
}
|
||||
|
||||
return help;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 수정
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||
* @param {string} userId - 수정자 ID
|
||||
* @param {string} ip - 수정자 IP
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async update(id: number, updateHelpDto: UpdateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||
const help = await this.findOne(id);
|
||||
|
||||
Object.assign(help, updateHelpDto);
|
||||
help.updtUserId = userId;
|
||||
help.updtIp = ip;
|
||||
|
||||
return await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 삭제 (soft delete - useYn = 'N')
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {string} userId - 삭제자 ID
|
||||
* @param {string} ip - 삭제자 IP
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id: number, userId: string, ip: string): Promise<void> {
|
||||
const help = await this.findOne(id);
|
||||
|
||||
help.useYn = 'N';
|
||||
help.updtUserId = userId;
|
||||
help.updtIp = ip;
|
||||
|
||||
await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 영구 삭제 (hard delete)
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async hardRemove(id: number): Promise<void> {
|
||||
const help = await this.findOne(id);
|
||||
await this.helpRepository.remove(help);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,37 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { MptService } from './mpt.service';
|
||||
import { MptModel } from './entities/mpt.entity';
|
||||
|
||||
@Controller('mpt')
|
||||
export class MptController {
|
||||
constructor(private readonly mptService: MptService) {}
|
||||
|
||||
/**
|
||||
* MPT 참조값 조회
|
||||
*/
|
||||
@Get('reference')
|
||||
getReferenceValues() {
|
||||
return this.mptService.getReferenceValues();
|
||||
}
|
||||
|
||||
@Get()
|
||||
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();
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장별 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);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<MptModel>) {
|
||||
return this.mptService.create(data);
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
bulkCreate(@Body() data: Partial<MptModel>[]) {
|
||||
return this.mptService.bulkCreate(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<MptModel>) {
|
||||
return this.mptService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.mptService.remove(+id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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' },
|
||||
};
|
||||
import {
|
||||
MPT_REFERENCE_RANGES,
|
||||
MPT_CATEGORIES,
|
||||
MptReferenceRange,
|
||||
MptCategory,
|
||||
} from '../common/const/MptReference';
|
||||
|
||||
/**
|
||||
* MPT 통계 응답 DTO
|
||||
@@ -53,22 +37,6 @@ export class MptService {
|
||||
private readonly mptRepository: Repository<MptModel>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByFarmId(farmNo: number): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
||||
@@ -85,38 +53,6 @@ export class MptService {
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<MptModel> {
|
||||
const mpt = await this.mptRepository.findOne({
|
||||
where: { pkMptNo: id, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!mpt) {
|
||||
throw new NotFoundException(`MPT #${id} not found`);
|
||||
}
|
||||
return mpt;
|
||||
}
|
||||
|
||||
async create(data: Partial<MptModel>): Promise<MptModel> {
|
||||
const mpt = this.mptRepository.create(data);
|
||||
return this.mptRepository.save(mpt);
|
||||
}
|
||||
|
||||
async bulkCreate(data: Partial<MptModel>[]): Promise<MptModel[]> {
|
||||
const mpts = this.mptRepository.create(data);
|
||||
return this.mptRepository.save(mpts);
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<MptModel>): Promise<MptModel> {
|
||||
await this.findOne(id);
|
||||
await this.mptRepository.update(id, data);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const mpt = await this.findOne(id);
|
||||
await this.mptRepository.softRemove(mpt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장별 MPT 통계 조회
|
||||
* - 개체별 최신 검사 결과 기준
|
||||
@@ -187,7 +123,7 @@ export class MptService {
|
||||
const category = ref.category as keyof typeof categoryStatus;
|
||||
|
||||
// 범위 밖이면 주의
|
||||
if (value > ref.upper || value < ref.lower) {
|
||||
if (value > ref.upperLimit || value < ref.lowerLimit) {
|
||||
categoryStatus[category] = 'caution';
|
||||
|
||||
// 주의 개체 목록에 추가
|
||||
@@ -196,7 +132,7 @@ export class MptService {
|
||||
category,
|
||||
itemName: itemKey,
|
||||
value,
|
||||
status: value > ref.upper ? 'high' : 'low',
|
||||
status: value > ref.upperLimit ? 'high' : 'low',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -239,11 +175,11 @@ export class MptService {
|
||||
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);
|
||||
? (a.value - refA.upperLimit) / (refA.upperLimit - refA.lowerLimit)
|
||||
: (refA.lowerLimit - a.value) / (refA.upperLimit - refA.lowerLimit);
|
||||
const deviationB = b.status === 'high'
|
||||
? (b.value - refB.upper) / (refB.upper - refB.lower)
|
||||
: (refB.lower - b.value) / (refB.upper - refB.lower);
|
||||
? (b.value - refB.upperLimit) / (refB.upperLimit - refB.lowerLimit)
|
||||
: (refB.lowerLimit - b.value) / (refB.upperLimit - refB.lowerLimit);
|
||||
return deviationB - deviationA;
|
||||
})
|
||||
.slice(0, 5);
|
||||
@@ -255,4 +191,14 @@ export class MptService {
|
||||
riskyCows: sortedRiskyCows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 참조값 조회
|
||||
*/
|
||||
getReferenceValues(): { references: Record<string, MptReferenceRange>; categories: MptCategory[] } {
|
||||
return {
|
||||
references: MPT_REFERENCE_RANGES,
|
||||
categories: MPT_CATEGORIES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
FilterEngineResult,
|
||||
SortOption,
|
||||
} from './interfaces/filter.interface';
|
||||
import { PAGINATION_CONFIG } from '../../common/config/PaginationConfig';
|
||||
|
||||
/**
|
||||
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
||||
@@ -160,11 +161,15 @@
|
||||
// 3. 전체 개수 조회 (페이지네이션 전)
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// 4. 페이지네이션 적용
|
||||
if (options.pagination) {
|
||||
const { page, limit } = options.pagination;
|
||||
this.applyPagination(queryBuilder, page, limit);
|
||||
}
|
||||
// 4. 페이지네이션 적용 (기본값: PaginationConfig 사용)
|
||||
const page = options.pagination?.page ?? PAGINATION_CONFIG.LIMITS.DEFAULT_PAGE;
|
||||
const requestedLimit = options.pagination?.limit ?? PAGINATION_CONFIG.DEFAULTS.COW_LIST;
|
||||
// 최대값 제한 적용
|
||||
const limit = Math.min(
|
||||
Math.max(requestedLimit, PAGINATION_CONFIG.LIMITS.MIN),
|
||||
PAGINATION_CONFIG.LIMITS.MAX
|
||||
);
|
||||
this.applyPagination(queryBuilder, page, limit);
|
||||
|
||||
// 5. 데이터 조회
|
||||
const data = await queryBuilder.getMany();
|
||||
@@ -173,15 +178,11 @@
|
||||
const result: FilterEngineResult<T> = {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
|
||||
if (options.pagination) {
|
||||
const { page, limit } = options.pagination;
|
||||
result.page = page;
|
||||
result.limit = limit;
|
||||
result.totalPages = Math.ceil(total / limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user