This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
/**
* ============================================================
* 개체(Cow) 컨트롤러
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 엔드포인트:
* - GET /cow - 기본 개체 목록 조회
* - GET /cow/:id - 개체 상세 조회
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
* ============================================================
*/
import { Controller, Get, Post, Put, Delete, Body, Param, Query } 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
* 랭킹이 적용된 개체 목록 조회
*
* 사용 페이지: 개체 목록 페이지
*/
@Post('ranking')
findAllWithRanking(@Body() rankingRequest: RankingRequestDto) {
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로 시작)
*/
@Get(':cowId')
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);
}
}

View File

@@ -0,0 +1,37 @@
/**
* ============================================================
* 개체(Cow) 모듈
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 등록된 엔티티:
* - CowModel: 개체 기본 정보
* - GenomeRequestModel: 유전체 분석 의뢰
* - GenomeTraitDetailModel: 유전체 형질 상세 (35개 형질)
* ============================================================
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CowController } from './cow.controller';
import { CowService } from './cow.service';
import { CowModel } from './entities/cow.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
@Module({
imports: [
TypeOrmModule.forFeature([
CowModel, // 개체 기본 정보 (tb_cow)
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
]),
FilterEngineModule, // 필터 엔진 모듈
],
controllers: [CowController],
providers: [CowService],
exports: [CowService],
})
export class CowModule {}

View File

@@ -0,0 +1,394 @@
/**
* ============================================================
* 개체(Cow) 서비스
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 주요 기능:
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
* 2. 개체 단건 조회 (findOne, findByCowId)
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
* - GENOME: 35개 형질 EBV 가중 평균
* 4. 개체 CRUD (create, update, remove)
* ============================================================
*/
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { CowModel } from './entities/cow.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
import { FilterEngineService } from '../shared/filter/filter-engine.service';
import {
RankingRequestDto,
RankingCriteriaType,
TraitRankingCondition,
} from './dto/ranking-request.dto';
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
/**
* 개체(소) 관리 서비스
*
* 담당 기능:
* - 개체 CRUD 작업
* - 유전체 기반 랭킹 계산
* - 필터링 및 정렬
*/
@Injectable()
export class CowService {
constructor(
// 개체(소) 테이블 Repository
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
// 유전체 분석 의뢰 Repository (형질 데이터 접근용)
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
// 유전체 형질 상세 Repository (EBV 값 접근용)
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
private readonly filterEngineService: FilterEngineService,
) { }
// ============================================================
// 기본 조회 메서드
// ============================================================
/**
* 전체 개체 목록 조회
*
* @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)로 단건 조회
*
* @param cowId - 개체식별번호 (예: KOR002119144049)
* @returns 개체 정보 (farm 포함)
* @throws NotFoundException - 개체를 찾을 수 없는 경우
*/
async findByCowId(cowId: string): Promise<CowModel> {
const cow = await this.cowRepository.findOne({
where: { cowId: cowId, delDt: IsNull() },
relations: ['farm'],
});
if (!cow) {
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
}
return cow;
}
// ============================================================
// 랭킹 적용 조회 메서드
// ============================================================
/**
* 랭킹 적용 개체 목록 조회 (메인 API)
*
* POST /cow/ranking 에서 호출
*
* 기능:
* 1. 필터 조건으로 개체 목록 조회
* 2. 랭킹 기준(GENOME/GENE)에 따라 점수 계산
* 3. 점수 기준 정렬 후 순위 부여
*
* @param rankingRequest - 필터 옵션 + 랭킹 옵션
* @returns 순위가 적용된 개체 목록
*/
async findAllWithRanking(rankingRequest: RankingRequestDto): Promise<any> {
// Step 1: 요청에서 필터 옵션과 랭킹 옵션 추출
const { filterOptions, rankingOptions } = rankingRequest;
const { criteriaType } = rankingOptions;
// Step 2: 필터 조건에 맞는 개체 목록 조회
const cows = await this.getFilteredCows(filterOptions);
// Step 3: 랭킹 기준에 따라 분기 처리
switch (criteriaType) {
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
case RankingCriteriaType.GENOME:
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
// 기본값: 랭킹 없이 순서대로 반환
default:
return {
items: cows.map((cow, index) => ({
entity: cow,
rank: index + 1,
sortValue: 0,
})),
total: cows.length,
criteriaType,
};
}
}
/**
* 필터 조건에 맞는 개체 목록 조회 (Private)
*
* @param filterOptions - 필터/정렬/페이지네이션 옵션
* @returns 필터링된 개체 목록
*/
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
// QueryBuilder로 기본 쿼리 구성
const queryBuilder = this.cowRepository
.createQueryBuilder('cow')
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
if (filterOptions?.farmNo) {
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
farmNo: filterOptions.farmNo
});
}
// FilterEngine 사용하여 동적 필터 적용
if (filterOptions?.filters) {
const result = await this.filterEngineService.executeFilteredQuery(
queryBuilder,
filterOptions,
);
return result.data;
}
// 필터 없으면 전체 조회 (최신순)
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
}
// ============================================================
// 유전체(GENOME) 랭킹 메서드
// ============================================================
/**
* 유전체 형질 기반 랭킹 적용 (Private)
*
* 계산 방식: 선택한 형질들의 EBV 가중 평균
* - 각 형질에 weight(가중치) 적용 가능
* - 모든 선택 형질이 있어야 점수 계산
*
* @param cows - 필터링된 개체 목록
* @param traitConditions - 형질별 가중치 조건 배열
* @returns 순위가 적용된 개체 목록
* @example
* traitConditions = [
* { traitNm: '도체중', weight: 8 },
* { traitNm: '근내지방도', weight: 10 }
* ]
*/
private async applyGenomeRanking(
cows: CowModel[],
traitConditions: TraitRankingCondition[],
): Promise<any> {
// 각 개체별로 점수 계산
const cowsWithScore = await Promise.all(
cows.map(async (cow) => {
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
const traitDetails = await this.genomeTraitDetailRepository.find({
where: { cowId: cow.cowId, delDt: IsNull() },
});
// 형질 데이터가 없으면 점수 null
if (traitDetails.length === 0) {
return { entity: cow, sortValue: null, details: [] };
}
// Step 2: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
const latestRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
});
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
return { entity: cow, sortValue: null, details: [] };
}
// Step 4: 가중 평균 계산
let weightedSum = 0; // 가중치 적용된 EBV 합계
let totalWeight = 0; // 총 가중치
let hasAllTraits = true; // 모든 선택 형질 존재 여부
const details: any[] = []; // 계산 상세 내역
// 사용자가 선택한 각 형질에 대해 처리
for (const condition of traitConditions) {
// 형질명으로 해당 형질 데이터 찾기
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
const weight = condition.weight || 1; // 가중치 (기본값: 1)
if (trait && trait.traitEbv !== null) {
// EBV 값이 있으면 가중치 적용하여 합산
const ebv = Number(trait.traitEbv);
weightedSum += ebv * weight; // EBV × 가중치
totalWeight += weight; // 가중치 누적
// 상세 내역 저장 (응답용)
details.push({
code: condition.traitNm, // 형질명
value: ebv, // EBV 값
weight, // 적용된 가중치
});
} else {
// 형질이 없으면 플래그 설정
hasAllTraits = false;
}
}
// Step 6: 최종 점수 계산 (가중 평균)
// 모든 선택 형질이 있어야만 점수 계산
const sortValue = (hasAllTraits && totalWeight > 0)
? weightedSum / totalWeight // 가중 평균 = 가중합 / 총가중치
: null;
// Step 7: 응답 데이터 구성
return {
entity: {
...cow,
anlysDt: latestRequest.requestDt, // 분석일자 추가
},
sortValue, // 계산된 종합 점수 (선발지수)
details, // 점수 계산에 사용된 형질별 상세
ranking: {
requestNo: latestRequest.pkRequestNo, // 분석 의뢰 번호
requestDt: latestRequest.requestDt, // 분석 의뢰일
traits: traitDetails.map((d) => ({ // 전체 형질 데이터
traitName: d.traitName, // 형질명
traitVal: d.traitVal, // 실측값
traitEbv: d.traitEbv, // EBV (표준화육종가)
traitPercentile: d.traitPercentile, // 백분위
})),
},
};
}),
);
// ========================================
// 백엔드 응답 예시
// ========================================
// {
// "items": [
// {
// "entity": { "cowId": "KOR123456", "cowNm": "뽀삐", ... },
// "sortValue": 85.5, // 가중 평균 점수
// "rank": 1, // 순위
// "ranking": {
// "requestNo": 100,
// "traits": [
// { "traitName": "도체중", "traitVal": 450, "traitEbv": 12.5 },
// { "traitName": "등심단면적", "traitVal": 95, "traitEbv": 8.3 }
// ]
// }
// }
// ],
// "total": 100,
// "criteriaType": "GENOME"
// }
// Step 8: 점수 기준 내림차순 정렬
const sorted = cowsWithScore.sort((a, b) => {
// null 값은 맨 뒤로
if (a.sortValue === null && b.sortValue === null) return 0;
if (a.sortValue === null) return 1;
if (b.sortValue === null) return -1;
// 점수 높은 순 (내림차순)
return b.sortValue - a.sortValue;
});
// Step 9: 순위 부여 후 반환
return {
items: sorted.map((item, index) => ({
...item,
rank: index + 1, // 1부터 시작하는 순위
})),
total: sorted.length,
criteriaType: RankingCriteriaType.GENOME,
};
}
// ============================================================
// 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);
}
}

View File

@@ -0,0 +1,129 @@
/**
* ============================================================
* 랭킹 요청 DTO
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 프론트에서 POST /cow/ranking 호출 시 사용
*
* 지원하는 랭킹 기준:
* 1. GENOME - 35개 유전체 형질 EBV 가중치 기반
* ============================================================
*/
/**
* 랭킹 기준 타입
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
*/
export enum RankingCriteriaType {
GENOME = 'GENOME',
}
// ============================================================
// 필터 관련 타입 (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;
}
// ============================================================
// 랭킹 조건 타입
// ============================================================
/**
* 유전체 형질 랭킹 조건
* - 35개 형질 중 사용자가 선택한 형질만 대상
* - weight: 1~10 가중치 (10이 100%)
*
* 예: { traitNm: '도체중', weight: 8 }
*/
export interface TraitRankingCondition {
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
weight?: number; // 가중치 1~10 (기본값: 1)
}
/**
* 랭킹 옵션
*/
export interface RankingOptions {
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
limit?: number;
offset?: number;
}
// ============================================================
// 메인 요청 DTO
// ============================================================
/**
* 랭킹 요청 DTO
*
* 프론트에서 POST /cow/ranking 호출 시 Body로 전송
*
* @example
* {
* filterOptions: {
* filters: [{ field: 'cowSex', operator: 'eq', value: 'F' }],
* pagination: { page: 1, limit: 20 }
* },
* rankingOptions: {
* criteriaType: 'GENOME',
* traitConditions: [
* { traitNm: '도체중', weight: 8 },
* { traitNm: '근내지방도', weight: 10 }
* ]
* }
* }
*/
export interface RankingRequestDto {
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
rankingOptions: RankingOptions; // 랭킹 조건
}

View File

@@ -0,0 +1,89 @@
import { BaseModel } from 'src/common/entities/base.entity';
import { FarmModel } from 'src/farm/entities/farm.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* 개체 기본 정보 (tb_cow)
* 암소/수소, 부모 혈통 포함
*/
@Entity({ name: 'tb_cow' })
export class CowModel extends BaseModel {
@PrimaryGeneratedColumn({
name: 'pk_cow_no',
type: 'int',
comment: '내부 PK (자동증가)',
})
pkCowNo: number;
@Column({
name: 'cow_id',
type: 'varchar',
length: 20,
nullable: true,
comment: '개체식별번호 (KOR 또는 KPN)',
})
cowId: string;
@Column({
name: 'cow_sex',
type: 'varchar',
length: 1,
nullable: true,
comment: '성별 (M/F)',
})
cowSex: string;
@Column({
name: 'cow_birth_dt',
type: 'date',
nullable: true,
comment: '생년월일',
})
cowBirthDt: Date;
@Column({
name: 'sire_kpn',
type: 'varchar',
length: 20,
nullable: true,
comment: '부(씨수소) KPN번호',
})
sireKpn: string;
@Column({
name: 'dam_cow_id',
type: 'varchar',
length: 20,
nullable: true,
comment: '모(어미소) 개체식별번호 (KOR)',
})
damCowId: string;
@Column({
name: 'fk_farm_no',
type: 'int',
nullable: true,
comment: '농장번호 FK',
})
fkFarmNo: number;
@Column({
name: 'cow_status',
type: 'varchar',
length: 20,
nullable: true,
comment: '개체상태',
})
cowStatus: string;
// Relations
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fk_farm_no' })
farm: FarmModel;
}