Compare commits

..

52 Commits

Author SHA1 Message Date
NYD
d8e7121b1a fix_text_align 2026-01-08 19:18:52 +09:00
NYD
65d56ecc85 fix_loop_loading 2026-01-08 18:59:56 +09:00
NYD
fce5dcc283 fix_unlimit_loading 2026-01-08 18:55:56 +09:00
c3ccab75c8 Update backend/.env 2026-01-08 09:40:28 +00:00
NYD
dabee8666c fix_cow_detail_page 2026-01-08 18:30:03 +09:00
NYD
f8ff86e4ea update_cow_detail_page 2026-01-08 16:04:01 +09:00
NYD
9e5ffb2c15 update_cow_list_detail_page 2026-01-07 17:56:22 +09:00
NYD
f5b52df26f update_cow_list_ui_2 2026-01-07 15:14:24 +09:00
NYD
dae3808221 update_cow_list_ui 2026-01-07 15:13:42 +09:00
NYD
0780f2e47c Merge branch 'main' of http://gitea.turbosoft.kr:80/turbosoft/genome2025 2026-01-06 17:26:25 +09:00
NYD
261bc4f91f add_cow_data_batch_insert 2026-01-06 17:23:53 +09:00
838b279eb5 주석수정 및 코드정리 2025-12-31 09:56:54 +09:00
42cb317354 주석수정 및 코드정리 2025-12-31 08:13:02 +09:00
NYD
5bad8c5dc4 frontend_btns_wrap_add_claasname 2025-12-30 09:43:45 +09:00
9de32fe394 entity 연결 수정 및 코드정리 2025-12-29 13:55:43 +09:00
5204000d34 service 로직 수정3 2025-12-26 07:58:40 +09:00
c84dc1e96d service 로직 수정2 2025-12-25 16:47:37 +09:00
0d1663e698 service 로직 수정 2025-12-25 15:57:12 +09:00
2877a474eb 파일 정리 2025-12-24 22:50:13 +09:00
05d89fdfcd 미사용 파일정리 2025-12-24 08:25:44 +09:00
1644fcf241 번식 능력 검사 리스트 및 보고서 수정 2025-12-22 19:52:38 +09:00
d3dda3d929 entity 수정 2025-12-19 17:56:09 +09:00
c8bd04f124 개체분석 상태 값 수정 2025-12-19 15:19:50 +09:00
abc2f20495 필터 및 화면 수정사항 반영 2025-12-18 17:01:24 +09:00
4d0f8f3b6b 필터 최소값 수정 2025-12-17 18:27:34 +09:00
a3f0d8bc62 모바일 반응형 ui 수정 2025-12-17 17:52:36 +09:00
7ba2272dc2 유전자 DB 연동 및 필터검색 수정 2025-12-17 17:10:57 +09:00
c0d7408bcf 차트 수정 사항 반영 및 ui 개선 2025-12-17 16:10:49 +09:00
b3915808f2 모바일 인증 화면 ui수정 2025-12-17 10:29:43 +09:00
f1fb7c868a mpt화면 수정 2025-12-16 16:53:23 +09:00
eab96e0bfc mpt 페이지 추가 2025-12-16 16:46:12 +09:00
3d022a1305 필터 기본값 수정 2025-12-15 18:47:26 +09:00
c2b81c19c5 init 2025-12-15 14:41:47 +09:00
26577fb696 init 2025-12-15 14:34:19 +09:00
8d78c3a1dc init 2025-12-15 14:28:09 +09:00
6defb21c4d init 2025-12-15 14:13:12 +09:00
e37c0fd60e init 2025-12-15 14:02:04 +09:00
dacc3a8f8b init 2025-12-15 13:45:03 +09:00
2f4ca350e7 init 2025-12-15 13:44:53 +09:00
8e011748d1 init 2025-12-15 13:44:16 +09:00
26cf9fe3de Merge remote-tracking branch 'origin/main' 2025-12-15 12:00:51 +09:00
370225a1ae 로컬은 되는거 확인함 2025-12-15 12:00:46 +09:00
fea6835956 개발용 설정 2025-12-15 11:43:46 +09:00
4c2f1239a6 redis 잔여 설정도 제거 2025-12-15 11:17:56 +09:00
c52aa10bf9 로그가 왜 2025-12-15 10:30:21 +09:00
d768b8dcef redis 제거 2025-12-15 10:15:28 +09:00
2d06f2fcbc 바쁘다 2025-12-15 10:07:10 +09:00
6b8ea7e74c 로그로그 2025-12-15 09:55:43 +09:00
ef5f921e21 정리2 2025-12-15 09:36:25 +09:00
35c3c85531 정리 2025-12-15 09:30:44 +09:00
7470cea1fb 로그출력 2025-12-15 09:25:17 +09:00
15c0ad0a1a system health 추가 2025-12-15 09:18:49 +09:00
223 changed files with 11328 additions and 103248 deletions

102
.env
View File

@@ -1,102 +0,0 @@
# ==============================================
# DEVELOPMENT ENVIRONMENT VARIABLES
# ==============================================
# Copy this file to .env.local for local development
# DO NOT commit sensitive values to version control
# ==============================================
# DATABASE CONFIGURATION
# ==============================================
DATABASE_URL=postgresql://genome:genome1@3@192.168.11.46:5431/genome_db
POSTGRES_HOST=192.168.11.46
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db
POSTGRES_PORT=5431
POSTGRES_SYNCHRONIZE=true
POSTGRES_LOGGING=true
# ==============================================
# REDIS CONFIGURATION
# ==============================================
REDIS_URL=redis://192.168.11.46:6379
REDIS_HOST=192.168.11.46
REDIS_PORT=6379
# ==============================================
# BACKEND CONFIGURATION
# ==============================================
BACKEND_PORT=4000
NODE_ENV=development
# ==============================================
# JWT AUTHENTICATION
# ==============================================
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRES_IN=7d
# ==============================================
# CORS CONFIGURATION
# ==============================================
CORS_ORIGIN=http://localhost:3000,http://192.168.11.249:3000,http://123.143.174.11:5244
CORS_CREDENTIALS=true
# ==============================================
# SECURITY SETTINGS
# ==============================================
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# ==============================================
# FILE UPLOAD
# ==============================================
MAX_FILE_SIZE=10485760
UPLOAD_DESTINATION=./uploads
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx
# ==============================================
# EMAIL CONFIGURATION
# ==============================================
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
FROM_EMAIL=noreply@yourdomain.com
# ==============================================
# LOGGING
# ==============================================
LOG_LEVEL=debug
LOG_FORMAT=dev
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs
# ==============================================
# EXTERNAL SERVICES
# ==============================================
# AWS_ACCESS_KEY_ID=your-aws-access-key
# AWS_SECRET_ACCESS_KEY=your-aws-secret
# AWS_REGION=us-east-1
# AWS_S3_BUCKET=your-bucket-name
# ==============================================
# MONITORING
# ==============================================
# SENTRY_DSN=your-sentry-dsn
# HEALTH_CHECK_ENABLED=true
# ==============================================
# FRONTEND CONFIGURATION
# ==============================================
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=/api
# ==============================================
# NGINX CONFIGURATION
# ==============================================
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443

View File

@@ -1,102 +0,0 @@
# ==============================================
# DEVELOPMENT ENVIRONMENT VARIABLES
# ==============================================
# Copy this file to .env.local for local development
# DO NOT commit sensitive values to version control
# ==============================================
# DATABASE CONFIGURATION
# ==============================================
DATABASE_URL=postgresql://user:password@localhost:5432/genome_db
POSTGRES_HOST=192.168.11.46
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db
POSTGRES_PORT=5431
POSTGRES_SYNCHRONIZE=true
POSTGRES_LOGGING=true
# ==============================================
# REDIS CONFIGURATION
# ==============================================
REDIS_URL=redis://192.168.11.46:6379
REDIS_HOST=localhost
REDIS_PORT=6379
# ==============================================
# BACKEND CONFIGURATION
# ==============================================
BACKEND_PORT=4000
NODE_ENV=development
# ==============================================
# JWT AUTHENTICATION
# ==============================================
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRES_IN=7d
# ==============================================
# CORS CONFIGURATION
# ==============================================
CORS_ORIGIN=http://localhost:3000,http://192.168.11.46:3000,http://123.143.174.11:5244
CORS_CREDENTIALS=true
# ==============================================
# SECURITY SETTINGS
# ==============================================
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# ==============================================
# FILE UPLOAD
# ==============================================
MAX_FILE_SIZE=10485760
UPLOAD_DESTINATION=./uploads
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx
# ==============================================
# EMAIL CONFIGURATION
# ==============================================
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
FROM_EMAIL=noreply@yourdomain.com
# ==============================================
# LOGGING
# ==============================================
LOG_LEVEL=debug
LOG_FORMAT=dev
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs
# ==============================================
# EXTERNAL SERVICES
# ==============================================
# AWS_ACCESS_KEY_ID=your-aws-access-key
# AWS_SECRET_ACCESS_KEY=your-aws-secret
# AWS_REGION=us-east-1
# AWS_S3_BUCKET=your-bucket-name
# ==============================================
# MONITORING
# ==============================================
# SENTRY_DSN=your-sentry-dsn
# HEALTH_CHECK_ENABLED=true
# ==============================================
# FRONTEND CONFIGURATION
# ==============================================
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=/api
# ==============================================
# NGINX CONFIGURATION
# ==============================================
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443

View File

@@ -1,79 +0,0 @@
# ==============================================
# ENVIRONMENT VARIABLES TEMPLATE
# ==============================================
# Copy this file to .env and fill in your actual values
# NEVER commit real credentials to version control
# ==============================================
# DATABASE CONFIGURATION
# ==============================================
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=your_database_name
# ==============================================
# REDIS CONFIGURATION
# ==============================================
REDIS_URL=redis://localhost:6379
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password_if_needed
# ==============================================
# BACKEND CONFIGURATION
# ==============================================
BACKEND_PORT=4000
NODE_ENV=development
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
JWT_EXPIRES_IN=24h
API_PREFIX=/api/v1
# ==============================================
# FRONTEND CONFIGURATION
# ==============================================
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_APP_NAME=Next Nest Docker Template
# ==============================================
# NGINX CONFIGURATION
# ==============================================
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
# ==============================================
# CORS CONFIGURATION
# ==============================================
CORS_ORIGIN=http://localhost:3000,http://localhost:80
CORS_CREDENTIALS=true
# ==============================================
# SECURITY SETTINGS
# ==============================================
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# ==============================================
# EMAIL CONFIGURATION (Optional)
# ==============================================
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-password
# ==============================================
# EXTERNAL SERVICES (Optional)
# ==============================================
# AWS_ACCESS_KEY_ID=your-aws-access-key
# AWS_SECRET_ACCESS_KEY=your-aws-secret-key
# AWS_REGION=us-east-1
# AWS_S3_BUCKET=your-bucket-name
# ==============================================
# MONITORING & LOGGING (Optional)
# ==============================================
# LOG_LEVEL=info
# SENTRY_DSN=your-sentry-dsn
# MONITORING_ENABLED=true

View File

@@ -1,77 +0,0 @@
# ==============================================
# PRODUCTION ENVIRONMENT VARIABLES
# ==============================================
# This file contains production environment variable templates
# DO NOT use default values in production!
# ==============================================
# DATABASE CONFIGURATION
# ==============================================
DATABASE_URL=postgresql://prod_user:STRONG_PASSWORD@postgres:5432/prod_db
POSTGRES_USER=prod_user
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
POSTGRES_DB=prod_db
# ==============================================
# REDIS CONFIGURATION
# ==============================================
REDIS_URL=redis://redis:6379
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=REDIS_STRONG_PASSWORD
# ==============================================
# BACKEND CONFIGURATION
# ==============================================
BACKEND_PORT=4000
NODE_ENV=production
JWT_SECRET=SUPER_SECURE_JWT_SECRET_AT_LEAST_32_CHARACTERS_LONG
JWT_EXPIRES_IN=1h
API_PREFIX=/api/v1
# ==============================================
# FRONTEND CONFIGURATION
# ==============================================
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=https://your-domain.com
NEXT_PUBLIC_APP_NAME=Your App Name
# ==============================================
# NGINX CONFIGURATION
# ==============================================
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
# ==============================================
# CORS CONFIGURATION
# ==============================================
CORS_ORIGIN=https://your-domain.com
CORS_CREDENTIALS=true
# ==============================================
# SECURITY SETTINGS
# ==============================================
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=50
BCRYPT_SALT_ROUNDS=15
# ==============================================
# SSL CONFIGURATION
# ==============================================
SSL_CERT_PATH=/etc/nginx/ssl/cert.pem
SSL_KEY_PATH=/etc/nginx/ssl/key.pem
# ==============================================
# MONITORING & LOGGING
# ==============================================
LOG_LEVEL=warn
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
MONITORING_ENABLED=true
# ==============================================
# EXTERNAL SERVICES
# ==============================================
# AWS_ACCESS_KEY_ID=your-production-aws-key
# AWS_SECRET_ACCESS_KEY=your-production-aws-secret
# AWS_REGION=us-east-1
# AWS_S3_BUCKET=your-production-bucket

101
.gitignore vendored
View File

@@ -1,101 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# .env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Docker
.dockerignore
# IDE and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Database
*.sqlite
*.sqlite3
*.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Temporary folders
tmp/
temp/
.tmp/
.temp/
# OS generated files
Thumbs.db
ehthumbs.db
# SSL certificates
*.key
*.crt
*.pem
ssl/
# Backup files
*.bak
*.backup

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 쿼리 파일을 포함한 무시된 디폴트 폴더
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/

6
.idea/PMDPlugin.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PMDPlugin">
<option name="skipTestSources" value="false" />
</component>
</project>

8
.idea/genome2025.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/genome2025.iml" filepath="$PROJECT_DIR$/.idea/genome2025.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

11
.project Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>genome2025</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
</buildSpec>
<natures>
</natures>
</projectDescription>

290
README.md
View File

@@ -1,290 +0,0 @@
# Next.js + NestJS + Docker Template
A full-stack TypeScript template with Next.js frontend, NestJS backend, PostgreSQL, Redis, and Nginx, all containerized with Docker.
## 🚀 Quick Start
## 개발 환경 구축
```bash
# Clone the repository
git clone <repository-url>
cd next_nest_docker_template
# Copy environment variables
cp .env.example .env
# Start development environment
docker compose up -d
# View logs
docker compose logs -f
```
Your application will be available at:
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:4000
- **Nginx Proxy**: http://localhost:80
## 📁 Project Structure
```
├── frontend/ # Next.js application
│ ├── src/ # Source code
│ ├── Dockerfile # Development Dockerfile
│ └── .env.local.example # Frontend environment variables
├── backend/ # NestJS application
│ ├── src/ # Source code
│ ├── Dockerfile # Development Dockerfile
│ └── .env.example # Backend environment variables
├── nginx/ # Nginx configuration
│ ├── nginx.conf # Proxy configuration
│ └── ssl/ # SSL certificates directory
├── .env # Main environment variables
├── .env.example # Environment template
├── .env.production # Production environment template
├── docker-compose.yml # Development containers
└── docker-compose.prod.yml # Production containers
```
## 🛠️ Technology Stack
### Frontend
- **Next.js 15.5.3** - React framework with Turbopack
- **React 19.1.0** - UI library
- **TypeScript** - Type safety
- **Tailwind CSS** - Styling
### Backend
- **NestJS 11** - Node.js framework
- **TypeScript** - Type safety
- **Express** - HTTP server
### Database & Cache
- **PostgreSQL 15** - Primary database
- **Redis 7** - Caching and sessions
### Infrastructure
- **Docker & Docker Compose** - Containerization
- **Nginx** - Reverse proxy and load balancer
## 🔧 Environment Configuration
### Development Setup
1. Copy the environment template:
```bash
cp .env.example .env
```
2. Configure your variables in `.env`:
```env
# Database
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=mydb
# Security
JWT_SECRET=your-super-secret-jwt-key
CORS_ORIGIN=http://localhost:3000
# Ports
FRONTEND_PORT=3000
BACKEND_PORT=4000
```
### Production Setup
1. Copy the production template:
```bash
cp .env.production .env
```
2. Update with your production values:
```env
# Use strong passwords in production
POSTGRES_PASSWORD=STRONG_PRODUCTION_PASSWORD
JWT_SECRET=SUPER_SECURE_JWT_SECRET_AT_LEAST_32_CHARACTERS_LONG
# Use your domain
NEXT_PUBLIC_API_URL=https://your-domain.com
CORS_ORIGIN=https://your-domain.com
```
## 🐳 Docker Commands
### Development
```bash
# Start all services
docker compose up -d
# Rebuild and start
docker compose up -d --build
# View logs
docker compose logs -f [service-name]
# Stop services
docker compose down
# Remove volumes (⚠️ deletes data)
docker compose down -v
```
### Production
```bash
# Start production environment
docker compose -f docker-compose.prod.yml up -d
# Build and deploy
docker compose -f docker-compose.prod.yml up -d --build
```
## 🔍 Service Details
### Frontend (Next.js)
- **Port**: 3000 (configurable via `FRONTEND_PORT`)
- **Development**: Hot reload enabled
- **Build**: Optimized production build with Turbopack
### Backend (NestJS)
- **Port**: 4000 (configurable via `BACKEND_PORT`)
- **Development**: Watch mode enabled
- **Features**: JWT auth, CORS, rate limiting
### Database (PostgreSQL)
- **Port**: 5432 (configurable via `POSTGRES_PORT`)
- **Volume**: `postgres_data` for persistence
- **Health Check**: Built-in readiness check
### Cache (Redis)
- **Port**: 6379 (configurable via `REDIS_PORT`)
- **Volume**: `redis_data` for persistence
- **Health Check**: Ping command
### Proxy (Nginx)
- **Ports**: 80 (HTTP), 443 (HTTPS)
- **Features**: Load balancing, SSL termination
- **Configuration**: `nginx/nginx.conf`
## 🔐 Security Features
- **JWT Authentication** - Secure API access
- **CORS Configuration** - Cross-origin request control
- **Rate Limiting** - API abuse prevention
- **Environment Variables** - Secure configuration management
- **SSL Support** - HTTPS encryption ready
## 🚦 Health Checks
All services include health checks:
- **Frontend**: HTTP GET to `/`
- **Backend**: HTTP GET to `/`
- **PostgreSQL**: `pg_isready` command
- **Redis**: `redis-cli ping` command
## 📝 Development Workflow
1. **Setup Environment**:
```bash
cp .env.example .env
# Edit .env with your settings
```
2. **Start Development**:
```bash
docker compose up -d
```
3. **Develop**:
- Frontend: Edit files in `frontend/src/`
- Backend: Edit files in `backend/src/`
- Changes are automatically reflected due to volume mounts
4. **View Logs**:
```bash
docker compose logs -f frontend
docker compose logs -f backend
```
5. **Database Access**:
```bash
docker exec -it postgres-db psql -U user -d mydb
```
6. **Redis Access**:
```bash
docker exec -it redis-cache redis-cli
```
## 🔧 Customization
### Adding New Services
1. Add service to `docker-compose.yml`
2. Update environment variables
3. Configure networking and dependencies
### SSL Configuration
1. Place certificates in `nginx/ssl/`
2. Update `nginx.conf` for HTTPS
3. Update environment variables for HTTPS URLs
### Environment Variables
All configurable values use environment variables with sensible defaults:
- See `.env.example` for full list
- Override any value in your `.env` file
- Production values in `.env.production`
## 🐛 Troubleshooting
### Common Issues
**Port Conflicts**:
```bash
# Change ports in .env
FRONTEND_PORT=3001
BACKEND_PORT=4001
```
**Permission Issues**:
```bash
# Fix file permissions
sudo chown -R $USER:$USER .
```
**Database Connection**:
```bash
# Check database logs
docker compose logs postgres
```
**Container Not Starting**:
```bash
# Check specific service logs
docker compose logs [service-name]
```
### Reset Everything
```bash
# Stop and remove everything
docker compose down -v
docker system prune -f
# Start fresh
docker compose up -d --build
```
## 📄 License
This project is licensed under the MIT License.
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
---
**Happy coding!** 🎉

50
backend/.env Normal file
View File

@@ -0,0 +1,50 @@
# ==============================================
# 로컬 개발용 (npm run start:dev)
# ==============================================
# DATABASE
POSTGRES_HOST=192.168.11.46
# POSTGRES_HOST=localhost
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db
POSTGRES_PORT=5431
POSTGRES_SYNCHRONIZE=true
POSTGRES_LOGGING=true
# BACKEND
BACKEND_PORT=4000
NODE_ENV=development
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRES_IN=7d
# CORS
CORS_ORIGIN=http://localhost:3000,http://192.168.11.46:3000
CORS_CREDENTIALS=true
# SECURITY
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# FILE UPLOAD
MAX_FILE_SIZE=10485760
UPLOAD_DESTINATION=./uploads
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx
# EMAIL (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=turbosoft11@gmail.com
SMTP_PASS="kojl sxbx pdfi yhxz"
FROM_EMAIL=turbosoft11@gmail.com
# LOGGING
LOG_LEVEL=debug
LOG_FORMAT=dev
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs

49
backend/.env.dev Normal file
View File

@@ -0,0 +1,49 @@
# ==============================================
# Docker 개발 환경용 (docker-compose)
# ==============================================
# DATABASE - Docker에서 호스트 DB 접근
POSTGRES_HOST=192.168.11.46
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db
POSTGRES_PORT=5431
POSTGRES_SYNCHRONIZE=true
POSTGRES_LOGGING=true
# BACKEND
BACKEND_PORT=4000
NODE_ENV=development
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRES_IN=7d
# CORS
CORS_ORIGIN=http://localhost:3000,http://192.168.11.46:3000,https://genome2025.turbosoft.kr
CORS_CREDENTIALS=true
# SECURITY
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# FILE UPLOAD
MAX_FILE_SIZE=10485760
UPLOAD_DESTINATION=./uploads
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx
# EMAIL (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=turbosoft11@gmail.com
SMTP_PASS="kojl sxbx pdfi yhxz"
FROM_EMAIL=turbosoft11@gmail.com
# LOGGING
LOG_LEVEL=debug
LOG_FORMAT=dev
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs

49
backend/.env.prod Normal file
View File

@@ -0,0 +1,49 @@
# ==============================================
# 프로덕션 환경용 (배포)
# ==============================================
# DATABASE
POSTGRES_HOST=192.168.11.46
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db
POSTGRES_PORT=5431
POSTGRES_SYNCHRONIZE=false
POSTGRES_LOGGING=false
# BACKEND
BACKEND_PORT=4000
NODE_ENV=production
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRES_IN=7d
# CORS
CORS_ORIGIN=https://genome2025.turbosoft.kr
CORS_CREDENTIALS=true
# SECURITY
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
BCRYPT_SALT_ROUNDS=12
# FILE UPLOAD
MAX_FILE_SIZE=10485760
UPLOAD_DESTINATION=./uploads
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx
# EMAIL (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=turbosoft11@gmail.com
SMTP_PASS="kojl sxbx pdfi yhxz"
FROM_EMAIL=turbosoft11@gmail.com
# LOGGING
LOG_LEVEL=warn
LOG_FORMAT=combined
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs

58
backend/.gitignore vendored
View File

@@ -1,9 +1,21 @@
# compiled output
/dist
# ==============================================
# Dependencies
# ==============================================
/node_modules
# ==============================================
# Build Output
# ==============================================
/dist
/build
# ==============================================
# Environment Variables (민감정보)
# ==============================================
# ==============================================
# Logs
# ==============================================
logs
*.log
npm-debug.log*
@@ -12,14 +24,15 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
# ==============================================
# Testing
# ==============================================
/coverage
/.nyc_output
# IDEs and editors
# ==============================================
# IDE & Editors
# ==============================================
/.idea
.project
.classpath
@@ -27,30 +40,35 @@ lerna-debug.log*
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# ==============================================
# OS Files
# ==============================================
.DS_Store
Thumbs.db
# temp directory
# ==============================================
# Temp & Runtime
# ==============================================
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# ==============================================
# Diagnostic Reports
# ==============================================
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# ==============================================
# Uploads (로컬 테스트용)
# ==============================================
/uploads/*
!/uploads/.gitkeep

View File

@@ -5,20 +5,27 @@ WORKDIR /app
# 필요한 패키지 설치
RUN apk add --no-cache curl
# 환경변수 설정
ENV NODE_ENV=production
ENV NODE_OPTIONS="--enable-source-maps"
# package.json 복사
COPY package*.json ./
# 의존성 설치
RUN npm install
# 의존성 설치 (devDependencies 포함 - nest CLI 필요)
RUN npm install --include=dev
# 소스 코드 복사
COPY . .
# NestJS 빌드
RUN npm run build
RUN npx nest build
# devDependencies 제거
RUN npm prune --production
# 포트 노출
EXPOSE 4000
# 애플리케이션 실행
CMD ["npm", "run", "start:prod"]
CMD ["node", "dist/main.js"]

View File

@@ -1,95 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
// 1. 해당 개체의 형질 데이터 확인
const cowTraitsResult = await conn.query(
"SELECT trait_name, trait_ebv FROM tb_genome_trait_detail WHERE cow_id = 'KOR002191643715' AND del_dt IS NULL ORDER BY trait_name"
);
const cowTraits = cowTraitsResult.rows;
console.log('=== 개체 KOR002191643715 형질 데이터 ===');
console.log('형질수:', cowTraits.length);
let totalEbv = 0;
cowTraits.forEach(t => {
console.log(t.trait_name + ': ' + t.trait_ebv);
totalEbv += Number(t.trait_ebv || 0);
});
console.log('\n*** 내 개체 EBV 합계(선발지수):', totalEbv.toFixed(2));
// 2. 해당 개체의 농가 확인
const cowInfoResult = await conn.query(
"SELECT gr.fk_farm_no, f.farmer_name FROM tb_genome_request gr JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no LEFT JOIN tb_farm f ON gr.fk_farm_no = f.pk_farm_no WHERE c.cow_id = 'KOR002191643715' AND gr.del_dt IS NULL LIMIT 1"
);
const cowInfo = cowInfoResult.rows;
console.log('\n=== 농가 정보 ===');
console.log('농가번호:', cowInfo[0]?.fk_farm_no, '농장주:', cowInfo[0]?.farmer_name);
const farmNo = cowInfo[0]?.fk_farm_no;
// 3. 같은 농가의 모든 개체 EBV 합계
if (farmNo) {
const farmCowsResult = await conn.query(
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY total_ebv DESC`,
[farmNo]
);
const farmCows = farmCowsResult.rows;
console.log('\n=== 같은 농가 개체들 EBV 합계 (35형질 전체) ===');
console.log('개체수:', farmCows.length);
let farmSum = 0;
farmCows.forEach(c => {
console.log(c.cow_id + ': ' + Number(c.total_ebv).toFixed(2));
farmSum += Number(c.total_ebv);
});
if (farmCows.length > 0) {
console.log('\n*** 농가 평균:', (farmSum / farmCows.length).toFixed(2));
}
}
// 4. 전체 보은군 평균
const allCowsResult = await conn.query(
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35`
);
const allCows = allCowsResult.rows;
console.log('\n=== 보은군 전체 통계 ===');
console.log('개체수:', allCows.length);
let regionSum = 0;
allCows.forEach(c => regionSum += Number(c.total_ebv));
if (allCows.length > 0) {
console.log('*** 보은군 평균:', (regionSum / allCows.length).toFixed(2));
}
// 5. 최대/최소 확인
if (allCows.length > 0) {
const maxCow = allCows.reduce((max, c) => Number(c.total_ebv) > Number(max.total_ebv) ? c : max, allCows[0]);
const minCow = allCows.reduce((min, c) => Number(c.total_ebv) < Number(min.total_ebv) ? c : min, allCows[0]);
console.log('\n최대:', maxCow?.cow_id, Number(maxCow?.total_ebv).toFixed(2));
console.log('최소:', minCow?.cow_id, Number(minCow?.total_ebv).toFixed(2));
}
await conn.end();
}
main().catch(console.error);

View File

@@ -1,97 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
// getComparisonAverages가 계산하는 방식 확인
// 농가 1번의 카테고리별 평균 EBV 계산
const farmNo = 1;
// 카테고리 매핑 (백엔드와 동일)
const TRAIT_CATEGORY_MAP = {
'12개월령체중': '성장',
'도체중': '생산', '등심단면적': '생산', '등지방두께': '생산', '근내지방도': '생산',
'체고': '체형', '십자': '체형', '체장': '체형', '흉심': '체형', '흉폭': '체형',
'고장': '체형', '요각폭': '체형', '곤폭': '체형', '좌골폭': '체형', '흉위': '체형',
'안심weight': '무게', '등심weight': '무게', '채끝weight': '무게', '목심weight': '무게',
'앞다리weight': '무게', '우둔weight': '무게', '설도weight': '무게', '사태weight': '무게',
'양지weight': '무게', '갈비weight': '무게',
'안심rate': '비율', '등심rate': '비율', '채끝rate': '비율', '목심rate': '비율',
'앞다리rate': '비율', '우둔rate': '비율', '설도rate': '비율', '사태rate': '비율',
'양지rate': '비율', '갈비rate': '비율',
};
// 농가 1번의 모든 형질 데이터 조회
const result = await conn.query(`
SELECT gtd.trait_name, gtd.trait_ebv
FROM tb_genome_trait_detail gtd
JOIN tb_genome_request gr ON gtd.fk_request_no = gr.pk_request_no
WHERE gr.fk_farm_no = $1
AND gtd.del_dt IS NULL
AND gtd.trait_ebv IS NOT NULL
`, [farmNo]);
const details = result.rows;
console.log('농가 1번 전체 형질 데이터 수:', details.length);
// 카테고리별로 합계 계산
const categoryMap = {};
for (const d of details) {
const category = TRAIT_CATEGORY_MAP[d.trait_name] || '기타';
if (!categoryMap[category]) {
categoryMap[category] = { sum: 0, count: 0 };
}
categoryMap[category].sum += Number(d.trait_ebv);
categoryMap[category].count += 1;
}
console.log('\n=== getComparisonAverages 방식 (카테고리별 평균) ===');
const categories = ['성장', '생산', '체형', '무게', '비율'];
let totalAvgEbv = 0;
for (const cat of categories) {
const data = categoryMap[cat];
const avgEbv = data ? data.sum / data.count : 0;
console.log(`${cat}: 합계=${data?.sum?.toFixed(2)} / 개수=${data?.count} = 평균 ${avgEbv.toFixed(2)}`);
totalAvgEbv += avgEbv;
}
console.log('\n*** farmAvgZ (카테고리 평균의 합/5):', (totalAvgEbv / categories.length).toFixed(2));
// getSelectionIndex 방식 비교
console.log('\n=== getSelectionIndex 방식 (개체별 합계의 평균) ===');
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY total_ebv DESC
`, [farmNo]);
const farmCows = farmCowsResult.rows;
let farmSum = 0;
farmCows.forEach(c => farmSum += Number(c.total_ebv));
const farmAvgScore = farmCows.length > 0 ? farmSum / farmCows.length : 0;
console.log(`개체수: ${farmCows.length}`);
console.log(`*** farmAvgScore (개체별 합계의 평균): ${farmAvgScore.toFixed(2)}`);
console.log('\n=================================================');
console.log('farmAvgZ (카테고리 방식):', (totalAvgEbv / categories.length).toFixed(2));
console.log('farmAvgScore (선발지수 방식):', farmAvgScore.toFixed(2));
console.log('=================================================');
await conn.end();
}
main().catch(console.error);

View File

@@ -1,114 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const farmNo = 1;
const cowId = 'KOR002191643715';
console.log('=======================================================');
console.log('대시보드 vs 개체상세 차트 비교');
console.log('=======================================================\n');
// =====================================================
// 1. 대시보드: getFarmRegionRanking API
// - 농가 평균 = 농가 내 개체들의 선발지수 평균
// - 보은군 평균 = 전체 개체들의 선발지수 평균
// =====================================================
console.log('=== 1. 대시보드 (getFarmRegionRanking) ===');
console.log('보은군 내 농가 위치 차트\n');
// 모든 개체별 선발지수 (35개 형질 EBV 합계)
const allCowsResult = await conn.query(`
SELECT c.cow_id, gr.fk_farm_no, SUM(gtd.trait_ebv) as total_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id, gr.fk_farm_no
HAVING COUNT(*) = 35
`);
const allCows = allCowsResult.rows;
// 농가별 평균 계산
const farmScoresMap = new Map();
for (const cow of allCows) {
const fNo = cow.fk_farm_no;
if (!farmScoresMap.has(fNo)) {
farmScoresMap.set(fNo, []);
}
farmScoresMap.set(fNo, [...farmScoresMap.get(fNo), Number(cow.total_ebv)]);
}
// 농가별 평균
const farmAverages = [];
for (const [fNo, scores] of farmScoresMap.entries()) {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
farmAverages.push({ farmNo: fNo, avgScore: avg, cowCount: scores.length });
}
farmAverages.sort((a, b) => b.avgScore - a.avgScore);
// 보은군 전체 평균 (개체별 합계의 평균)
const regionAvgScore_dashboard = allCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / allCows.length;
// 농가 1번 평균
const myFarm = farmAverages.find(f => f.farmNo === farmNo);
const farmAvgScore_dashboard = myFarm?.avgScore || 0;
console.log('농가 평균 (개체 선발지수 평균):', farmAvgScore_dashboard.toFixed(2));
console.log('보은군 평균 (개체 선발지수 평균):', regionAvgScore_dashboard.toFixed(2));
console.log('차이 (농가 - 보은군):', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
// =====================================================
// 2. 개체 상세: getSelectionIndex API
// - 내 개체 = 개체의 선발지수 (35개 형질 EBV 합계)
// - 농가 평균 = 같은 농가 개체들의 선발지수 평균
// - 보은군 평균 = 전체 개체들의 선발지수 평균
// =====================================================
console.log('\n=== 2. 개체 상세 (getSelectionIndex) ===');
console.log('농가 및 보은군 내 개체 위치 차트\n');
// 내 개체 선발지수
const myCow = allCows.find(c => c.cow_id === cowId);
const myScore = myCow ? Number(myCow.total_ebv) : 0;
// 같은 농가 개체들의 평균
const farmCows = allCows.filter(c => c.fk_farm_no === farmNo);
const farmAvgScore_detail = farmCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / farmCows.length;
// 보은군 전체 평균
const regionAvgScore_detail = regionAvgScore_dashboard; // 동일
console.log('내 개체 선발지수:', myScore.toFixed(2));
console.log('농가 평균:', farmAvgScore_detail.toFixed(2));
console.log('보은군 평균:', regionAvgScore_detail.toFixed(2));
console.log('');
console.log('내 개체 vs 농가평균:', (myScore - farmAvgScore_detail).toFixed(2));
console.log('내 개체 vs 보은군평균:', (myScore - regionAvgScore_detail).toFixed(2));
// =====================================================
// 3. 비교 요약
// =====================================================
console.log('\n=======================================================');
console.log('비교 요약');
console.log('=======================================================');
console.log('');
console.log('[대시보드] 농가 vs 보은군 차이:', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
console.log('[개체상세] 개체 vs 농가 차이:', (myScore - farmAvgScore_detail).toFixed(2));
console.log('[개체상세] 개체 vs 보은군 차이:', (myScore - regionAvgScore_detail).toFixed(2));
console.log('');
console.log('=> 대시보드는 농가평균 vs 보은군평균 비교 (차이 작음)');
console.log('=> 개체상세는 개별개체 vs 평균 비교 (개체가 우수하면 차이 큼)');
await conn.end();
}
main().catch(console.error);

View File

@@ -1,126 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const cowId = 'KOR002191643715';
const farmNo = 1;
console.log('=======================================================');
console.log('선발지수 계산 방식 비교 분석');
console.log('=======================================================\n');
// 1. 해당 개체의 35개 형질 EBV 확인
const traitsResult = await conn.query(`
SELECT trait_name, trait_ebv
FROM tb_genome_trait_detail
WHERE cow_id = $1 AND del_dt IS NULL
ORDER BY trait_name
`, [cowId]);
const traits = traitsResult.rows;
console.log('=== 개체 형질 데이터 ===');
console.log('형질 수:', traits.length);
// EBV 합계
const ebvSum = traits.reduce((sum, t) => sum + Number(t.trait_ebv || 0), 0);
console.log('EBV 합계:', ebvSum.toFixed(2));
// EBV 평균
const ebvAvg = ebvSum / traits.length;
console.log('EBV 평균:', ebvAvg.toFixed(2));
console.log('\n=== 선발지수 계산 방식 비교 ===\n');
// 방식 1: EBV 합계 (getSelectionIndex 방식)
console.log('방식1 - EBV 합계 (weightedSum):', ebvSum.toFixed(2));
// 방식 2: EBV 평균
console.log('방식2 - EBV 평균 (sum/count):', ebvAvg.toFixed(2));
// 농가/보은군 평균도 각 방식으로 계산
console.log('\n=== 농가 평균 계산 방식 비교 ===\n');
// 농가 내 모든 개체
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
`, [farmNo]);
const farmCows = farmCowsResult.rows;
// 방식 1: 개체별 합계의 평균
const farmSumAvg = farmCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / farmCows.length;
console.log('방식1 - 개체별 합계의 평균:', farmSumAvg.toFixed(2));
// 방식 2: 개체별 평균의 평균
const farmAvgAvg = farmCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / farmCows.length;
console.log('방식2 - 개체별 평균의 평균:', farmAvgAvg.toFixed(2));
console.log('\n=== 보은군 평균 계산 방식 비교 ===\n');
// 보은군 전체 개체
const allCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
`);
const allCows = allCowsResult.rows;
// 방식 1: 개체별 합계의 평균
const regionSumAvg = allCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / allCows.length;
console.log('방식1 - 개체별 합계의 평균:', regionSumAvg.toFixed(2));
// 방식 2: 개체별 평균의 평균
const regionAvgAvg = allCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / allCows.length;
console.log('방식2 - 개체별 평균의 평균:', regionAvgAvg.toFixed(2));
console.log('\n=======================================================');
console.log('결과 비교');
console.log('=======================================================\n');
console.log('만약 "합계" 방식 사용 시:');
console.log(' 내 개체:', ebvSum.toFixed(2));
console.log(' 농가 평균:', farmSumAvg.toFixed(2));
console.log(' 보은군 평균:', regionSumAvg.toFixed(2));
console.log(' → 내 개체 vs 농가: +', (ebvSum - farmSumAvg).toFixed(2));
console.log(' → 내 개체 vs 보은군: +', (ebvSum - regionSumAvg).toFixed(2));
console.log('\n만약 "평균" 방식 사용 시:');
console.log(' 내 개체:', ebvAvg.toFixed(2));
console.log(' 농가 평균:', farmAvgAvg.toFixed(2));
console.log(' 보은군 평균:', regionAvgAvg.toFixed(2));
console.log(' → 내 개체 vs 농가: +', (ebvAvg - farmAvgAvg).toFixed(2));
console.log(' → 내 개체 vs 보은군: +', (ebvAvg - regionAvgAvg).toFixed(2));
console.log('\n=======================================================');
console.log('리스트 선발지수 확인 (page.tsx의 GENOMIC_TRAITS)');
console.log('=======================================================\n');
// 리스트에서 보여주는 선발지수는 어떻게 계산되나?
// page.tsx:350-356 확인 필요
console.log('리스트의 overallScore 계산식 확인 필요:');
console.log(' - selectionIndex.score 사용 시: 합계 방식');
console.log(' - GENOMIC_TRAITS.reduce / length 사용 시: 평균 방식');
await conn.end();
}
main().catch(console.error);

View File

@@ -1,47 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const farmNo = 1;
// 농가 내 모든 개체의 선발지수(가중 합계) 조회
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY sum_ebv DESC
`, [farmNo]);
const farmCows = farmCowsResult.rows;
console.log('=== 농가 1번 개체별 선발지수 (가중 합계) ===\n');
let total = 0;
farmCows.forEach((c, i) => {
const score = Number(c.sum_ebv);
total += score;
console.log(`${i+1}. ${c.cow_id}: ${score.toFixed(2)}`);
});
console.log('\n=== 계산 ===');
console.log('개체 수:', farmCows.length);
console.log('선발지수 총합:', total.toFixed(2));
console.log('농가 평균 = 총합 / 개체수 =', total.toFixed(2), '/', farmCows.length, '=', (total / farmCows.length).toFixed(2));
await conn.end();
}
main().catch(console.error);

View File

@@ -1,66 +0,0 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const cowId = 'KOR002191643715';
console.log('=======================================================');
console.log('리스트 vs 개체상세 선발지수 비교');
console.log('=======================================================\n');
// 해당 개체의 35개 형질 EBV 조회
const traitsResult = await conn.query(`
SELECT trait_name, trait_ebv
FROM tb_genome_trait_detail
WHERE cow_id = $1 AND del_dt IS NULL
ORDER BY trait_name
`, [cowId]);
const traits = traitsResult.rows;
console.log('형질 수:', traits.length);
// 1. 가중 합계 (weight = 1)
let weightedSum = 0;
let totalWeight = 0;
traits.forEach(t => {
const ebv = Number(t.trait_ebv);
const weight = 1;
weightedSum += ebv * weight;
totalWeight += weight;
});
console.log('\n=== 계산 비교 ===');
console.log('가중 합계 (weightedSum):', weightedSum.toFixed(2));
console.log('총 가중치 (totalWeight):', totalWeight);
console.log('');
console.log('리스트 (cow.service.ts) - 가중 합계:', weightedSum.toFixed(2));
console.log('개체상세 (genome.service.ts) - 가중 합계:', weightedSum.toFixed(2));
console.log('\n=== 프론트엔드 가중치 확인 ===');
console.log('프론트엔드에서 weight / 100 정규화 확인 필요');
console.log('예: weight 100 → 1, weight 50 → 0.5');
// 만약 프론트에서 weight/100을 적용한다면?
console.log('\n=== 만약 weight가 0.01로 적용된다면? ===');
let weightedSum2 = 0;
let totalWeight2 = 0;
traits.forEach(t => {
const ebv = Number(t.trait_ebv);
const weight = 0.01; // 1/100
weightedSum2 += ebv * weight;
totalWeight2 += weight;
});
console.log('가중 합계:', weightedSum2.toFixed(2));
await conn.end();
}
main().catch(console.error);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,559 +0,0 @@
DELETE FROM "tb_genome_cow";
/*!40000 ALTER TABLE "tb_genome_cow" DISABLE KEYS */;
INSERT INTO "tb_genome_cow" ("reg_dt", "updt_dt", "reg_ip", "reg_user_id", "updt_ip", "updt_user_id", "pk_genome_cow_no", "fk_genome_no", "fk_trait_no", "trait_val", "breed_val", "percentile") VALUES
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 1, 1, 1, 73.20, 2.30, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 2, 1, 2, 76.30, 1.90, 2),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 3, 1, 3, 14.80, 1.90, 2),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 4, 1, 4, -2.20, -1.10, 12),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 5, 1, 5, 0.30, -0.50, 67),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 6, 1, 6, 5.60, 2.00, 2),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 7, 1, 7, 6.70, 2.30, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 8, 1, 8, 9.20, 2.40, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 9, 1, 9, 0.60, 0.30, 39),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 10, 1, 10, 2.60, 2.10, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 11, 1, 11, 2.50, 1.90, 2),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 12, 1, 12, 1.80, 1.40, 8),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 13, 1, 13, 3.80, 2.50, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 14, 1, 14, 2.00, 2.40, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 15, 1, 15, 4.20, 1.00, 16),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 16, 1, 16, 1.50, 2.50, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 17, 1, 17, 6.70, 1.70, 4),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 18, 1, 18, 2.10, 2.40, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 19, 1, 19, 4.70, 2.80, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 20, 1, 20, 6.30, 2.10, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 21, 1, 21, 5.00, 2.10, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 22, 1, 22, 9.60, 2.40, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 23, 1, 23, 3.90, 2.10, 2),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 24, 1, 24, 6.10, 2.10, 1),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 25, 1, 25, 7.20, 1.00, 15),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 26, 1, 26, 0.10, 1.80, 3),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 27, 1, 27, 0.10, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 28, 1, 28, 0.20, 1.60, 5),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 29, 1, 29, 0.50, 2.90, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 30, 1, 30, 0.40, 1.70, 4),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 31, 1, 31, 0.30, 1.40, 8),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 32, 1, 32, 0.90, 2.60, 0),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 33, 1, 33, 0.30, 1.80, 3),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 34, 1, 34, 0.20, 1.20, 10),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 35, 1, 35, -0.90, -2.90, 99),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 36, 2, 1, 26.70, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 37, 2, 2, 26.00, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 38, 2, 3, 4.60, -0.30, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 39, 2, 4, -1.50, -0.60, 27),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 40, 2, 5, 0.50, -0.20, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 41, 2, 6, 1.70, 0.30, 38),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 42, 2, 7, 2.00, 0.40, 34),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 43, 2, 8, 4.50, 0.80, 21),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 44, 2, 9, -0.30, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 45, 2, 10, 0.50, 0.20, 42),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 46, 2, 11, 0.50, 0.30, 38),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 47, 2, 12, 0.50, 0.30, 38),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 48, 2, 13, 1.10, 0.60, 27),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 49, 2, 14, 0.90, 0.50, 31),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 50, 2, 15, -0.10, -0.20, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 51, 2, 16, 2.10, -0.20, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 52, 2, 17, 0.50, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 53, 2, 18, 1.80, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 54, 2, 19, 2.10, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 55, 2, 20, 1.70, 0.00, 50),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 56, 2, 21, 2.20, -0.30, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 57, 2, 22, 1.30, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 58, 2, 23, 2.30, 0.00, 50),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 59, 2, 24, 3.20, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 60, 2, 25, 0.00, 0.00, 50),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 61, 2, 26, 0.00, -0.20, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 62, 2, 27, 0.00, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 63, 2, 28, 0.20, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 64, 2, 29, 0.20, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 65, 2, 30, 0.10, 0.00, 50),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 66, 2, 31, 0.10, 0.00, 50),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 67, 2, 32, 0.10, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 68, 2, 33, 0.20, 0.20, 42),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 69, 2, 34, -0.10, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 70, 2, 35, 0.20, 0.10, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 71, 3, 1, -18.00, -2.00, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 72, 3, 2, -18.90, -1.90, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 73, 3, 3, -7.20, -2.80, 99),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 74, 3, 4, 0.40, 0.60, 73),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 75, 3, 5, 0.50, -0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 76, 3, 6, -1.60, -1.20, 89),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 77, 3, 7, -1.60, -1.20, 89),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 78, 3, 8, -1.90, -1.50, 93),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 79, 3, 9, -1.40, -1.70, 95),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 80, 3, 10, -1.30, -2.10, 98),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 81, 3, 11, -0.80, -1.60, 94),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 82, 3, 12, -1.10, -2.10, 98),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 83, 3, 13, -0.70, -1.40, 92),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 84, 3, 14, -0.10, -1.00, 83),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 85, 3, 15, -4.70, -2.40, 99),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 86, 3, 16, -0.30, -2.00, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 87, 3, 17, -1.50, -2.00, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 88, 3, 18, -0.60, -2.20, 98),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 89, 3, 19, -0.80, -1.70, 95),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 90, 3, 20, -0.80, -1.50, 93),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 91, 3, 21, -1.50, -1.90, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 92, 3, 22, -2.30, -1.90, 97),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 93, 3, 23, -0.90, -1.80, 96),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 94, 3, 24, -2.50, -2.40, 99),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 95, 3, 25, -1.70, -1.70, 95),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 96, 3, 26, 0.00, -0.80, 78),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 97, 3, 27, -0.10, -0.70, 75),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 98, 3, 28, -0.10, -1.10, 87),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 99, 3, 29, 0.00, -0.50, 70),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 100, 3, 30, 0.10, -0.20, 56),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 101, 3, 31, -0.20, -0.90, 82),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 102, 3, 32, -0.20, -1.30, 89),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 103, 3, 33, -0.10, -1.20, 87),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 104, 3, 34, -0.40, -2.10, 98),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 105, 3, 35, 0.20, 0.90, 19),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 106, 4, 1, 59.70, 1.80, 8),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 107, 4, 2, 16.50, 0.90, 22),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 108, 4, 4, -0.10, -0.30, 64),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 109, 4, 5, 0.40, 0.60, 32),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 110, 4, 6, 0.40, 1.20, 17),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 111, 4, 8, 0.50, 1.50, 15),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 112, 4, 15, 0.30, 0.90, 23),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 113, 4, 9, 0.20, 0.60, 32),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 114, 4, 13, 0.20, 0.50, 35),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 115, 4, 14, 0.40, 1.10, 19),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 116, 4, 26, 0.10, 0.40, 44),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 117, 4, 27, 0.20, 0.60, 33),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 118, 4, 28, 0.10, 0.30, 47),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 119, 4, 29, 0.10, 0.20, 52),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 120, 4, 30, 0.00, -0.10, 59),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 121, 4, 31, 0.20, 0.70, 29),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 122, 4, 32, 0.30, 0.80, 25),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 123, 4, 33, 0.10, 0.40, 43),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 124, 4, 34, 0.00, -0.20, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 125, 4, 35, 0.10, 0.30, 48),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 126, 5, 1, 49.20, 1.40, 14),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 127, 5, 2, 9.90, 0.60, 30),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 128, 5, 4, 0.00, -0.10, 55),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 129, 5, 5, 0.20, 0.30, 42),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 130, 5, 6, 0.20, 0.70, 28),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 131, 5, 8, 0.30, 0.80, 25),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 132, 5, 15, 0.20, 0.50, 35),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 133, 5, 9, 0.10, 0.30, 45),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 134, 5, 13, 0.10, 0.20, 48),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 135, 5, 14, 0.20, 0.60, 32),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 136, 5, 26, 0.00, 0.10, 55),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 137, 5, 27, 0.10, 0.30, 47),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 138, 5, 28, 0.00, 0.10, 56),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 139, 5, 29, 0.00, 0.00, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 140, 5, 30, -0.10, -0.20, 64),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 141, 5, 31, 0.10, 0.30, 48),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 142, 5, 32, 0.10, 0.40, 43),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 143, 5, 33, 0.00, 0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 144, 5, 34, -0.10, -0.30, 68),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 145, 5, 35, 0.00, 0.20, 51),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 146, 6, 1, 31.40, 0.90, 32),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 147, 6, 2, 5.70, 0.40, 42),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 148, 6, 4, 0.10, 0.10, 48),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 149, 6, 5, 0.10, 0.10, 52),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 150, 6, 6, 0.10, 0.30, 45),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 151, 6, 8, 0.10, 0.30, 46),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 152, 6, 15, 0.00, 0.10, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 153, 6, 9, 0.00, 0.00, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 154, 6, 13, 0.00, 0.00, 59),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 155, 6, 14, 0.10, 0.20, 49),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 156, 6, 26, 0.00, -0.10, 61),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 157, 6, 27, 0.00, 0.00, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 158, 6, 28, 0.00, -0.10, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 159, 6, 29, 0.00, -0.10, 63),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 160, 6, 30, -0.10, -0.30, 69),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 161, 6, 31, 0.00, 0.10, 57),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 162, 6, 32, 0.00, 0.20, 53),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 163, 6, 33, 0.00, 0.00, 60),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 164, 6, 34, -0.20, -0.50, 74),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 165, 6, 35, 0.00, 0.10, 55),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 166, 7, 1, 29.10, 0.80, 36),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 167, 7, 2, 9.40, 0.50, 32),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 168, 7, 4, 0.00, 0.00, 57),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 169, 7, 5, 0.10, 0.00, 54),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 170, 7, 6, 0.00, 0.10, 52),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 171, 7, 8, 0.00, 0.10, 53),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 172, 7, 15, 0.00, 0.00, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 173, 7, 9, 0.00, -0.10, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 174, 7, 13, 0.00, -0.10, 63),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 175, 7, 14, 0.00, 0.10, 55),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 176, 7, 26, 0.00, -0.20, 65),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 177, 7, 27, 0.00, -0.10, 61),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 178, 7, 28, 0.00, -0.20, 66),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 179, 7, 29, 0.00, -0.20, 67),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 180, 7, 30, -0.10, -0.40, 72),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 181, 7, 31, 0.00, 0.00, 58),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 182, 7, 32, 0.00, 0.10, 56),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 183, 7, 33, 0.00, -0.10, 64),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 184, 7, 34, -0.20, -0.70, 78),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 185, 7, 35, 0.00, 0.00, 59),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 186, 8, 1, 1.10, 0.10, 53),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 187, 8, 2, -2.60, -0.20, 62),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 188, 8, 4, 0.10, 0.20, 45),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 189, 8, 5, 0.00, -0.10, 61),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 190, 8, 6, -0.10, -0.30, 68),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 191, 8, 8, -0.10, -0.30, 69),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 192, 8, 15, -0.10, -0.20, 65),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 193, 8, 9, 0.00, -0.20, 66),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 194, 8, 13, 0.00, -0.20, 68),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 195, 8, 14, -0.10, -0.30, 70),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 196, 8, 26, -0.10, -0.40, 73),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 197, 8, 27, -0.10, -0.50, 76),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 198, 8, 28, -0.10, -0.60, 79),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 199, 8, 29, 0.00, -0.30, 70),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 200, 8, 30, -0.10, -0.50, 77),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 201, 8, 31, -0.10, -0.60, 80),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 202, 8, 32, -0.20, -0.70, 82),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 203, 8, 33, -0.10, -0.60, 81),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 204, 8, 34, -0.30, -1.00, 86),
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 205, 8, 35, 0.00, -0.20, 65),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 206, 4, 1, 50.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 207, 9, 1, 50.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 208, 4, 2, 45.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 209, 9, 2, 45.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 210, 4, 3, 10.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 211, 9, 3, 10.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 212, 4, 4, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 213, 9, 4, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 214, 4, 5, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 215, 9, 5, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 216, 4, 6, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 217, 9, 6, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 218, 4, 7, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 219, 9, 7, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 220, 4, 8, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 221, 9, 8, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 222, 4, 9, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 223, 9, 9, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 224, 4, 10, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 225, 9, 10, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 226, 4, 11, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 227, 9, 11, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 228, 4, 12, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 229, 9, 12, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 230, 4, 13, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 231, 9, 13, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 232, 4, 14, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 233, 9, 14, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 234, 4, 15, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 235, 9, 15, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 236, 4, 16, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 237, 9, 16, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 238, 4, 17, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 239, 9, 17, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 240, 4, 18, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 241, 9, 18, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 242, 4, 19, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 243, 9, 19, 2.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 244, 4, 20, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 245, 9, 20, 2.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 246, 4, 21, 4.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 247, 9, 21, 4.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 248, 4, 22, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 249, 9, 22, 1.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 250, 4, 23, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 251, 9, 23, 3.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 252, 4, 24, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 253, 9, 24, 5.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 254, 4, 25, 1.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 255, 9, 25, 1.00, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 256, 4, 26, 0.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 257, 9, 26, 0.50, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 258, 4, 27, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 259, 9, 27, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 260, 4, 28, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 261, 9, 28, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 262, 4, 29, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 263, 9, 29, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 264, 4, 30, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 265, 9, 30, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 266, 4, 31, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 267, 9, 31, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 268, 4, 32, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 269, 9, 32, 0.20, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 270, 4, 33, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 271, 9, 33, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 272, 4, 34, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 273, 9, 34, 0.30, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 274, 4, 35, 0.10, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 275, 9, 35, 0.10, 1.50, 15),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 276, 5, 1, 35.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 277, 10, 1, 35.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 278, 5, 2, 38.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 279, 10, 2, 38.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 280, 5, 3, 8.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 281, 10, 3, 8.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 282, 5, 4, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 283, 10, 4, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 284, 5, 5, 1.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 285, 10, 5, 1.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 286, 5, 6, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 287, 10, 6, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 288, 5, 7, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 289, 10, 7, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 290, 5, 8, 3.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 291, 10, 8, 3.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 292, 5, 9, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 293, 10, 9, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 294, 5, 10, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 295, 10, 10, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 296, 5, 11, 0.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 297, 10, 11, 0.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 298, 5, 12, 0.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 299, 10, 12, 0.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 300, 5, 13, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 301, 10, 13, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 302, 5, 14, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 303, 10, 14, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 304, 5, 15, 1.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 305, 10, 15, 1.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 306, 5, 16, 4.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 307, 10, 16, 4.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 308, 5, 17, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 309, 10, 17, 1.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 310, 5, 18, 2.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 311, 10, 18, 2.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 312, 5, 19, 2.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 313, 10, 19, 2.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 314, 5, 20, 1.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 315, 10, 20, 1.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 316, 5, 21, 3.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 317, 10, 21, 3.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 318, 5, 22, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 319, 10, 22, 1.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 320, 5, 23, 2.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 321, 10, 23, 2.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 322, 5, 24, 3.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 323, 10, 24, 3.80, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 324, 5, 25, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 325, 10, 25, 0.50, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 326, 5, 26, 0.40, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 327, 10, 26, 0.40, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 328, 5, 27, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 329, 10, 27, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 330, 5, 28, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 331, 10, 28, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 332, 5, 29, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 333, 10, 29, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 334, 5, 30, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 335, 10, 30, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 336, 5, 31, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 337, 10, 31, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 338, 5, 32, 0.10, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 339, 10, 32, 0.10, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 340, 5, 33, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 341, 10, 33, 0.20, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 342, 5, 34, 0.30, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 343, 10, 34, 0.30, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 344, 5, 35, 0.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 345, 10, 35, 0.00, 0.90, 25),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 346, 6, 1, 42.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 347, 11, 1, 42.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 348, 6, 2, 43.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 349, 11, 2, 43.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 350, 6, 3, 8.50, 0.50, 38),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 351, 11, 3, 8.50, 0.50, 38),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 352, 6, 4, -0.80, -0.20, 48),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 353, 11, 4, -0.80, -0.20, 48),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 354, 6, 5, 1.80, 0.90, 32),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 355, 11, 5, 1.80, 0.90, 32),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 356, 6, 6, 1.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 357, 11, 6, 1.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 358, 6, 7, 1.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 359, 11, 7, 1.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 360, 6, 8, 3.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 361, 11, 8, 3.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 362, 6, 9, 0.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 363, 11, 9, 0.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 364, 6, 10, 1.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 365, 11, 10, 1.00, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 366, 6, 11, 0.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 367, 11, 11, 0.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 368, 6, 12, 0.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 369, 11, 12, 0.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 370, 6, 13, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 371, 11, 13, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 372, 6, 14, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 373, 11, 14, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 374, 6, 15, 1.40, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 375, 11, 15, 1.40, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 376, 6, 16, 3.80, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 377, 11, 16, 3.80, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 378, 6, 17, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 379, 11, 17, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 380, 6, 18, 2.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 381, 11, 18, 2.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 382, 6, 19, 2.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 383, 11, 19, 2.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 384, 6, 20, 1.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 385, 11, 20, 1.70, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 386, 6, 21, 3.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 387, 11, 21, 3.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 388, 6, 22, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 389, 11, 22, 1.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 390, 6, 23, 2.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 391, 11, 23, 2.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 392, 6, 24, 3.90, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 393, 11, 24, 3.90, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 394, 6, 25, 0.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 395, 11, 25, 0.60, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 396, 6, 26, 0.30, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 397, 11, 26, 0.30, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 398, 6, 27, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 399, 11, 27, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 400, 6, 28, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 401, 11, 28, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 402, 6, 29, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 403, 11, 29, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 404, 6, 30, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 405, 11, 30, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 406, 6, 31, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 407, 11, 31, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 408, 6, 32, 0.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 409, 11, 32, 0.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 410, 6, 33, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 411, 11, 33, 0.20, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 412, 6, 34, 0.30, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 413, 11, 34, 0.30, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 414, 6, 35, 0.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 415, 11, 35, 0.10, 0.60, 35),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 416, 7, 1, 5.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 417, 12, 1, 5.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 418, 7, 2, 10.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 419, 12, 2, 10.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 420, 7, 3, 2.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 421, 12, 3, 2.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 422, 7, 4, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 423, 12, 4, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 424, 7, 5, -1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 425, 12, 5, -1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 426, 7, 6, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 427, 12, 6, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 428, 7, 7, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 429, 12, 7, -2.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 430, 7, 8, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 431, 12, 8, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 432, 7, 9, -1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 433, 12, 9, -1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 434, 7, 10, -1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 435, 12, 10, -1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 436, 7, 11, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 437, 12, 11, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 438, 7, 12, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 439, 12, 12, -1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 440, 7, 13, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 441, 12, 13, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 442, 7, 14, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 443, 12, 14, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 444, 7, 15, -1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 445, 12, 15, -1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 446, 7, 16, 1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 447, 12, 16, 1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 448, 7, 17, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 449, 12, 17, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 450, 7, 18, 0.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 451, 12, 18, 0.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 452, 7, 19, 0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 453, 12, 19, 0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 454, 7, 20, 0.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 455, 12, 20, 0.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 456, 7, 21, 1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 457, 12, 21, 1.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 458, 7, 22, 0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 459, 12, 22, 0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 460, 7, 23, 1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 461, 12, 23, 1.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 462, 7, 24, 1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 463, 12, 24, 1.50, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 464, 7, 25, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 465, 12, 25, -0.80, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 466, 7, 26, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 467, 12, 26, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 468, 7, 27, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 469, 12, 27, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 470, 7, 28, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 471, 12, 28, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 472, 7, 29, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 473, 12, 29, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 474, 7, 30, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 475, 12, 30, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 476, 7, 31, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 477, 12, 31, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 478, 7, 32, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 479, 12, 32, -0.10, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 480, 7, 33, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 481, 12, 33, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 482, 7, 34, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 483, 12, 34, 0.00, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 484, 7, 35, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 485, 12, 35, -0.20, -1.50, 85),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 486, 8, 1, 45.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 487, 13, 1, 45.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 488, 8, 2, 42.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 489, 13, 2, 42.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 490, 8, 3, 9.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 491, 13, 3, 9.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 492, 8, 4, 1.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 493, 13, 4, 1.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 494, 8, 5, 2.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 495, 13, 5, 2.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 496, 8, 6, 2.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 497, 13, 6, 2.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 498, 8, 7, 2.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 499, 13, 7, 2.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 500, 8, 8, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 501, 13, 8, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 502, 8, 9, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 503, 13, 9, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 504, 8, 10, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 505, 13, 10, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 506, 8, 11, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 507, 13, 11, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 508, 8, 12, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 509, 13, 12, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 510, 8, 13, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 511, 13, 13, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 512, 8, 14, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 513, 13, 14, 1.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 514, 8, 15, 2.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 515, 13, 15, 2.00, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 516, 8, 16, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 517, 13, 16, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 518, 8, 17, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 519, 13, 17, 1.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 520, 8, 18, 2.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 521, 13, 18, 2.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 522, 8, 19, 2.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 523, 13, 19, 2.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 524, 8, 20, 1.90, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 525, 13, 20, 1.90, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 526, 8, 21, 3.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 527, 13, 21, 3.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 528, 8, 22, 1.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 529, 13, 22, 1.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 530, 8, 23, 2.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 531, 13, 23, 2.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 532, 8, 24, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 533, 13, 24, 4.50, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 534, 8, 25, 0.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 535, 13, 25, 0.80, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 536, 8, 26, 0.40, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 537, 13, 26, 0.40, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 538, 8, 27, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 539, 13, 27, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 540, 8, 28, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 541, 13, 28, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 542, 8, 29, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 543, 13, 29, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 544, 8, 30, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 545, 13, 30, 0.20, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 546, 8, 31, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 547, 13, 31, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 548, 8, 32, 0.10, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 549, 13, 32, 0.10, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 550, 8, 33, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 551, 13, 33, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 552, 8, 34, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 553, 13, 34, 0.30, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 554, 8, 35, 0.10, 1.32, 18),
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 555, 13, 35, 0.10, 1.32, 18);
/*!40000 ALTER TABLE "tb_genome_cow" ENABLE KEYS */;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,214 +0,0 @@
-- ============================================
-- 간단 Seed 데이터 v3 (업로드 테스트용)
-- 시나리오: 마스터 데이터 + 최소 사용자만 / 실제 업로드 파일로 테스트
-- ============================================
-- 설명: 파일 업로드 기능 테스트를 위한 최소 seed 데이터
-- - 마스터 데이터 (마커, SNP, 형질 등) 포함
-- - 사용자/농장 데이터만 최소로 포함
-- - 개체 데이터는 업로드 파일로 생성 예정
-- ============================================
-- PART 0: 스키마 초기화 (선택사항 - 완전히 새로 시작할 때만 사용)
-- ============================================
-- 주의: 아래 주석을 해제하면 모든 테이블과 데이터가 삭제됩니다
-- DROP SCHEMA public CASCADE;
-- CREATE SCHEMA public;
-- GRANT ALL ON SCHEMA public TO postgres;
-- GRANT ALL ON SCHEMA public TO public;
-- ============================================
-- PART 0: ENUM 타입 생성 및 VARCHAR 길이 수정
-- ============================================
-- ENUM 타입이 이미 존재하면 삭제 후 재생성
DROP TYPE IF EXISTS tb_cow_anlys_stat_enum CASCADE;
CREATE TYPE tb_cow_anlys_stat_enum AS ENUM ('친자일치', '친자불일치', '분석불가', '이력제부재');
DROP TYPE IF EXISTS tb_cow_cow_repro_type_enum CASCADE;
CREATE TYPE tb_cow_cow_repro_type_enum AS ENUM ('공란우', '수란우', '인공수정', '도태대상');
DROP TYPE IF EXISTS tb_cow_cow_status_enum CASCADE;
CREATE TYPE tb_cow_cow_status_enum AS ENUM ('정상', '폐사', '도축', '매각');
-- tb_cow 테이블에 컬럼 추가 (이미 있으면 무시)
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS anlys_stat tb_cow_anlys_stat_enum;
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_repro_type tb_cow_cow_repro_type_enum;
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_status tb_cow_cow_status_enum DEFAULT '정상'::tb_cow_cow_status_enum;
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_short_no varchar(4);
-- 기타 테이블 컬럼 확장 (테이블 있을 경우만)
ALTER TABLE tb_genome_trait ALTER COLUMN dam TYPE varchar(20);
ALTER TABLE tb_region_genome ALTER COLUMN dam TYPE varchar(20);
-- tb_pedigree 모든 ID 컬럼 확장
ALTER TABLE tb_pedigree
ALTER COLUMN fk_animal_no TYPE varchar(20),
ALTER COLUMN sire_id TYPE varchar(20),
ALTER COLUMN dam_id TYPE varchar(20),
ALTER COLUMN paternal_grandsire_id TYPE varchar(20),
ALTER COLUMN paternal_granddam_id TYPE varchar(20),
ALTER COLUMN maternal_grandsire_id TYPE varchar(20),
ALTER COLUMN maternal_granddam_id TYPE varchar(20);
-- ============================================
-- PART 1: 마스터 데이터 (필수 참조 데이터)
-- ============================================
-- 1. 마커 타입 (2개)
INSERT INTO tb_marker_type (pk_type_cd, type_nm, type_desc, use_yn, reg_dt, updt_dt)
VALUES
('QTY', '육량형', '육량 관련 마커 (도체중, 등심단면적 등)', 'Y', NOW(), NOW()),
('QLT', '육질형', '육질 관련 마커 (근내지방도, 연도 등)', 'Y', NOW(), NOW())
ON CONFLICT (pk_type_cd) DO NOTHING;
-- 2. 마커 정보 (7개 유전자만 - 각 1개 대표 SNP)
INSERT INTO tb_marker (fk_marker_type, marker_nm, marker_desc, related_trait, snp_cnt, use_yn, favorable_allele, reg_dt, updt_dt)
VALUES
-- 육량형 (3개)
('QTY', 'PLAG1', 'Pleiomorphic adenoma gene 1', '도체중', 1, 'Y', 'G', NOW(), NOW()),
('QTY', 'NCAPG2', 'Non-SMC condensin II complex subunit G2', '체구', 1, 'Y', 'T', NOW(), NOW()),
('QTY', 'BTB', 'BTB domain containing', '등심단면적', 1, 'Y', 'T', NOW(), NOW()),
-- 육질형 (4개)
('QLT', 'NT5E', '5 prime nucleotidase ecto', '근내지방도', 1, 'Y', 'C', NOW(), NOW()),
('QLT', 'SCD', 'Stearoyl-CoA desaturase', '지방산불포화도', 1, 'Y', 'G', NOW(), NOW()),
('QLT', 'FASN', 'Fatty acid synthase', '지방합성', 1, 'Y', 'G', NOW(), NOW()),
('QLT', 'CAPN1', 'Calpain 1', '연도', 1, 'Y', 'G', NOW(), NOW())
ON CONFLICT DO NOTHING;
-- 3. SNP 정보 (7개 - 각 유전자당 1개 대표 SNP)
INSERT INTO tb_snp_info (snp_nm, chr, position, snp_alleles, reg_dt, updt_dt)
VALUES
-- 육량형 대표 SNP
('14:25016263', '14', 25016263, '[G/C]', NOW(), NOW()), -- PLAG1 (GG/GC/CC)
('7:38528304', '7', 38528304, '[T/G]', NOW(), NOW()), -- NCAPG2 (TT/TG/GG)
('5:45120340', '5', 45120340, '[T/C]', NOW(), NOW()), -- BTB (TT/TC/CC)
-- 육질형 대표 SNP
('6:58230560', '6', 58230560, '[C/T]', NOW(), NOW()), -- NT5E (CC/CT/TT)
('26:21194784', '26', 21194784, '[G/A]', NOW(), NOW()), -- SCD (GG/GA/AA)
('19:51230120', '19', 51230120, '[G/A]', NOW(), NOW()), -- FASN (GG/GA/AA)
('29:44104889', '29', 44104889, '[G/A]', NOW(), NOW()) -- CAPN1 (GG/GA/AA)
ON CONFLICT (snp_nm) DO NOTHING;
-- 4. 마커-SNP 매핑 (7개 - 각 유전자당 1개 대표 SNP)
INSERT INTO tb_marker_snp (pk_fk_marker_no, pk_fk_snp_no, reg_dt, updt_dt)
SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'PLAG1' AND s.snp_nm = '14:25016263'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NCAPG2' AND s.snp_nm = '7:38528304'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'BTB' AND s.snp_nm = '5:45120340'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NT5E' AND s.snp_nm = '6:58230560'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'SCD' AND s.snp_nm = '26:21194784'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'FASN' AND s.snp_nm = '19:51230120'
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'CAPN1' AND s.snp_nm = '29:44104889'
ON CONFLICT DO NOTHING;
-- 5. 형질 정보 (35개 경제형질)
INSERT INTO tb_genome_trait_info (trait_nm, trait_ctgry, trait_desc, use_yn, reg_dt, updt_dt)
VALUES
-- 성장형질 (6개)
('이유체중', '성장', '송아지 이유시 체중 (kg)', 'Y', NOW(), NOW()),
('육성체중', '성장', '육성기 체중 (kg)', 'Y', NOW(), NOW()),
('12개월체중', '성장', '12개월령 체중 (kg)', 'Y', NOW(), NOW()),
('일당증체량', '성장', '일일 체중 증가량 (kg/day)', 'Y', NOW(), NOW()),
('체고', '성장', '어깨높이 (cm)', 'Y', NOW(), NOW()),
('체장', '성장', '몸통 길이 (cm)', 'Y', NOW(), NOW()),
-- 도체형질 (10개)
('도체중', '도체', '도축 후 도체 무게 (kg)', 'Y', NOW(), NOW()),
('등지방두께', '도체', '등 부위 지방 두께 (mm)', 'Y', NOW(), NOW()),
('등심단면적', '도체', '등심 단면적 (cm²)', 'Y', NOW(), NOW()),
('근내지방도', '도체', '마블링 점수 (1~9)', 'Y', NOW(), NOW()),
('육량지수', '도체', '고기 생산량 지수', 'Y', NOW(), NOW()),
('육색', '도체', '고기 색깔 (1~9)', 'Y', NOW(), NOW()),
('지방색', '도체', '지방 색깔 (1~9)', 'Y', NOW(), NOW()),
('조직감', '도체', '고기 조직감 (1~3)', 'Y', NOW(), NOW()),
('성숙도', '도체', '고기 성숙 정도 (1~9)', 'Y', NOW(), NOW()),
('보수력', '도체', '수분 보유 능력 (%)', 'Y', NOW(), NOW()),
-- 육질형질 (7개)
('연도', '육질', '고기 부드러운 정도', 'Y', NOW(), NOW()),
('다즙성', '육질', '육즙 함량', 'Y', NOW(), NOW()),
('풍미', '육질', '고기 맛', 'Y', NOW(), NOW()),
('가열감량', '육질', '조리시 손실율 (%)', 'Y', NOW(), NOW()),
('전단력', '육질', '자르는 힘 (kgf)', 'Y', NOW(), NOW()),
('지방산불포화도', '육질', '불포화 지방산 비율 (%)', 'Y', NOW(), NOW()),
('오메가3비율', '육질', '오메가3 지방산 비율 (%)', 'Y', NOW(), NOW()),
-- 번식형질 (6개)
('초산월령', '번식', '첫 분만 월령 (개월)', 'Y', NOW(), NOW()),
('분만간격', '번식', '분만 사이 기간 (일)', 'Y', NOW(), NOW()),
('수태율', '번식', '임신 성공률 (%)', 'Y', NOW(), NOW()),
('분만난이도', '번식', '분만 어려움 정도 (1~5)', 'Y', NOW(), NOW()),
('송아지생존율', '번식', '신생 송아지 생존율 (%)', 'Y', NOW(), NOW()),
('모성능력', '번식', '어미소 양육 능력', 'Y', NOW(), NOW()),
-- 건강형질 (6개)
('체세포수', '건강', '우유 체세포 수 (천개/ml)', 'Y', NOW(), NOW()),
('질병저항성', '건강', '질병 저항 능력', 'Y', NOW(), NOW()),
('발굽건강', '건강', '발굽 건강 상태', 'Y', NOW(), NOW()),
('유방염저항성', '건강', '유방염 저항성', 'Y', NOW(), NOW()),
('호흡기질환저항성', '건강', '호흡기 질환 저항성', 'Y', NOW(), NOW()),
('대사질환저항성', '건강', '대사 질환 저항성', 'Y', NOW(), NOW())
ON CONFLICT (trait_nm) DO NOTHING;
-- ============================================
-- PART 2: 사용자 및 농장 데이터 (최소)
-- ============================================
-- 사용자 2명 (ADMIN + TEST 농장주)
INSERT INTO tb_user (user_id, user_password, user_nm, user_role, hp_no, email, addr, reg_dt, updt_dt)
VALUES
('admin', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '관리자', 'ADMIN', '010-0000-0000', 'admin@test.com', '서울시', NOW(), NOW()),
('testuser', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '테스트농장주', 'FARM_OWNER', '010-1111-1111', 'test@test.com', '충북 청주시', NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING;
-- 농장 1개 (테스트용)
INSERT INTO tb_farm (farm_nm, farm_addr, owner_nm, contact, reg_dt, updt_dt)
VALUES
('테스트농장', '충북 청주시 상당구', '테스트농장주', '010-1111-1111', NOW(), NOW())
ON CONFLICT DO NOTHING;
-- ============================================
-- PART 3: KPN (종축 수소) 데이터 (최소)
-- ============================================
-- KPN 수소 2마리만 (부계/모계 참조용)
INSERT INTO tb_kpn (kpn_no, kpn_nm, birth_dt, sire, dam, reg_dt, updt_dt)
VALUES
('KPN001001001001', '종축수소1', '2018-01-15', NULL, NULL, NOW(), NOW()),
('KPN001001001002', '종축수소2', '2019-03-20', NULL, NULL, NOW(), NOW())
ON CONFLICT (kpn_no) DO NOTHING;
-- ============================================
-- 업로드 테스트용 안내
-- ============================================
-- 다음 단계: 파일 업로드로 데이터 생성
--
-- 1. 개체정보 파일 업로드 (fileType: "유전자")
-- → 농장주명 + 개체번호 매핑 정보
-- → tb_cow 테이블에 저장
--
-- 2. 유전능력평가 결과 업로드 (fileType: "유전체")
-- → 533두 유전체 분석 데이터 (CSV)
-- → tb_genome_cow, tb_genome_trait 테이블에 저장
--
-- 3. SNP 타이핑 결과 업로드 (fileType: "유전자")
-- → 개체별 SNP 유전자형
-- → tb_snp_cow 테이블에 저장
--
-- 4. MPT 분석결과 업로드 (fileType: "혈액대사검사", 선택사항)
-- → 혈액 샘플 분석 결과
-- → tb_repro_mpt 테이블에 저장
--
-- 업로드 후 확인 쿼리:
-- SELECT * FROM tb_uploadfile WHERE file_type IN ('유전자', '유전체', '혈액대사검사') ORDER BY reg_dt DESC;
-- SELECT count(*) FROM tb_cow;
-- SELECT count(*) FROM tb_genome_cow;
-- SELECT count(*) FROM tb_snp_cow;
-- SELECT farm_owner, count(*) FROM tb_cow GROUP BY farm_owner;
-- ============================================
-- 완료
-- ============================================
-- v3 seed 데이터 생성 완료
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
# 혈액대사판정시험(MPT) 검사항목 및 권장수치
## 개요
번식능력 검사를 위한 혈액대사판정시험(MPT) 검사항목으로, 5개 카테고리 총 16개 항목으로 구성됩니다.
---
## 1. 에너지 카테고리
| 항목 | 권장수치 | 단위 |
|------|---------|------|
| 혈당 | 40-84 | mg/dL |
| 콜레스테롤 | 74-252 | mg/dL |
| 유리지방산(NEFA) | 115-660 | μEq/L |
---
## 2. 단백질 카테고리
| 항목 | 권장수치 | 단위 |
|------|---------|------|
| 총단백질 | 6.2-7.7 | g/dL |
| 알부민 | 3.3-4.3 | g/dL |
| 총글로블린 | 9.1-36.1 | g/dL |
| A/G | 0.1-0.4 | - |
| 요소태질소(BUN) | 11.7-18.9 | mg/dL |
---
## 3. 간기능 카테고리
| 항목 | 권장수치 | 단위 |
|------|---------|------|
| AST | 47-92 | U/L |
| GGT | 11-32 | U/L |
| 지방간 지수 | -1.2 ~ 9.9 | - |
---
## 4. 미네랄 카테고리
| 항목 | 권장수치 | 단위 |
|------|---------|------|
| 칼슘 | 8.1-10.6 | mg/dL |
| 인 | 6.2-8.9 | mg/dL |
| 칼슘/인 | 1.2-1.3 | - |
| 마그네슘 | 1.6-3.3 | mg/dL |
---
## 5. 별도 카테고리
| 항목 | 권장수치 | 단위 |
|------|---------|------|
| 크레아틴 | 1.0-1.3 | mg/dL |
---
## 결과 판정 기준
| 판정 | 설명 |
|------|------|
| 낮음 | 권장수치 하한 미만 |
| 권장범위 | 권장수치 범위 내 |
| 높음 | 권장수치 상한 초과 |
---
## 시각화 방식
- **폴리곤(레이더) 차트**: 5개 카테고리를 5각형 구조로 표현
- **가로 막대 도표**: 각 항목별 낮음/권장범위/높음 표시
- **색상 구분**: 우수/적정/부족으로 구분
---
## 참고
> 혈액대사판정시험의 주요 5가지 항목에 대한 시군 및 농가 수치비교를 위해 표준화한 자료입니다.
> 본 결과자료를 통한 종합평가는 보은군 평균과 농가 평균을 비교하여 상대적 차이의 수준을 나타냅니다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
# 유전체/유전자 검사 가능 조건 요약
## 1. DB 상태값 정의
### chipSireName (아비명)
| DB 값 | 의미 | 분석 가능 여부 |
|-------|------|----------------|
| `일치` | 친자감별 일치 | 가능 |
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
| `분석불가` | 모근 오염/불량 등 기타 사유 | 불가 |
| `정보없음` | 개체 식별번호/형식 오류 | 불가 |
| `null` | 미분석 (의뢰 없음) | - 표시 |
## - 아비명 가능한 개체에 대해서 어미명 판단 진행
### chipDamName (어미명)
| DB 값 | 의미 | 분석 가능 여부 |
|-------|------|----------------|
| `일치` | 친자감별 일치 | 통과 |
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
| `이력제부재` | 모 이력제 정보 없음 | 유전체 불가 / 유전자 가능 |
| `정보없음` | 정보 없음 | 통과 |
| `null` | 정보 없음 | 통과 |
---
## 2. 탭별 검사 가능 조건
### 유전체 탭
```
유효 조건 (모두 충족해야 함):
1. chipSireName === '일치'
2. chipDamName !== '불일치'
3. chipDamName !== '이력제부재'
4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
```
### 유전자 탭
```
유효 조건:
1. chipSireName !== '분석불가'
2. chipSireName !== '정보없음'
3. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
※ 불일치/이력제부재도 유전자 데이터가 있으면 표시 가능
```
---
## 3. 개체 목록 배지 표시 (unavailableReason)
### 분석일자 컬럼
| unavailableReason | 배지 색상 | 표시 텍스트 |
|-------------------|-----------|-------------|
| `null` | - | `-` |
| `분석불가` | 회색 | 분석불가 |
| `부 불일치` | 빨간색 | 부 불일치 |
| `모 불일치` | 주황색 | 모 불일치 |
| `모 이력제부재` | 주황색 | 모 이력제부재 |
| `형질정보없음` | 회색 | 형질정보없음 |
### unavailableReason 결정 로직 (cow.service.ts)
```typescript
if (!latestRequest || !latestRequest.chipSireName) {
unavailableReason = null; // '-' 표시
} else if (chipSireName === '분석불가' || chipSireName === '정보없음') {
unavailableReason = '분석불가';
} else if (chipSireName !== '일치') {
unavailableReason = '부 불일치';
} else if (chipDamName === '불일치') {
unavailableReason = '모 불일치';
} else if (chipDamName === '이력제부재') {
unavailableReason = '모 이력제부재';
}
// 형질 데이터 없으면
unavailableReason = '형질정보없음';
```
---
## 4. 개체 상세 페이지 배지
### 부 KPN 배지 (renderSireBadge)
| 조건 | 배지 색상 | 표시 |
|------|-----------|------|
| `EXCLUDED_COW_IDS` 포함 | 회색 | 분석불가 |
| `chipSireName === '분석불가'` | 회색 | 분석불가 |
| `chipSireName === '정보없음'` | 회색 | 분석불가 |
| `chipSireName === '일치'` | 초록색 | 일치 |
| 그 외 | 빨간색 | 불일치 |
| `null` | - | 표시 안 함 |
### 모 개체 배지 (renderDamBadge)
| 조건 | 배지 색상 | 표시 |
|------|-----------|------|
| `chipDamName === '일치'` | 초록색 | 일치 |
| `chipDamName === '불일치'` | 빨간색 | 불일치 |
| `chipDamName === '이력제부재'` | 주황색 | 이력제부재 |
| 그 외/null | - | 표시 안 함 |
---
## 5. 분석불가 안내 문구
| 상태 | 안내 문구 |
|------|-----------|
| `분석불가` (DB) | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
| `정보없음` (DB) | 개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다. |
| `부 불일치` | 부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
| `모 불일치` | 모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
| `모 이력제부재` | 모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다. |
| `EXCLUDED_COW_IDS` | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
---
## 6. 관련 파일
### 백엔드
- `backend/src/common/config/GenomeAnalysisConfig.ts` - 유효성 검사 함수
- `backend/src/cow/cow.service.ts` - unavailableReason 결정 로직
### 프론트엔드
- `frontend/src/lib/utils/genome-analysis-config.ts` - 유효성 검사, 메시지 함수
- `frontend/src/app/cow/page.tsx` - 개체 목록 배지
- `frontend/src/app/cow/[cowNo]/page.tsx` - 개체 상세 배지, 탭 조건
---
## 7. 제외 개체 목록 (EXCLUDED_COW_IDS)
특수 사유로 분석 불가한 개체를 하드코딩으로 관리:
```typescript
export const EXCLUDED_COW_IDS = [
'KOR002191642861', // 모근상태 불량으로 인한 DNA분해
];
```
> 이 목록에 포함된 개체는 유전체/유전자 탭 모두 분석불가로 처리됨

File diff suppressed because it is too large Load Diff

View File

@@ -1,719 +0,0 @@
# 프론트엔드 API 연동 가이드
> **작성일**: 2025-10-26
> **버전**: 1.0
> **대상**: 한우 유전체 분석 시스템 프론트엔드 개발자
## 📋 목차
1. [개요](#1-개요)
2. [인증 흐름](#2-인증-흐름)
3. [사용자-농장-개체 관계](#3-사용자-농장-개체-관계)
4. [API 연동 방법](#4-api-연동-방법)
5. [주요 구현 사례](#5-주요-구현-사례)
6. [문제 해결](#6-문제-해결)
7. [추가 구현 권장사항](#7-추가-구현-권장사항)
---
## 1. 개요
### 1.1 시스템 구조
```
사용자 (User) 1:N 농장 (Farm) 1:N 개체 (Cow)
```
- **User**: 로그인한 사용자 (농가, 컨설턴트, 기관담당자)
- **Farm**: 사용자가 소유한 농장 (한 사용자가 여러 농장 소유 가능)
- **Cow**: 농장에 속한 개체 (한우)
### 1.2 주요 기술 스택
**백엔드**:
- NestJS 10.x
- TypeORM
- JWT 인증
- PostgreSQL
**프론트엔드**:
- Next.js 15.5.3 (App Router)
- TypeScript
- Zustand (상태관리)
- Axios (HTTP 클라이언트)
### 1.3 API Base URL
```
개발: http://localhost:4000
운영: 환경변수 NEXT_PUBLIC_API_URL 사용
```
---
## 2. 인증 흐름
### 2.1 JWT 기반 인증
모든 API 요청은 JWT 토큰을 필요로 합니다 (일부 Public 엔드포인트 제외).
**전역 Guard 적용**:
```typescript
// backend/src/main.ts
app.useGlobalGuards(new JwtAuthGuard(reflector));
```
### 2.2 로그인 프로세스
```typescript
// 1. 사용자 로그인
const response = await authApi.login({
userId: 'user123',
userPassword: 'password123'
});
// 2. 응답 구조
{
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
userNo: 1,
userName: '홍길동',
userEmail: 'hong@example.com',
// ...
}
}
// 3. 토큰 자동 저장 (auth-store.ts에서 처리)
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('refreshToken', response.refreshToken);
```
### 2.3 자동 토큰 주입
`apiClient`가 모든 요청에 자동으로 토큰을 추가합니다:
```typescript
// frontend/src/lib/api-client.ts (자동 처리)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
```
**개발자는 별도로 토큰을 관리할 필요 없음**
---
## 3. 사용자-농장-개체 관계
### 3.1 데이터 모델
```typescript
// User Entity
{
pkUserNo: number; // 사용자 번호
userId: string; // 로그인 ID
userName: string; // 이름
userSe: 'FARM' | 'CNSLT' | 'ORGAN'; // 사용자 구분
farms: FarmModel[]; // 소유 농장 목록
}
// Farm Entity
{
pkFarmNo: number; // 농장 번호
fkUserNo: number; // 사용자 번호 (FK)
farmCode: string; // 농장 코드 (F000001)
farmName: string; // 농장명
farmAddress: string; // 농장 주소
cows: CowModel[]; // 농장의 개체 목록
}
// Cow Entity
{
pkCowNo: string; // 개체번호 (12자리)
fkFarmNo: number; // 농장 번호 (FK)
cowSex: 'M' | 'F'; // 성별
cowBirthDt: Date; // 생년월일
cowStatus: string; // 개체 상태
delYn: 'Y' | 'N'; // 삭제 여부
}
```
### 3.2 관계 API 호출 순서
```typescript
// 올바른 순서:
// 1. 로그인한 사용자의 농장 목록 조회
const farms = await farmApi.findAll(); // GET /farm
// 2. 특정 농장의 개체 조회
const cows = await cowApi.findByFarmNo(farms[0].pkFarmNo); // GET /cow/farm/:farmNo
```
**❌ 잘못된 방법**: farmNo를 하드코딩
```typescript
const cows = await cowApi.findByFarmNo(1); // ❌ 다른 사용자의 데이터 접근 불가
```
---
## 4. API 연동 방법
### 4.1 API 모듈 구조
```
frontend/src/lib/api/
├── api-client.ts # Axios 인스턴스 + 인터셉터
├── index.ts # 모든 API export
├── auth.api.ts # 인증 API
├── farm.api.ts # 농장 API
├── cow.api.ts # 개체 API
├── kpn.api.ts # KPN API
└── dashboard.api.ts # 대시보드 API
```
### 4.2 Import 방법
```typescript
import { cowApi, farmApi, authApi } from '@/lib/api';
```
### 4.3 주요 Farm API
```typescript
// 1. 현재 사용자의 농장 목록 조회
const farms = await farmApi.findAll();
// GET /farm
// 응답: FarmDto[]
// 2. 농장 상세 조회
const farm = await farmApi.findOne(farmNo);
// GET /farm/:id
// 응답: FarmDto
// 3. 농장 생성
const newFarm = await farmApi.create({
userNo: 1,
farmName: '행복농장',
farmAddress: '충청북도 보은군...',
farmBizNo: '123-45-67890'
});
// POST /farm
```
### 4.4 주요 Cow API
```typescript
// 1. 특정 농장의 개체 목록 조회
const cows = await cowApi.findByFarmNo(farmNo);
// GET /cow/farm/:farmNo
// 응답: CowDto[]
// 2. 개체 상세 조회
const cow = await cowApi.findOne(cowNo);
// GET /cow/:cowNo
// 응답: CowDto
// 3. 개체 검색
const results = await cowApi.search('KOR001', farmNo, 20);
// GET /cow/search?keyword=KOR001&farmNo=1&limit=20
// 응답: CowDto[]
// 4. 개체 랭킹 조회 (필터 + 정렬)
const ranking = await cowApi.getRanking({
filterOptions: {
filters: [/* 필터 조건 */]
},
rankingOptions: {
criteria: 'GENE',
order: 'DESC'
}
});
// POST /cow/ranking
// 응답: RankingResult<CowDto>
```
---
## 5. 주요 구현 사례
### 5.1 개체 목록 페이지 (/cow/page.tsx)
**완전한 구현 예시** (하드코딩 없음):
```typescript
'use client'
import { useState, useEffect } from 'react'
import { cowApi, farmApi } from '@/lib/api'
import type { Cow } from '@/types/cow.types'
export default function CowListPage() {
const [cows, setCows] = useState<Cow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCows = async () => {
try {
setLoading(true)
setError(null)
// 1단계: 사용자의 농장 목록 조회
const farms = await farmApi.findAll()
if (!farms || farms.length === 0) {
setError('등록된 농장이 없습니다. 농장을 먼저 등록해주세요.')
setLoading(false)
return
}
// 2단계: 첫 번째 농장의 개체 조회
// TODO: 여러 농장이 있을 경우 사용자가 선택할 수 있도록 UI 추가
const farmNo = farms[0].pkFarmNo
const data = await cowApi.findByFarmNo(farmNo)
setCows(data)
} catch (err) {
console.error('개체 데이터 조회 실패:', err)
setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다')
} finally {
setLoading(false)
}
}
fetchCows()
}, [])
if (loading) return <div> ...</div>
if (error) return <div>: {error}</div>
return (
<div>
<h1> </h1>
{cows.map(cow => (
<div key={cow.pkCowNo}>
<p>: {cow.pkCowNo}</p>
<p>: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
</div>
))}
</div>
)
}
```
### 5.2 개체 상세 페이지 (/cow/[cowNo]/page.tsx)
```typescript
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { cowApi } from '@/lib/api'
import type { Cow } from '@/types/cow.types'
export default function CowDetailPage() {
const params = useParams()
const cowNo = params.cowNo as string
const [cow, setCow] = useState<Cow | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchCow = async () => {
try {
const data = await cowApi.findOne(cowNo)
setCow(data)
} catch (err) {
console.error('개체 상세 조회 실패:', err)
} finally {
setLoading(false)
}
}
fetchCow()
}, [cowNo])
if (loading) return <div> ...</div>
if (!cow) return <div> </div>
return (
<div>
<h1> : {cow.pkCowNo}</h1>
<p>: {cow.fkFarmNo}</p>
<p>: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
<p>: {cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString() : '-'}</p>
</div>
)
}
```
### 5.3 Dashboard 통계 페이지
```typescript
'use client'
import { useState, useEffect } from 'react'
import { farmApi, cowApi } from '@/lib/api'
export default function DashboardPage() {
const [stats, setStats] = useState({
totalFarms: 0,
totalCows: 0,
farms: []
})
useEffect(() => {
const fetchStats = async () => {
try {
// 1. 사용자의 농장 목록 조회
const farms = await farmApi.findAll()
// 2. 각 농장의 개체 수 집계
let totalCows = 0
const farmsWithCows = await Promise.all(
farms.map(async (farm) => {
const cows = await cowApi.findByFarmNo(farm.pkFarmNo)
totalCows += cows.length
return {
...farm,
cowCount: cows.length
}
})
)
setStats({
totalFarms: farms.length,
totalCows,
farms: farmsWithCows
})
} catch (err) {
console.error('통계 조회 실패:', err)
}
}
fetchStats()
}, [])
return (
<div>
<h1></h1>
<p> : {stats.totalFarms}</p>
<p> : {stats.totalCows}</p>
{stats.farms.map(farm => (
<div key={farm.pkFarmNo}>
<p>{farm.farmName}: {farm.cowCount}</p>
</div>
))}
</div>
)
}
```
---
## 6. 문제 해결
### 6.1 인증 에러 (401 Unauthorized)
**증상**:
```
{"statusCode":401,"message":["인증이 필요합니다. 로그인 후 이용해주세요."]}
```
**원인**:
- localStorage에 토큰이 없음
- 토큰 만료
**해결 방법**:
```typescript
// 1. 로그인 상태 확인
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
router.push('/login')
return
}
// 2. 토큰 갱신 (필요 시)
await authApi.refreshToken(refreshToken)
```
### 6.2 농장이 없는 경우
**증상**:
```
등록된 농장이 없습니다.
```
**해결 방법**:
```typescript
if (!farms || farms.length === 0) {
return (
<div>
<p> .</p>
<button onClick={() => router.push('/farm/create')}>
</button>
</div>
)
}
```
### 6.3 CORS 에러
**증상**:
```
Access to XMLHttpRequest has been blocked by CORS policy
```
**해결 방법**:
백엔드에서 CORS 설정 확인:
```typescript
// backend/src/main.ts
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
```
---
## 7. 추가 구현 권장사항
### 7.1 여러 농장 선택 UI
현재는 첫 번째 농장만 사용하지만, 사용자가 여러 농장을 소유한 경우 선택할 수 있어야 합니다.
**구현 예시**:
```typescript
'use client'
import { useState, useEffect } from 'react'
import { farmApi, cowApi } from '@/lib/api'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
export default function CowListPage() {
const [farms, setFarms] = useState([])
const [selectedFarmNo, setSelectedFarmNo] = useState<number | null>(null)
const [cows, setCows] = useState([])
useEffect(() => {
const fetchFarms = async () => {
const data = await farmApi.findAll()
setFarms(data)
// 첫 번째 농장 자동 선택
if (data.length > 0) {
setSelectedFarmNo(data[0].pkFarmNo)
}
}
fetchFarms()
}, [])
useEffect(() => {
if (!selectedFarmNo) return
const fetchCows = async () => {
const data = await cowApi.findByFarmNo(selectedFarmNo)
setCows(data)
}
fetchCows()
}, [selectedFarmNo])
return (
<div>
<Select value={String(selectedFarmNo)} onValueChange={(val) => setSelectedFarmNo(Number(val))}>
<SelectTrigger>
<SelectValue placeholder="농장 선택" />
</SelectTrigger>
<SelectContent>
{farms.map(farm => (
<SelectItem key={farm.pkFarmNo} value={String(farm.pkFarmNo)}>
{farm.farmName} ({farm.farmCode})
</SelectItem>
))}
</SelectContent>
</Select>
<div>
{cows.map(cow => (
<div key={cow.pkCowNo}>{cow.pkCowNo}</div>
))}
</div>
</div>
)
}
```
### 7.2 유전자 정보 포함 API 사용
현재 `GET /cow/farm/:farmNo`는 기본 정보만 반환합니다.
유전자 정보가 필요한 경우 `POST /cow/ranking` API를 사용하세요.
**구현 예시**:
```typescript
// 유전자 정보를 포함한 개체 조회
const ranking = await cowApi.getRanking({
filterOptions: {
filters: [
{
field: 'cow.fkFarmNo',
operator: 'equals',
value: farmNo
}
]
},
rankingOptions: {
criteria: 'GENE',
order: 'DESC'
}
})
// ranking.items에 SNP, Trait 등 모든 관계 데이터 포함됨
const cowsWithGenes = ranking.items
```
**백엔드 참조**:
- `backend/src/cow/cow.service.ts:122-140` - `createRankingQueryBuilder()`
- SNP, Trait, Repro, MPT 데이터를 모두 leftJoin으로 포함
### 7.3 에러 바운더리 추가
```typescript
// components/ErrorBoundary.tsx
'use client'
import { useEffect } from 'react'
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('에러 발생:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold mb-4"> </h2>
<p className="mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
</button>
</div>
)
}
```
### 7.4 로딩 스켈레톤 UI
```typescript
// components/CowListSkeleton.tsx
export default function CowListSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="border rounded p-4 animate-pulse">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded"></div>
</div>
))}
</div>
)
}
// 사용
if (loading) return <CowListSkeleton />
```
---
## 8. 참고 자료
### 8.1 백엔드 API 문서
- **Cow API**: `backend/src/cow/cow.controller.ts`
- **Farm API**: `backend/src/farm/farm.controller.ts`
- **Auth API**: `backend/src/auth/auth.controller.ts`
### 8.2 프론트엔드 코드 위치
```
frontend/src/
├── app/
│ ├── cow/
│ │ ├── page.tsx # 개체 목록 (완성)
│ │ └── [cowNo]/page.tsx # 개체 상세
│ └── dashboard/page.tsx # 대시보드
├── lib/api/
│ ├── cow.api.ts # Cow API
│ ├── farm.api.ts # Farm API (신규 추가)
│ └── index.ts # API export
├── types/
│ ├── cow.types.ts # Cow 타입
│ └── auth.types.ts # Auth 타입
└── store/
└── auth-store.ts # 인증 상태 관리
```
### 8.3 타입 정의 참조
**백엔드 Entity → 프론트엔드 Types 매핑**:
| 백엔드 Entity | 프론트엔드 Types | 설명 |
|--------------|-----------------|------|
| `UsersModel` | `UserDto` | 사용자 |
| `FarmModel` | `FarmDto` | 농장 |
| `CowModel` | `CowDto` | 개체 |
| `KpnModel` | `KpnDto` | KPN |
**필드명 주의사항**:
- 백엔드: `pkCowNo`, `fkFarmNo`, `cowBirthDt`, `cowSex`
- 프론트엔드도 동일하게 사용 (DTO 변환 없음)
---
## 9. 체크리스트
개발 시 확인사항:
- [ ] JWT 토큰이 localStorage에 저장되는가?
- [ ] API 호출 시 Authorization 헤더가 자동으로 추가되는가?
- [ ] farmNo를 하드코딩하지 않고 `farmApi.findAll()`로 조회하는가?
- [ ] 농장이 없는 경우를 처리했는가?
- [ ] 에러 발생 시 사용자에게 적절한 메시지를 보여주는가?
- [ ] 로딩 상태를 표시하는가?
- [ ] 여러 농장이 있는 경우를 고려했는가?
---
## 10. 문의
질문이나 문제가 있는 경우:
1. 백엔드 API 문서 확인: `backend/doc/기능요구사항전체정리.md`
2. PRD 문서 확인: `E:/repo5/prd/`
3. 코드 참조:
- 완성된 `/cow/page.tsx` 구현 참조
- `lib/api-client.ts` 인터셉터 참조
- `store/auth-store.ts` 인증 흐름 참조
---
**문서 작성**: Claude Code
**최종 수정일**: 2025-10-26

3613
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/ioredis": "^2.0.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -37,7 +37,7 @@
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"ioredis": "^5.8.1",
"multer": "^2.0.2",
"nodemailer": "^7.0.9",
"passport": "^0.7.0",

View File

@@ -0,0 +1,117 @@
import { BadRequestException, Body, Controller, Get, Post, UploadedFile, UseInterceptors, Logger } from "@nestjs/common";
import { AdminService } from "./admin.service";
import { FileInterceptor } from "@nestjs/platform-express";
import { basename, extname, join } from "path";
import { BaseResultDto } from "src/common/dto/base.result.dto";
import { diskStorage } from "multer";
import * as fs from 'fs/promises';
import { randomUUID } from "crypto";
import { tmpdir } from "os";
/**
※업로드 관련 기능 추후 공통화 처리 필요.
**/
const ALLOWED_EXTENSIONS = ['.xlsx', '.txt', '.csv', '.xls']; // 파일 업로드 허용 확장자
const ALLOWED_MIME_TYPES = [ // 파일 업로드 허용 MIME 타입
'text/plain',
// XLSX
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// XLS (구형 + CSV도 섞여 나옴)
'application/vnd.ms-excel',
// CSV 계열
'text/csv',
'application/csv',
'text/plain',
// 한컴
'application/haansoftxls',
];
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
private readonly logger = new Logger(AdminController.name);
@Get('dashboard')
getDashboard() {
return null;
}
@Post('batchUpload')
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: async (req, file, callback) => {
// 환경 변수가 없으면 시스템 임시 디렉터리 사용
const uploadDir = process.env.UPLOAD_DESTINATION
? join(process.env.UPLOAD_DESTINATION, 'tmp')
: join(tmpdir(), 'genome2025-uploads');
try {
// 디렉터리가 없으면 생성
await fs.mkdir(uploadDir, { recursive: true });
callback(null, uploadDir);
} catch (error) {
callback(error, null);
}
},
filename: (req, file, callback) => {
const ext = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}-${Date.now()}${ext}`);
},
}),
fileFilter: (req, file, callback) => { // 파일 업로드 필터링
const ext = extname(file.originalname).toLowerCase();
const mime = file.mimetype;
if (!ALLOWED_EXTENSIONS.includes(ext)) { // 허용되지 않은 확장자 필터링
return callback(
new BadRequestException(`허용되지 않은 확장자: ${ext}`),
false,
);
}
if (!ALLOWED_MIME_TYPES.includes(mime)) { // 허용되지 않은 MIME 타입 필터링
return callback(
new BadRequestException(`허용되지 않은 MIME 타입: ${mime}`),
false,
);
}
callback(null, true);
}
}))
async batchUpload(@UploadedFile() file: Express.Multer.File, @Body('div') div: string) {
let divName = '';
try {
if (!file?.path){
throw new BadRequestException('파일 업로드 실패')
};
if (div === 'genome-result') { // 유전체 분석 결과(DGV)
divName = '유전체 분석 결과(DGV)';
await this.adminService.batchInsertGenomeResult(file);
}else if (div === 'snp-typing') { // 개체별 SNP 타이핑 결과(유전자 타이핑 결과)
divName = '개체별 SNP 타이핑 결과(유전자 타이핑 결과)';
await this.adminService.batchInsertSnpTyping(file);
}else if (div === 'mpt-result') { // MPT 분석결과(종합혈액화학검사결과서)
divName = 'MPT 분석결과(종합혈액화학검사결과서)';
await this.adminService.batchInsertMptResult(file);
}
// else if (div === 'animal-info') { // 소 정보 입력은 어디서 처리?
// divName = '소 정보 입력';
// return this.adminService.batchUploadAnimalInfo(file);
// }
return BaseResultDto.ok(`${divName} 파일 업로드 성공.\n데이터 입력 중...`, 'SUCCESS', 'OK');
} catch (error) {
return BaseResultDto.fail(`${divName} 파일 업로드 실패.\n${error.message}`, 'FAIL');
} finally {
await fs.unlink(file.path).catch(() => {}); // 파일 삭제
this.logger.log(`[batchUpload] ${divName} 파일 업로드 완료`);
}
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { GenomeRequestModel } from "../genome/entities/genome-request.entity";
import { CowModel } from "../cow/entities/cow.entity";
import { GenomeTraitDetailModel } from "../genome/entities/genome-trait-detail.entity";
import { MptModel } from "src/mpt/entities/mpt.entity";
import { FarmModel } from "src/farm/entities/farm.entity";
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
@Module({
imports: [
TypeOrmModule.forFeature([
GenomeRequestModel,
CowModel,
GenomeTraitDetailModel,
MptModel,
FarmModel,
GeneDetailModel,
]),
],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,847 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { GenomeTraitDetailModel } from "src/genome/entities/genome-trait-detail.entity";
import { GenomeRequestModel } from "src/genome/entities/genome-request.entity";
import { CowModel } from "src/cow/entities/cow.entity";
import { Repository, IsNull, In } from "typeorm";
import { MptModel } from "src/mpt/entities/mpt.entity";
import { MptDto } from "src/mpt/dto/mpt.dto";
import { parseNumber, parseDate } from "src/common/utils";
import { ExcelUtil } from "src/common/excel/excel.util";
import { createReadStream } from "fs";
import * as readline from 'readline';
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
// 유전체 분석 결과 Repository
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 유전체 분석 의뢰 Repository
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
// 소 개체 Repository
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
// 혈액화학검사 결과 Repository
@InjectRepository(MptModel)
private readonly mptRepository: Repository<MptModel>,
// 유전자 상세 정보 Repository
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@Inject(ExcelUtil)
private readonly excelUtil: ExcelUtil,
) {}
async batchInsertGenomeResult(file: Express.Multer.File) {
this.logger.log(`[배치업로드] 유전체 분석 결과 파일 처리 시작: ${file.originalname}`);
try {
// 엑셀파일 로드
const rawData = this.excelUtil.parseExcelData(file);
const headerRow = rawData[0];
const dataRows = rawData.slice(1);
// 헤더에서 형질명 추출 및 인덱스 매핑
// 컬럼 구조: A(samplename), B, C, D~DD(형질 데이터)
// 형질 패턴: "XXXXX", "XXXXX_표준화육종가", "XXXXX_백분율"
const traitMap = new Map<string, {
name: string;
valIndex: number | null;
ebvIndex: number | null;
percentileIndex: number | null;
}>();
// 헤더 분석 (인덱스 3부터 시작, D열부터)
for (let colIdx = 3; colIdx < headerRow.length && colIdx <= 107; colIdx++) {
const headerValue = headerRow[colIdx];
if (!headerValue || typeof headerValue !== 'string') {
continue;
}
const trimmedHeader = headerValue.trim();
// 형질명 추출 (접미사 제거)
if (trimmedHeader.endsWith('_표준화육종가')) {
const traitName = trimmedHeader.replace('_표준화육종가', '');
if (!traitMap.has(traitName)) {
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(traitName)!.ebvIndex = colIdx;
} else if (trimmedHeader.endsWith('_백분율')) {
const traitName = trimmedHeader.replace('_백분율', '');
if (!traitMap.has(traitName)) {
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(traitName)!.percentileIndex = colIdx;
} else {
// 형질명 단독 (trait_val)
// 이미 존재하는 형질인지 확인 (접미사가 있는 경우 이미 추가됨)
if (!traitMap.has(trimmedHeader)) {
traitMap.set(trimmedHeader, { name: trimmedHeader, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(trimmedHeader)!.valIndex = colIdx;
}
}
this.logger.log(`[배치업로드] 형질 개수: ${traitMap.size}`);
// 데이터 행 처리
const traitDataArray: Array<{
cowId: string;
traitName: string;
traitVal: number | null;
traitEbv: number | null;
traitPercentile: number | null;
}> = [];
for (let rowIdx = 0; rowIdx < dataRows.length; rowIdx++) {
const row = dataRows[rowIdx];
// A열(인덱스 0): cowId (samplename)
const cowId = row[0];
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
this.logger.warn(`[배치업로드] ${rowIdx + 2}행: cowId가 없어 건너뜀`);
continue;
}
const trimmedCowId = cowId.trim();
// 각 형질별로 데이터 추출
for (const [traitName, indices] of traitMap.entries()) {
const traitVal = indices.valIndex !== null && row[indices.valIndex] !== null && row[indices.valIndex] !== undefined
? parseNumber(row[indices.valIndex])
: null;
const traitEbv = indices.ebvIndex !== null && row[indices.ebvIndex] !== null && row[indices.ebvIndex] !== undefined
? parseNumber(row[indices.ebvIndex])
: null;
const traitPercentile = indices.percentileIndex !== null && row[indices.percentileIndex] !== null && row[indices.percentileIndex] !== undefined
? parseNumber(row[indices.percentileIndex])
: null;
// 값이 하나라도 있으면 추가
if (traitVal !== null || traitEbv !== null || traitPercentile !== null) {
traitDataArray.push({
cowId: trimmedCowId,
traitName: traitName,
traitVal: traitVal,
traitEbv: traitEbv,
traitPercentile: traitPercentile,
});
}
}
}
this.logger.log(`[배치업로드] 파싱 완료: ${traitDataArray.length}개 형질 데이터`);
// ============================================
// 3단계: Json Array 각 객체 fk_request_no 추가
// - fk_request_no 추가 방법 : 소 식별번호(cowId)로 조회 후 fk_request_no 추가
// ============================================
this.logger.log('[배치업로드] 3단계: fk_request_no 조회 중...');
// 고유한 cowId 목록 추출
const uniqueCowIds = [...new Set(traitDataArray.map(item => item.cowId))];
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}`);
// cowId별로 Cow와 GenomeRequest 조회
const cowIdToRequestNoMap = new Map<string, number | null>();
for (const cowId of uniqueCowIds) {
try {
// Step 1: cowId로 개체 조회
const cow = await this.cowRepository.findOne({
where: { cowId: cowId, delDt: IsNull() },
});
if (!cow) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
cowIdToRequestNoMap.set(cowId, null);
continue;
}
// Step 2: 해당 개체의 최신 분석 의뢰 조회
const request = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
});
if (!request) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 유전체 분석 의뢰를 찾을 수 없습니다.`);
cowIdToRequestNoMap.set(cowId, null);
continue;
}
cowIdToRequestNoMap.set(cowId, request.pkRequestNo);
} catch (error) {
this.logger.error(`[배치업로드] cowId "${cowId}" 조회 중 오류: ${error.message}`);
cowIdToRequestNoMap.set(cowId, null);
}
}
// fk_request_no 추가
const traitDataWithRequestNo = traitDataArray.map(item => {
const fkRequestNo = cowIdToRequestNoMap.get(item.cowId);
return {
...item,
fkRequestNo: fkRequestNo,
};
});
// fk_request_no가 없는 데이터 필터링
const validTraitData = traitDataWithRequestNo.filter(item => item.fkRequestNo !== null);
const invalidCount = traitDataWithRequestNo.length - validTraitData.length;
if (invalidCount > 0) {
this.logger.warn(`[배치업로드] fk_request_no가 없는 데이터 ${invalidCount}건 제외`);
}
this.logger.log(`[배치업로드] 유효한 형질 데이터: ${validTraitData.length}`);
// ============================================
// 4단계: 데이터 DB Insert : 비동기 batchInsert(upsert)
// ============================================
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
let successCount = 0;
let errorCount = 0;
// 배치 크기 설정 (한 번에 처리할 데이터 수)
const BATCH_SIZE = 100;
for (let i = 0; i < validTraitData.length; i += BATCH_SIZE) {
const batch = validTraitData.slice(i, i + BATCH_SIZE);
try {
// 각 배치를 upsert 처리
const insertPromises = batch.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 traitName 기준)
const existing = await this.genomeTraitDetailRepository.findOne({
where: {
cowId: item.cowId,
traitName: item.traitName,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkRequestNo = item.fkRequestNo!;
existing.traitVal = item.traitVal;
existing.traitEbv = item.traitEbv;
existing.traitPercentile = item.traitPercentile;
await this.genomeTraitDetailRepository.save(existing);
} else {
// 삽입
const newTraitDetail = this.genomeTraitDetailRepository.create({
fkRequestNo: item.fkRequestNo!,
cowId: item.cowId,
traitName: item.traitName,
traitVal: item.traitVal,
traitEbv: item.traitEbv,
traitPercentile: item.traitPercentile,
});
await this.genomeTraitDetailRepository.save(newTraitDetail);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, traitName: ${item.traitName}): ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
successCount += results.filter(r => r === true).length;
errorCount += results.filter(r => r === false).length;
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validTraitData.length)}/${validTraitData.length}`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
errorCount += batch.length;
}
}
// ============================================
// 5단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}`);
return {
success: true,
total: traitDataArray.length,
valid: validTraitData.length,
successCount: successCount,
errorCount: errorCount,
excludedCount: invalidCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
async batchInsertSnpTyping(file: Express.Multer.File) {
this.logger.log(`[배치업로드] SNP 타이핑 결과 파일 처리 시작: ${file.originalname}`);
try {
// ============================================
// 1단계: 텍스트 파일 스트림 읽기
// ============================================
this.logger.log('[배치업로드] 1단계: 텍스트 파일 스트림 읽기 중...');
const stream = createReadStream(file.path, {
encoding: 'utf-8',
highWaterMark: 64 * 1024, // 64KB 버퍼
});
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let section: 'NONE' | 'HEADER' | 'DATA_WAIT_HEADER' | 'DATA' = 'NONE';
let dataHeader: string[] | null = null;
// 컬럼 인덱스 매핑
const COLUMN_MAPPING = {
SNP_NAME: 'SNP Name',
SAMPLE_ID: 'Sample ID',
ALLELE1: 'Allele1 - Top',
ALLELE2: 'Allele2 - Top',
CHR: 'Chr',
POSITION: 'Position',
SNP: 'SNP',
};
// 컬럼 인덱스 캐시 (헤더 읽은 후 한 번만 계산)
let columnIndices: {
snpNameIdx: number;
sampleIdIdx: number;
allele1Idx: number;
allele2Idx: number;
chrIdx: number;
positionIdx: number;
snpIdx: number;
} | null = null;
// ============================================
// 2단계: 스트림 방식으로 파일 파싱 및 배치 단위 DB 저장
// ============================================
this.logger.log('[배치업로드] 2단계: 스트림 방식 파일 파싱 및 배치 저장 중...');
// 배치 버퍼 (메모리에 일정 크기만 유지)
const BATCH_SIZE = 5000; // 배치 크기 (메모리 사용량 제어)
const batchBuffer: Array<{
cowId: string;
snpName: string;
chromosome: string | null;
position: string | null;
snpType: string | null;
allele1: string | null;
allele2: string | null;
}> = [];
let totalRows = 0;
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
// 배치 저장 함수
const flushBatch = async () => {
if (batchBuffer.length === 0) return;
try {
// 배치 단위로 upsert 처리
const insertPromises = batchBuffer.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 snpName 기준)
const existing = await this.geneDetailRepository.findOne({
where: {
cowId: item.cowId,
snpName: item.snpName,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkRequestNo = null; // cowId 조회 제거로 null 처리
existing.chromosome = item.chromosome;
existing.position = item.position;
existing.snpType = item.snpType;
existing.allele1 = item.allele1;
existing.allele2 = item.allele2;
await this.geneDetailRepository.save(existing);
} else {
// 삽입
const newGeneDetail = this.geneDetailRepository.create({
cowId: item.cowId,
snpName: item.snpName,
fkRequestNo: null, // cowId 조회 제거로 null 처리
chromosome: item.chromosome,
position: item.position,
snpType: item.snpType,
allele1: item.allele1,
allele2: item.allele2,
});
await this.geneDetailRepository.save(newGeneDetail);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, snpName: ${item.snpName}): ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
const batchSuccess = results.filter(r => r === true).length;
const batchError = results.filter(r => r === false).length;
successCount += batchSuccess;
errorCount += batchError;
this.logger.log(`[배치업로드] 배치 저장 완료: ${batchSuccess}건 성공, ${batchError}건 실패 (총 처리: ${totalRows}건)`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 저장 중 오류: ${error.message}`);
errorCount += batchBuffer.length;
} finally {
// 배치 버퍼 초기화 (메모리 해제)
batchBuffer.length = 0;
}
};
// 파일 라인별 처리
for await (const rawLine of rl) {
const line = rawLine.trim();
if (!line) continue;
// 섹션 전환
if (line === '[Header]') {
section = 'HEADER';
continue;
}
if (line === '[Data]') {
section = 'DATA_WAIT_HEADER';
continue;
}
// [Header] 섹션은 무시
if (section === 'HEADER') {
continue;
}
// [Data] 다음 줄 = 컬럼 헤더
if (section === 'DATA_WAIT_HEADER') {
dataHeader = rawLine.split('\t').map(s => s.trim());
if (dataHeader.length < 2) {
throw new Error('[Data] 헤더 라인이 비정상입니다. (탭 구분 여부 확인 필요)');
}
// 컬럼 인덱스 계산 및 캐시
columnIndices = {
snpNameIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP_NAME),
sampleIdIdx: dataHeader.indexOf(COLUMN_MAPPING.SAMPLE_ID),
allele1Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE1),
allele2Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE2),
chrIdx: dataHeader.indexOf(COLUMN_MAPPING.CHR),
positionIdx: dataHeader.indexOf(COLUMN_MAPPING.POSITION),
snpIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP),
};
if (columnIndices.snpNameIdx === -1 || columnIndices.sampleIdIdx === -1) {
throw new Error(`필수 컬럼이 없습니다. (SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx})`);
}
this.logger.log(`[배치업로드] Data 헤더 컬럼 수: ${dataHeader.length}`);
this.logger.log(`[배치업로드] 컬럼 인덱스 - SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx}, Chr: ${columnIndices.chrIdx}, Position: ${columnIndices.positionIdx}, SNP: ${columnIndices.snpIdx}`);
section = 'DATA';
continue;
}
// 데이터 라인 처리
if (section === 'DATA') {
if (!dataHeader || !columnIndices) {
throw new Error('dataHeader 또는 columnIndices가 초기화되지 않았습니다.');
}
const values = rawLine.split('\t');
// 컬럼 수 불일치 시 스킵
if (values.length !== dataHeader.length) {
this.logger.warn(
`[배치업로드] 컬럼 수 불일치: header=${dataHeader.length}, values=${values.length} / line=${rawLine.slice(0, 120)}...`,
);
skippedCount++;
continue;
}
// 필수 필드 검증
const cowId = values[columnIndices.sampleIdIdx]?.trim();
const snpName = values[columnIndices.snpNameIdx]?.trim();
if (!cowId || !snpName) {
this.logger.warn(`[배치업로드] 필수 필드 누락: cowId=${cowId}, snpName=${snpName}`);
skippedCount++;
continue;
}
// 배치 버퍼에 추가
batchBuffer.push({
cowId,
snpName,
chromosome: columnIndices.chrIdx !== -1 ? (values[columnIndices.chrIdx]?.trim() || null) : null,
position: columnIndices.positionIdx !== -1 ? (values[columnIndices.positionIdx]?.trim() || null) : null,
snpType: columnIndices.snpIdx !== -1 ? (values[columnIndices.snpIdx]?.trim() || null) : null,
allele1: columnIndices.allele1Idx !== -1 ? (values[columnIndices.allele1Idx]?.trim() || null) : null,
allele2: columnIndices.allele2Idx !== -1 ? (values[columnIndices.allele2Idx]?.trim() || null) : null,
});
totalRows++;
// 배치 크기에 도달하면 즉시 DB에 저장
if (batchBuffer.length >= BATCH_SIZE) {
await flushBatch();
}
}
}
// 마지막 남은 배치 처리
await flushBatch();
// ============================================
// 3단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 총 처리: ${totalRows}건, 성공: ${successCount}건, 실패: ${errorCount}건, 스킵: ${skippedCount}`);
return {
success: true,
total: totalRows,
successCount: successCount,
errorCount: errorCount,
skippedCount: skippedCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
/**
* 혈액화학검사 결과 배치 삽입
* @param file - 파일
* @returns 성공 여부
*/
async batchInsertMptResult(file: Express.Multer.File) {
this.logger.log(`[배치업로드] 혈액화학검사 결과 파일 처리 시작: ${file.originalname}`);
try {
// ============================================
// 1단계: 엑셀파일 로드
// ============================================
this.logger.log('[배치업로드] 1단계: 엑셀 파일 로드 중...');
const rawData = this.excelUtil.parseExcelData(file);
if (rawData.length < 6) {
throw new Error('엑셀 파일에 데이터가 부족합니다. (최소 6행 필요: 헤더 5행 + 데이터 1행)');
}
// ============================================
// 2단계: 데이터 파싱 및 검증
// ============================================
this.logger.log('[배치업로드] 2단계: 데이터 파싱 중...');
// MptDto를 기반으로 하되, 파싱 단계에서는 null 허용
const mptDataArray: Array<Partial<MptDto> & {
cowId: string;
fkFarmNo: number | null;
}> = [];
// 5행부터 데이터 시작 (인덱스 5)
for (let rowIdx = 5; rowIdx < rawData.length; rowIdx++) {
const row = rawData[rowIdx];
// cowId 검증 (A열, 인덱스 0)
const cowId = row[0];
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId가 없어 건너뜀`);
continue;
}
const trimmedCowId = cowId.trim();
// cowShortNo 추출 (길이 검증)
let cowShortNo: string;
if (trimmedCowId.length >= 11) {
cowShortNo = trimmedCowId.slice(7, 11);
} else {
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId 길이가 부족하여 cowShortNo 추출 실패 (cowId: ${trimmedCowId})`);
cowShortNo = trimmedCowId.slice(-4) || trimmedCowId; // 최소한 뒤 4자리 또는 전체
}
// 날짜 파싱 (C열, 인덱스 2)
const testDt = parseDate(row[2]);
// 숫자 필드 파싱
const monthAge = parseNumber(row[3]);
const milkYield = parseNumber(row[4]);
const parity = parseNumber(row[5]);
const glucose = parseNumber(row[6]);
const cholesterol = parseNumber(row[7]);
const nefa = parseNumber(row[8]);
const bcs = parseNumber(row[9]);
const totalProtein = parseNumber(row[10]);
const albumin = parseNumber(row[11]);
const globulin = parseNumber(row[12]);
const agRatio = parseNumber(row[13]);
const bun = parseNumber(row[14]);
const ast = parseNumber(row[15]);
const ggt = parseNumber(row[16]);
const fattyLiverIdx = parseNumber(row[17]);
const calcium = parseNumber(row[18]);
const phosphorus = parseNumber(row[19]);
const caPRatio = parseNumber(row[20]);
const magnesium = parseNumber(row[21]);
const creatine = parseNumber(row[22]);
mptDataArray.push({
cowId: `KOR${trimmedCowId}`,
cowShortNo,
fkFarmNo: null, // 3단계에서 채움
testDt,
monthAge,
milkYield,
parity,
glucose,
cholesterol,
nefa,
bcs,
totalProtein,
albumin,
globulin,
agRatio,
bun,
ast,
ggt,
fattyLiverIdx,
calcium,
phosphorus,
caPRatio,
magnesium,
creatine,
});
}
this.logger.log(`[배치업로드] 파싱 완료: ${mptDataArray.length}`);
// ============================================
// 3단계: cowId별 fkFarmNo 조회
// ============================================
this.logger.log('[배치업로드] 3단계: fkFarmNo 조회 중...');
// 고유한 cowId 목록 추출
const uniqueCowIds = [...new Set(mptDataArray.map(item => item.cowId))];
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}`);
// cowId별 fkFarmNo 매핑 생성 (일괄 조회로 성능 최적화)
const cowIdToFarmNoMap = new Map<string, number | null>();
if (uniqueCowIds.length > 0) {
try {
// 모든 cowId를 한 번에 조회 (IN 쿼리)
const cows = await this.cowRepository.find({
where: {
cowId: In(uniqueCowIds),
delDt: IsNull(),
},
});
// 조회된 결과를 Map으로 변환
const foundCowIdSet = new Set<string>();
for (const cow of cows) {
if (cow.cowId) {
cowIdToFarmNoMap.set(cow.cowId, cow.fkFarmNo || null);
foundCowIdSet.add(cow.cowId);
}
}
// 조회되지 않은 cowId는 null로 설정
for (const cowId of uniqueCowIds) {
if (!foundCowIdSet.has(cowId)) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
cowIdToFarmNoMap.set(cowId, null);
}
}
this.logger.log(`[배치업로드] 조회 성공: ${cows.length}/${uniqueCowIds.length}`);
} catch (error) {
this.logger.error(`[배치업로드] cowId 일괄 조회 중 오류: ${error.message}`);
// 에러 발생 시 모든 cowId를 null로 설정
for (const cowId of uniqueCowIds) {
cowIdToFarmNoMap.set(cowId, null);
}
}
}
// fkFarmNo 추가
const mptDataWithFarmNo = mptDataArray.map(item => ({
...item,
fkFarmNo: cowIdToFarmNoMap.get(item.cowId) || null,
}));
// fkFarmNo가 있는 유효한 데이터만 필터링
const validMptData = mptDataWithFarmNo.filter(item => item.fkFarmNo !== null);
const invalidCount = mptDataWithFarmNo.length - validMptData.length;
if (invalidCount > 0) {
this.logger.warn(`[배치업로드] fkFarmNo가 없는 데이터 ${invalidCount}건 제외`);
}
this.logger.log(`[배치업로드] 유효한 MPT 데이터: ${validMptData.length}`);
// ============================================
// 4단계: 데이터 DB Insert (Upsert)
// ============================================
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
let successCount = 0;
let errorCount = 0;
// 배치 크기 설정 (한 번에 처리할 데이터 수)
const BATCH_SIZE = 100;
for (let i = 0; i < validMptData.length; i += BATCH_SIZE) {
const batch = validMptData.slice(i, i + BATCH_SIZE);
try {
// 각 배치를 upsert 처리
const insertPromises = batch.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 testDt 기준)
const existing = await this.mptRepository.findOne({
where: {
cowId: item.cowId,
testDt: item.testDt,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkFarmNo = item.fkFarmNo!;
existing.cowShortNo = item.cowShortNo;
existing.monthAge = item.monthAge;
existing.milkYield = item.milkYield;
existing.parity = item.parity;
existing.glucose = item.glucose;
existing.cholesterol = item.cholesterol;
existing.nefa = item.nefa;
existing.bcs = item.bcs;
existing.totalProtein = item.totalProtein;
existing.albumin = item.albumin;
existing.globulin = item.globulin;
existing.agRatio = item.agRatio;
existing.bun = item.bun;
existing.ast = item.ast;
existing.ggt = item.ggt;
existing.fattyLiverIdx = item.fattyLiverIdx;
existing.calcium = item.calcium;
existing.phosphorus = item.phosphorus;
existing.caPRatio = item.caPRatio;
existing.magnesium = item.magnesium;
existing.creatine = item.creatine;
await this.mptRepository.save(existing);
} else {
// 삽입
const newMpt = this.mptRepository.create({
cowId: item.cowId,
cowShortNo: item.cowShortNo,
fkFarmNo: item.fkFarmNo!,
testDt: item.testDt,
monthAge: item.monthAge,
milkYield: item.milkYield,
parity: item.parity,
glucose: item.glucose,
cholesterol: item.cholesterol,
nefa: item.nefa,
bcs: item.bcs,
totalProtein: item.totalProtein,
albumin: item.albumin,
globulin: item.globulin,
agRatio: item.agRatio,
bun: item.bun,
ast: item.ast,
ggt: item.ggt,
fattyLiverIdx: item.fattyLiverIdx,
calcium: item.calcium,
phosphorus: item.phosphorus,
caPRatio: item.caPRatio,
magnesium: item.magnesium,
creatine: item.creatine,
});
await this.mptRepository.save(newMpt);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
successCount += results.filter(r => r === true).length;
errorCount += results.filter(r => r === false).length;
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validMptData.length)}/${validMptData.length}`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
errorCount += batch.length;
}
}
// ============================================
// 5단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}`);
return {
success: true,
total: mptDataArray.length,
valid: validMptData.length,
successCount: successCount,
errorCount: errorCount,
excludedCount: invalidCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
}

View File

@@ -3,66 +3,66 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisModule } from './redis/redis.module';
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';
import { AdminModule } from './admin/admin.module';
import { ExcelModule } from './common/excel/excel.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('POSTGRES_HOST'),
port: configService.get('POSTGRES_PORT'),
username: configService.get('POSTGRES_USER'),
password: configService.get('POSTGRES_PASSWORD'),
database: configService.get('POSTGRES_DB'),
synchronize: configService.get('POSTGRES_SYNCHRONIZE'),
logging: configService.get('POSTGRES_LOGGING'),
autoLoadEntities: true,
entities: [],
}),
}),
// 인프라 모듈
RedisModule,
JwtModule,
CommonModule,
SharedModule,
// 인증/사용자 모듈
AuthModule,
UserModule,
// 비즈니스 모듈
FarmModule,
CowModule,
GenomeModule,
GeneModule,
MptModule,
DashboardModule,
// 기타
HelpModule,
],
controllers: [AppController],
providers: [AppService, JwtStrategy],
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('POSTGRES_HOST'),
port: configService.get('POSTGRES_PORT'),
username: configService.get('POSTGRES_USER'),
password: configService.get('POSTGRES_PASSWORD'),
database: configService.get('POSTGRES_DB'),
synchronize: configService.get('POSTGRES_SYNCHRONIZE'),
logging: configService.get('POSTGRES_LOGGING'),
autoLoadEntities: true,
entities: [],
}),
}),
// 인프라 모듈
JwtModule,
CommonModule,
SharedModule,
// 인증/사용자 모듈
AuthModule,
UserModule,
// 비즈니스 모듈
FarmModule,
CowModule,
GenomeModule,
GeneModule,
MptModule,
// 관리자 모듈
AdminModule,
// 기타
SystemModule,
ExcelModule,
],
controllers: [AppController],
providers: [AppService, JwtStrategy],
})
export class AppModule {}

View File

@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModel } from '../user/entities/user.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { JwtModule } from 'src/common/jwt/jwt.module';
import { EmailModule } from 'src/shared/email/email.module';
import { VerificationModule } from 'src/shared/verification/verification.module';
@@ -13,7 +15,7 @@ import { VerificationModule } from 'src/shared/verification/verification.module'
*/
@Module({
imports: [
TypeOrmModule.forFeature([UserModel]),
TypeOrmModule.forFeature([UserModel, FarmModel, GenomeRequestModel]),
JwtModule,
EmailModule,
VerificationModule,

View File

@@ -3,10 +3,13 @@ import {
Injectable,
NotFoundException,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserModel } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { Repository, IsNull } from 'typeorm';
import { LoginDto } from './dto/login.dto';
import { LoginResponseDto } from './dto/login-response.dto';
import { SignupDto } from './dto/signup.dto';
@@ -30,9 +33,15 @@ import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(UserModel)
private readonly userRepository: Repository<UserModel>,
@InjectRepository(FarmModel)
private readonly farmRepository: Repository<FarmModel>,
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
private readonly emailService: EmailService,
private readonly verificationService: VerificationService,
private readonly jwtService: JwtService,
@@ -41,57 +50,48 @@ export class AuthService {
/**
* 유저 로그인
*
* @async
* @param {LoginDto} loginDto
* @returns {Promise<LoginResponseDto>}
*/
async login(loginDto: LoginDto): Promise<LoginResponseDto> {
const { userId, userPassword } = loginDto;
this.logger.log(`[LOGIN] 로그인 시도 - userId: ${userId}`);
// 1. userId로 유저 찾기
const user = await this.userRepository.findOne({
where: { userId },
});
// 2. user 없으면 에러
if (!user) {
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); //HTTP 401 상태 코드 예외
}
// 3. 비밀번호 비교 (bcrypt)
const tempHash = await bcrypt.hash(userPassword, 10);
console.log('=========input password bcrypt hash========:', tempHash);
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
if (!isPasswordValid) {
if (!user) {
this.logger.warn(`[LOGIN] 사용자 없음 - userId: ${userId}`);
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
}
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
const inputHash = await bcrypt.hash(userPassword, 10);
this.logger.log(`[DEBUG] 입력 해시: ${inputHash}, DB 해시: ${user.userPw}`);
if (!isPasswordValid) {
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
}
// 4. 탈퇴 여부 확인
if (user.delDt !== null) {
this.logger.warn(`[LOGIN] 탈퇴 계정 - userId: ${userId}`);
throw new UnauthorizedException('탈퇴한 계정입니다');
}
// 6. JWT 토큰 생성
const payload = {
userId: user.userId,
userNo: user.pkUserNo,
};
// Access Token 생성 (기본 설정 사용)
const accessToken = this.jwtService.sign(payload as any);
// Refresh Token 생성 (별도 secret과 만료시간 사용)
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);
// 최근 검사 년도 조회
const defaultAnalysisYear = await this.getDefaultAnalysisYear(user.pkUserNo);
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}, defaultAnalysisYear: ${defaultAnalysisYear}`);
// 7. 로그인 응답 생성 (LoginResponseDto)
return {
message: '로그인 성공',
accessToken, // JWT 토큰 추가
refreshToken, // JWT 토큰 추가
accessToken,
user: {
pkUserNo: user.pkUserNo,
userId: user.userId,
@@ -99,37 +99,84 @@ export class AuthService {
userEmail: user.userEmail,
userRole: user.userRole || 'USER',
},
defaultAnalysisYear,
};
}
/**
* 사용자의 최근 검사 년도 조회
* @param userNo - 사용자 번호
* @returns 최근 검사 년도 (없으면 현재 년도)
*/
private async getDefaultAnalysisYear(userNo: number): Promise<number> {
try {
// 1. 사용자의 농장 번호 조회
const farm = await this.farmRepository.findOne({
where: { fkUserNo: userNo, delDt: IsNull() },
select: ['pkFarmNo'],
});
if (!farm) {
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, No farm found, returning current year`);
return new Date().getFullYear();
}
// 2. 농장의 검사 이력에서 최신 날짜 조회
const result = await this.genomeRequestRepository
.createQueryBuilder('request')
.select('MAX(request.chipReportDt)', 'maxChipDt')
.addSelect('MAX(request.msReportDt)', 'maxMsDt')
.where('request.fkFarmNo = :farmNo', { farmNo: farm.pkFarmNo })
.andWhere('request.delDt IS NULL')
.getRawOne();
const maxChipDt = result?.maxChipDt ? new Date(result.maxChipDt) : null;
const maxMsDt = result?.maxMsDt ? new Date(result.maxMsDt) : null;
// 둘 중 최신 날짜 선택
let latestDate: Date | null = null;
if (maxChipDt && maxMsDt) {
latestDate = maxChipDt > maxMsDt ? maxChipDt : maxMsDt;
} else if (maxChipDt) {
latestDate = maxChipDt;
} else if (maxMsDt) {
latestDate = maxMsDt;
}
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, farmNo: ${farm.pkFarmNo}, maxChipDt: ${maxChipDt?.toISOString()}, maxMsDt: ${maxMsDt?.toISOString()}, year: ${year}`);
return year;
} catch (error) {
this.logger.error(`[getDefaultAnalysisYear] Error: ${error.message}`);
return new Date().getFullYear();
}
}
/**
* 회원가입
*
* @async
* @param {SignupDto} signupDto
* @returns {Promise<SignupResponseDto>}
*/
async register(signupDto: SignupDto, clientIp: string): Promise<SignupResponseDto> {
const { userId, userEmail, userPhone } = signupDto;
this.logger.log(`[REGISTER] 회원가입 시도 - userId: ${userId}, email: ${userEmail}`);
// 0. 이메일 인증 확인 (Redis에 인증 완료 여부 체크)
const verifiedKey = `signup-verified:${userEmail}`;
const isEmailVerified = await this.verificationService.verifyCode(verifiedKey, 'true');
if (!isEmailVerified) {
this.logger.warn(`[REGISTER] 이메일 미인증 - email: ${userEmail}`);
throw new UnauthorizedException('이메일 인증이 완료되지 않았습니다');
}
// 1. 중복 체크 (ID, 이메일, 전화번호, 사업자번호)
const whereConditions = [{ userId }, { userEmail }, { userPhone }];
const existingUser = await this.userRepository.findOne({
where: whereConditions,
});
if (existingUser) {
if (existingUser.userId === userId) {
throw new ConflictException('이미 사용 중인 아이디입니다'); //HTTP 409 상태 코드 예외
throw new ConflictException('이미 사용 중인 아이디입니다');
}
if (existingUser.userEmail === userEmail) {
throw new ConflictException('이미 사용 중인 이메일입니다');
@@ -137,28 +184,24 @@ export class AuthService {
if (existingUser.userPhone === userPhone) {
throw new ConflictException('이미 사용 중인 전화번호입니다');
}
}
// 2. 비밀번호 해싱 (bcrypt)
const saltRounds = parseInt(this.configService.get<string>('BCRYPT_SALT_ROUNDS') || '10', 10);
const hashedPassword = await bcrypt.hash(signupDto.userPassword, saltRounds);
// 3. 사용자 생성
const newUser = this.userRepository.create({
userId: signupDto.userId,
userPw: hashedPassword,
userName: signupDto.userName,
userPhone: signupDto.userPhone,
userEmail: signupDto.userEmail,
regIp: clientIp, // 등록 ip
regIp: clientIp,
regUserId: signupDto.userId,
});
// 4. DB에 저장
const savedUser = await this.userRepository.save(newUser);
this.logger.log(`[REGISTER] 회원가입 성공 - userId: ${savedUser.userId}`);
// 5. 응답 구조 생성 (SignupResponseDto 반환)
return {
message: '회원가입이 완료되었습니다',
redirectUrl: '/dashboard',
@@ -169,10 +212,6 @@ export class AuthService {
/**
* 이메일 중복 체크
*
* @async
* @param {string} userEmail
* @returns {Promise<{ available: boolean; message: string }>}
*/
async checkEmailDuplicate(userEmail: string): Promise<{
available: boolean;
@@ -197,10 +236,6 @@ export class AuthService {
/**
* 회원가입 - 이메일 인증번호 발송
*
* @async
* @param {SendSignupCodeDto} dto
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
*/
async sendSignupCode(dto: SendSignupCodeDto): Promise<{
success: boolean;
@@ -208,42 +243,60 @@ export class AuthService {
expiresIn: number;
}> {
const { userEmail } = dto;
this.logger.log(`[SEND-CODE] ========== 인증번호 발송 시작 ==========`);
this.logger.log(`[SEND-CODE] 이메일: ${userEmail}`);
process.stdout.write(`[SEND-CODE] 이메일: ${userEmail}\n`);
// 1. 이메일 중복 체크
const existingUser = await this.userRepository.findOne({
where: { userEmail },
});
try {
// 1. 이메일 중복 체크
this.logger.log(`[SEND-CODE] Step 1: 이메일 중복 체크`);
const existingUser = await this.userRepository.findOne({
where: { userEmail },
});
if (existingUser) {
throw new ConflictException('이미 사용 중인 이메일입니다');
if (existingUser) {
this.logger.warn(`[SEND-CODE] 이메일 중복 - ${userEmail}`);
throw new ConflictException('이미 사용 중인 이메일입니다');
}
this.logger.log(`[SEND-CODE] Step 1 완료: 중복 없음`);
// 2. 인증번호 생성
this.logger.log(`[SEND-CODE] Step 2: 인증번호 생성`);
const code = this.verificationService.generateCode();
this.logger.log(`[SEND-CODE] 생성된 인증번호: ${code}`);
process.stdout.write(`[SEND-CODE] 생성된 인증번호: ${code}\n`);
// 3. Redis에 저장
this.logger.log(`[SEND-CODE] Step 3: Redis 저장`);
const key = `signup:${userEmail}`;
await this.verificationService.saveCode(key, code);
this.logger.log(`[SEND-CODE] Redis 저장 완료 - key: ${key}`);
// 4. 이메일 발송
this.logger.log(`[SEND-CODE] Step 4: 이메일 발송 시작`);
await this.emailService.sendVerificationCode(userEmail, code);
this.logger.log(`[SEND-CODE] 이메일 발송 완료`);
this.logger.log(`[SEND-CODE] ========== 인증번호 발송 성공 ==========`);
return {
success: true,
message: '인증번호가 이메일로 발송되었습니다',
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
};
} catch (error) {
this.logger.error(`[SEND-CODE] ========== 에러 발생 ==========`);
this.logger.error(`[SEND-CODE] Error Name: ${error.name}`);
this.logger.error(`[SEND-CODE] Error Message: ${error.message}`);
this.logger.error(`[SEND-CODE] Stack: ${error.stack}`);
process.stdout.write(`[SEND-CODE] ERROR: ${error.message}\n`);
process.stdout.write(`[SEND-CODE] STACK: ${error.stack}\n`);
throw error;
}
// 2. 인증번호 생성
const code = this.verificationService.generateCode();
console.log(`[DEBUG] Generated code for ${userEmail}: ${code}`);
// 3. Redis에 저장 (key: signup:이메일)
const key = `signup:${userEmail}`;
await this.verificationService.saveCode(key, code);
console.log(`[DEBUG] Saved code to Redis with key: ${key}`);
// 4. 이메일 발송
await this.emailService.sendVerificationCode(userEmail, code);
console.log(`[DEBUG] Email sent to: ${userEmail}`);
return {
success: true,
message: '인증번호가 이메일로 발송되었습니다',
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
};
}
/**
* 회원가입 - 이메일 인증번호 검증
*
* @async
* @param {VerifySignupCodeDto} dto
* @returns {Promise<{ success: boolean; message: string; verified: boolean }>}
*/
async verifySignupCode(dto: VerifySignupCodeDto): Promise<{
success: boolean;
@@ -251,21 +304,21 @@ export class AuthService {
verified: boolean;
}> {
const { userEmail, code } = dto;
console.log(`[DEBUG] Verifying code for ${userEmail}: ${code}`);
this.logger.log(`[VERIFY-CODE] 인증번호 검증 - email: ${userEmail}`);
// Redis에서 검증
const key = `signup:${userEmail}`;
const isValid = await this.verificationService.verifyCode(key, code);
console.log(`[DEBUG] Verification result: ${isValid}`);
if (!isValid) {
this.logger.warn(`[VERIFY-CODE] 인증 실패 - email: ${userEmail}`);
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
}
// 검증 완료 표시 (5분간 유효)
const verifiedKey = `signup-verified:${userEmail}`;
await this.verificationService.saveCode(verifiedKey, 'true');
this.logger.log(`[VERIFY-CODE] 인증 성공 - email: ${userEmail}`);
return {
success: true,
message: '이메일 인증이 완료되었습니다',
@@ -274,19 +327,14 @@ export class AuthService {
}
/**
* 아이디 찾기 - 인증번호 발송 (이메일 인증)
*
* @async
* @param {SendFindIdCodeDto} dto
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
* 아이디 찾기 - 인증번호 발송
*/
async sendFindIdCode(
dto: SendFindIdCodeDto,
): Promise<{ success: boolean; message: string; expiresIn: number }> {
const { userName, userEmail } = dto;
console.log(`[아이디 찾기] 인증번호 발송 요청 - 이름: ${userName}, 이메일: ${userEmail}`);
this.logger.log(`[FIND-ID] 인증번호 발송 - name: ${userName}, email: ${userEmail}`);
// 1. 사용자 확인
const user = await this.userRepository.findOne({
where: { userName, userEmail },
});
@@ -295,18 +343,12 @@ export class AuthService {
throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다');
}
// 2. 인증번호 생성
const code = this.verificationService.generateCode();
console.log(`[아이디 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`);
// 3. Redis에 저장 (key: find-id:이메일)
const key = `find-id:${userEmail}`;
await this.verificationService.saveCode(key, code);
console.log(`[아이디 찾기] Redis 저장 완료 - Key: ${key}`);
// 4. 이메일 발송
await this.emailService.sendVerificationCode(userEmail, code);
console.log(`[아이디 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`);
this.logger.log(`[FIND-ID] 인증번호 발송 완료 - email: ${userEmail}`);
return {
success: true,
@@ -317,26 +359,18 @@ export class AuthService {
/**
* 아이디 찾기 - 인증번호 검증
*
* @async
* @param {VerifyFindIdCodeDto} dto
* @returns {Promise<FindIdResponseDto>}
*/
async verifyFindIdCode(dto: VerifyFindIdCodeDto): Promise<FindIdResponseDto> {
const { userEmail, verificationCode } = dto;
console.log(`[아이디 찾기] 인증번호 검증 요청 - 이메일: ${userEmail}, 입력 코드: ${verificationCode}`);
this.logger.log(`[FIND-ID] 인증번호 검증 - email: ${userEmail}`);
// 1. 인증번호 검증
const key = `find-id:${userEmail}`;
console.log(`[아이디 찾기] 검증 Key: ${key}`);
const isValid = await this.verificationService.verifyCode(key, verificationCode);
console.log(`[아이디 찾기] 검증 결과: ${isValid}`);
if (!isValid) {
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
}
// 2. 사용자 정보 조회
const user = await this.userRepository.findOne({
where: { userEmail },
});
@@ -345,7 +379,6 @@ export class AuthService {
throw new NotFoundException('사용자를 찾을 수 없습니다');
}
// 3. 아이디 마스킹
const maskedUserId = this.maskUserId(user.userId);
return {
@@ -355,13 +388,6 @@ export class AuthService {
};
}
/**
* 아이디 마스킹 (앞 4자리만 표시)
*
* @private
* @param {string} userId
* @returns {string}
*/
private maskUserId(userId: string): string {
if (userId.length <= 4) {
return userId;
@@ -372,19 +398,14 @@ export class AuthService {
}
/**
* 비밀번호 재설정 - 인증번호 발송 (이메일 인증)
*
* @async
* @param {SendResetPasswordCodeDto} dto
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
* 비밀번호 재설정 - 인증번호 발송
*/
async sendResetPasswordCode(
dto: SendResetPasswordCodeDto,
): Promise<{ success: boolean; message: string; expiresIn: number }> {
const { userId, userEmail } = dto;
console.log(`[비밀번호 찾기] 인증번호 발송 요청 - 아이디: ${userId}, 이메일: ${userEmail}`);
this.logger.log(`[RESET-PW] 인증번호 발송 - userId: ${userId}, email: ${userEmail}`);
// 1. 사용자 확인
const user = await this.userRepository.findOne({
where: { userId, userEmail },
});
@@ -393,18 +414,12 @@ export class AuthService {
throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다');
}
// 2. 인증번호 생성
const code = this.verificationService.generateCode();
console.log(`[비밀번호 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`);
// 3. Redis에 저장 (key: reset-pw:이메일)
const key = `reset-pw:${userEmail}`;
await this.verificationService.saveCode(key, code);
console.log(`[비밀번호 찾기] Redis 저장 완료 - Key: ${key}`);
// 4. 이메일 발송
await this.emailService.sendVerificationCode(userEmail, code);
console.log(`[비밀번호 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`);
this.logger.log(`[RESET-PW] 인증번호 발송 완료 - email: ${userEmail}`);
return {
success: true,
@@ -414,29 +429,21 @@ export class AuthService {
}
/**
* 비밀번호 재설정 - 인증번호 검증 및 재설정 토큰 발급
*
* @async
* @param {VerifyResetPasswordCodeDto} dto
* @returns {Promise<{ success: boolean; message: string; resetToken: string }>}
* 비밀번호 재설정 - 인증번호 검증
*/
async verifyResetPasswordCode(
dto: VerifyResetPasswordCodeDto,
): Promise<{ success: boolean; message: string; resetToken: string }> {
const { userId, userEmail, verificationCode } = dto;
console.log(`[비밀번호 찾기] 인증번호 검증 요청 - 아이디: ${userId}, 이메일: ${userEmail}, 입력 코드: ${verificationCode}`);
this.logger.log(`[RESET-PW] 인증번호 검증 - userId: ${userId}, email: ${userEmail}`);
// 1. 인증번호 검증
const key = `reset-pw:${userEmail}`;
console.log(`[비밀번호 찾기] 검증 Key: ${key}`);
const isValid = await this.verificationService.verifyCode(key, verificationCode);
console.log(`[비밀번호 찾기] 검증 결과: ${isValid}`);
if (!isValid) {
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
}
// 2. 사용자 확인
const user = await this.userRepository.findOne({
where: { userId, userEmail },
});
@@ -445,7 +452,6 @@ export class AuthService {
throw new NotFoundException('사용자를 찾을 수 없습니다');
}
// 3. 비밀번호 재설정 토큰 생성 및 저장 (30분 유효)
const resetToken = await this.verificationService.generateResetToken(userId);
return {
@@ -457,22 +463,17 @@ export class AuthService {
/**
* 비밀번호 재설정 - 새 비밀번호로 변경
*
* @async
* @param {ResetPasswordDto} dto
* @returns {Promise<ResetPasswordResponseDto>}
*/
async resetPassword(dto: ResetPasswordDto): Promise<ResetPasswordResponseDto> {
const { resetToken, newPassword } = dto; // 요청
const { resetToken, newPassword } = dto;
this.logger.log(`[RESET-PW] 비밀번호 변경 시도`);
// 1. 재설정 토큰 검증
const userId = await this.verificationService.verifyResetToken(resetToken);
if (!userId) {
throw new UnauthorizedException('유효하지 않거나 만료된 토큰입니다');
}
// 2. 사용자 조회
const user = await this.userRepository.findOne({
where: { userId },
});
@@ -481,14 +482,14 @@ export class AuthService {
throw new NotFoundException('사용자를 찾을 수 없습니다');
}
// 3. 새 비밀번호 해싱
const saltRounds = parseInt(this.configService.get<string>('BCRYPT_SALT_ROUNDS') || '10', 10);
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
// 4. 비밀번호 업데이트
user.userPw = hashedPassword;
await this.userRepository.save(user);
this.logger.log(`[RESET-PW] 비밀번호 변경 완료 - userId: ${userId}`);
return {
message: '비밀번호가 변경되었습니다',
};

View File

@@ -4,7 +4,6 @@
export class LoginResponseDto {
message: string;
accessToken?: string;
refreshToken?: string;
user: {
pkUserNo: number;
userId: string;
@@ -12,4 +11,5 @@ export class LoginResponseDto {
userEmail: string;
userRole: 'USER' | 'ADMIN';
};
defaultAnalysisYear: number; // 최근 검사 년도
}

View File

@@ -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,

View File

@@ -1,61 +0,0 @@
/**
* 소 용도 분류 설정
*
* @description
* 소의 용도를 결정하는 비즈니스 로직 임계값 정의
* - 도태 (Culling): 낮은 수태율, 번식 능력 부족
* - 인공수정 (Artificial Insemination): 높은 수태율 + 우수 등급
* - 공란우 (Donor): 중간 수태율 + 우수 등급
* - 수란우 (Recipient): 높은 수태율 + 낮은 등급
*/
export const COW_PURPOSE_CONFIG = {
/**
* 수태율 기반 임계값 (%)
*/
CONCEPTION_RATE_THRESHOLDS: {
/**
* 도태 대상 최대 수태율 (30% 미만)
* 수태율이 이 값보다 낮으면 번식 능력이 부족하여 도태 대상
*/
CULLING_MAX: 30,
/**
* 공란우 최대 수태율 (50% 미만)
* 수태율이 낮지만 우수한 유전자 보유 시 수정란 공급
*/
DONOR_MAX: 50,
/**
* 수란우 최소 수태율 (65% 이상)
* 높은 수태율을 가진 소에게 우수 수정란 이식
*/
RECIPIENT_MIN: 65,
/**
* 인공수정 최소 수태율 (65% 이상)
* 높은 수태율 + 우수 등급 → 일반 인공수정 대상
*/
INSEMINATION_MIN: 65,
},
/**
* 나이 기반 임계값 (년)
*/
AGE_THRESHOLDS: {
/**
* 노령우 기준 (10년 이상)
* 이 나이 이상이면 도태 고려 대상
*/
OLD_AGE_YEARS: 10,
/**
* 번식 적정 최소 나이 (2년)
*/
BREEDING_MIN_AGE: 2,
/**
* 번식 적정 최대 나이 (8년)
*/
BREEDING_MAX_AGE: 8,
},
} as const;

View File

@@ -23,11 +23,18 @@ export const VALID_CHIP_SIRE_NAME = '일치';
/** 제외할 어미 칩 이름 값 목록 */
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
/** 순위/평균 집계 대상 지역 (이 지역만 집계에 포함, 테스트/기관 계정은 다른 regionSi 사용) */
export const VALID_REGION = '보은군';
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
export const EXCLUDED_COW_IDS = [
'KOR002191642861',
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
// 일치인데 정보가 없음
// 김정태님 유전체 내역 빠짐 1두
// 근데 유전자 검사내역은 있음
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
// 분석불가로 넣으면 유전자가 조회가 안됨
// 유전자가 조회될수 있는 조건은 불일치와 이력제부재만 가능 // 분석불가는 아예안되는듯
];
//=================================================================================================================
@@ -60,29 +67,29 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): boolean {
// 1. 아비 일치 확인
// 1. 개별 제외 개체 확인
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
// 2. 아비명이 '일치'가 아니면 무효 (null, 불일치, 분석불가, 정보없음 등)
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
// 2. 어미 제외 조건 확인
// 3. 어미명이 '불일치' 또는 '이력제부재'면 무효
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
// 3. 개별 제외 개체 확인
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
return true;
}
/**
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
* 부/모 불일치여도 유전자 데이터 있으면 표시하므로 조건 제거
*
* @param alias - 테이블 별칭 (예: 'request', 'genome')
* @returns SQL 조건 문자열
* @returns SQL 조건 문자열 (항상 true)
*
* @example
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
*/
export function getValidGenomeConditionSQL(alias: string): string {
const damConditions = INVALID_CHIP_DAM_NAMES.map(name => `${alias}.chipDamName != '${name}'`).join(' AND ');
return `${alias}.chipSireName = '${VALID_CHIP_SIRE_NAME}' AND (${alias}.chipDamName IS NULL OR (${damConditions}))`;
// 부/모 불일치 조건 제거 - 유전자 데이터 있으면 모두 표시
return '1=1';
}

View File

@@ -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;

View File

@@ -1,90 +0,0 @@
/**
* MPT 혈액대사검사 정상 범위 기준값
*
* @description
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
*
* @export
* @constant
*/
export const MPT_NORMAL_RANGES = {
/**
* 알부민 (Albumin)
* 단위: g/dL
*/
albumin: { min: 3.3, max: 4.3 },
/**
* 총 글로불린 (Total Globulin)
* 단위: g/L
*/
totalGlobulin: { 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)
* 단위: 지수
*/
fattyLiverIndex: { 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 },
} as const;
/**
* MPT 항목 타입
*/
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
/**
* MPT 범위 타입
*/
export interface MptRange {
min: number;
max: number;
}

View File

@@ -1,105 +0,0 @@
/**
* 추천 시스템 설정 상수
*
* @description
* KPN 추천, 개체 추천, 패키지 추천 등 추천 시스템 관련 설정값
*
* @source PRD 기능요구사항20.md SFR-COW-016, SFR-COW-037
*/
export const RECOMMENDATION_CONFIG = {
/**
* 유전자 매칭 점수 관련
*/
GENE_SCORE: {
/**
* 점수 차이 임계값
* 유전자 매칭 점수 차이가 이 값보다 작으면 근친도를 우선 고려
*/
DIFF_THRESHOLD: 5,
},
/**
* 기본값
*/
DEFAULTS: {
/**
* 근친도 임계값 (%)
* Wright's Coefficient 기준
*/
INBREEDING_THRESHOLD: 12.5,
/**
* 추천 개수
* 상위 N개의 KPN/개체를 추천
*/
RECOMMENDATION_LIMIT: 10,
/**
* 세대제약 기준
* 최근 N세대 이내 사용된 KPN을 추천에서 제외
*/
GENERATION_THRESHOLD: 3,
},
/**
* KPN 패키지 설정
*/
PACKAGE: {
/**
* 기본 패키지 크기
* 추천할 KPN 세트 개수
*/
DEFAULT_SIZE: 5,
/**
* 최소 패키지 크기
*/
MIN_SIZE: 3,
/**
* 최대 패키지 크기
*/
MAX_SIZE: 10,
},
/**
* 커버리지 기준 (%)
* 유전자 목표 달성률 평가 기준
*/
COVERAGE: {
/**
* 우수 기준
* 50% 이상 커버리지
*/
EXCELLENT: 50,
/**
* 양호 기준
* 30% 이상 커버리지
*/
GOOD: 30,
/**
* 최소 기준
* 20% 이상 커버리지
*/
MINIMUM: 20,
},
/**
* KPN 순환 전략
*/
ROTATION: {
/**
* 최소 KPN 개수
* 순환 전략 적용 최소 개수
*/
MIN_KPN_COUNT: 3,
/**
* 재사용 안전 세대
* 동일 KPN을 이 세대 이후에 재사용 가능
*/
SAFE_REUSE_GENERATION: 4,
},
} as const;

View File

@@ -1,6 +0,0 @@
// 계정 상태 Enum
export enum AccountStatusType {
ACTIVE = "ACTIVE", // 정상
INACTIVE = "INACTIVE", // 비활성
SUSPENDED = "SUSPENDED", // 정지
}

View File

@@ -1,5 +0,0 @@
// 개체 타입 Enum
export enum AnimalType {
COW = 'COW', // 개체
KPN = 'KPN', // KPN
}

View File

@@ -1,12 +0,0 @@
/**
* 분석 현황 상태 값 Enum
*
* @export
* @enum {number}
*/
export enum AnlysStatType {
MATCH = '친자일치',
MISMATCH = '친자불일치',
IMPOSSIBLE = '분석불가',
NO_HISTORY = '이력제부재',
}

View File

@@ -1,13 +0,0 @@
/**
* 사육/도태 추천 타입 Enum
*
* @export
* @enum {string}
*/
export enum BreedingRecommendationType {
/** 사육 추천 */
BREED = '사육추천',
/** 도태 추천 */
CULL = '도태추천',
}

View File

@@ -1,7 +0,0 @@
// 개체 번식 타입 Enum
export enum CowReproType {
DONOR = "공란우",
RECIPIENT = "수란우",
AI = "인공수정",
CULL = "도태대상",
}

View File

@@ -1,7 +0,0 @@
// 개체 상태 Enum
export enum CowStatusType {
NORMAL = "정상",
DEAD = "폐사",
SLAUGHTER = "도축",
SALE = "매각",
}

View File

@@ -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 = '마커정보',
}

View 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-muted/50',
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
},
{
key: 'protein',
name: '단백질 대사',
color: 'bg-muted/50',
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
},
{
key: 'liver',
name: '간기능',
color: 'bg-muted/50',
items: ['ast', 'ggt', 'fattyLiverIdx'],
},
{
key: 'mineral',
name: '미네랄',
color: 'bg-muted/50',
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
},
{
key: 'etc',
name: '기타',
color: 'bg-muted/50',
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';
}

View File

@@ -0,0 +1,13 @@
/**
* 랭킹 기준 타입 Enum
*
* @description
* 개체 목록 페이지에서 사용하는 랭킹 기준
*
* @export
* @enum {string}
*/
export enum RankingCriteriaType {
/** 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) */
GENOME = 'GENOME',
}

View 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] ?? '기타';
}

View File

@@ -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;
},
);

View File

@@ -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;
},
);

View File

@@ -0,0 +1,37 @@
// common/dto/base-result.dto.ts
export class BaseResultDto<T = any> {
success: boolean;
code: string;
message: string;
data?: T;
timestamp: string;
constructor(
success: boolean,
code: string,
message: string,
data?: T,
) {
this.success = success;
this.code = code;
this.message = message;
this.data = data;
this.timestamp = new Date().toISOString();
}
static ok<T>(
data?: T,
message = 'SUCCESS',
code = 'OK',
): BaseResultDto<T> {
return new BaseResultDto<T>(true, code, message, data);
}
static fail(
message: string,
code = 'FAIL',
): BaseResultDto<null> {
return new BaseResultDto<null>(false, code, message);
}
}

View File

@@ -0,0 +1,14 @@
import { ExcelUtil } from "./excel.util";
import { Global, Module } from "@nestjs/common";
/**
* Excel 모듈
* Excel 파일 유틸리티를 제공하는 모듈입니다.
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
*/
@Global()
@Module({
providers: [ExcelUtil],
exports: [ExcelUtil],
})
export class ExcelModule {}

View File

@@ -0,0 +1,70 @@
import * as XLSX from "xlsx";
import { Injectable, Logger } from "@nestjs/common";
/**
* Excel 파일 유틸리티
* Lib활용 등 서비스에 가까운 공통 유틸이므로, 서비스 클래스 형태로 생성
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
*/
@Injectable()
export class ExcelUtil {
private readonly logger = new Logger(ExcelUtil.name);
/**
* file 파일 데이터를 파싱하여 json 배열로 변환 - 추후 공통으로 처리 가능하면 정리해서 처리
* @param file - 파싱할 데이터
* @returns json 배열
*/
parseExcelData(file: Express.Multer.File): any[] {
try {
// ============================================
// 1단계: 엑셀파일 로드
// ============================================
this.logger.log('[parseExcelData] 1단계: 엑셀 파일 로드 중...');
let workbook: XLSX.WorkBook;
if(file?.buffer) {
workbook = XLSX.read(file.buffer, { type: 'buffer', cellDates: true });
}else if (file?.path){
workbook = XLSX.readFile(file.path, { cellDates: true });
}else{
throw new Error('file.buffer/file.path가 모두 없습니다.');
}
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('엑셀 파일에 시트가 없습니다.');
}
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
throw new Error(`시트 "${sheetName}"를 읽을 수 없습니다.`);
}
// 시트를 JSON 배열로 변환 (헤더 포함)
const rawData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: null,
raw: false
}) as any[][];
if (rawData.length < 2) {
throw new Error('엑셀 파일에 데이터가 없습니다. (헤더 포함 최소 2행 필요)');
}
this.logger.log(`[parseExcelData] 엑셀 파일 로드 완료: ${rawData.length}`);
return rawData;
} catch (error) {
this.logger.error(`[parseExcelData] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
} finally {
this.logger.log(`[parseExcelData] 처리 완료`);
}
}
}

View File

@@ -4,26 +4,17 @@ import {
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* 모든 예외 필터
*
* @description
* HTTP 예외뿐만 아니라 모든 예외를 잡아서 처리합니다.
* 예상치 못한 에러도 일관된 형식으로 응답합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalFilters(new AllExceptionsFilter());
*
* @export
* @class AllExceptionsFilter
* @implements {ExceptionFilter}
* 모든 예외 필터 - Docker 환경에서 로그 출력 보장
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
@@ -33,7 +24,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
let message: string | string[];
if (exception instanceof HttpException) {
// HTTP 예외 처리
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
@@ -45,14 +35,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
message = exception.message;
}
} else {
// 예상치 못한 에러 처리
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '서버 내부 오류가 발생했습니다.';
// 개발 환경에서는 실제 에러 메시지 표시
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
message = exception.message;
}
message = exception instanceof Error ? exception.message : '서버 내부 오류가 발생했습니다.';
}
const errorResponse = {
@@ -64,16 +48,46 @@ export class AllExceptionsFilter implements ExceptionFilter {
message: Array.isArray(message) ? message : [message],
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
// 스택 트레이스 포함
if (exception instanceof Error) {
(errorResponse as any).stack = exception.stack;
}
// 에러 로깅
console.error(
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
exception,
);
// ========== 상세 로깅 (stdout으로 즉시 출력) ==========
const logMessage = [
'',
'╔══════════════════════════════════════════════════════════════╗',
'║ EXCEPTION OCCURRED ║',
'╚══════════════════════════════════════════════════════════════╝',
` Timestamp : ${errorResponse.timestamp}`,
` Method : ${request.method}`,
` Path : ${request.url}`,
` Status : ${status}`,
` Message : ${JSON.stringify(message)}`,
];
if (exception instanceof Error) {
logMessage.push(` Error Name: ${exception.name}`);
logMessage.push(` Stack :`);
logMessage.push(exception.stack || 'No stack trace');
}
// Request Body 로깅 (민감정보 마스킹)
const safeBody = { ...request.body };
if (safeBody.password) safeBody.password = '****';
if (safeBody.token) safeBody.token = '****';
logMessage.push(` Body : ${JSON.stringify(safeBody)}`);
logMessage.push('══════════════════════════════════════════════════════════════════');
logMessage.push('');
// NestJS Logger 사용 (Docker stdout으로 출력)
this.logger.error(logMessage.join('\n'));
// 추가로 console.error도 출력 (백업)
console.error(logMessage.join('\n'));
// process.stdout으로 직접 출력 (버퍼링 우회)
process.stdout.write(logMessage.join('\n') + '\n');
response.status(status).json(errorResponse);
}

View File

@@ -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';
}
}
}

View File

@@ -3,50 +3,42 @@ import {
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
/**
* 로깅 인터셉터
*
* @description
* API 요청/응답을 로깅하고 실행 시간을 측정합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalInterceptors(new LoggingInterceptor());
*
* @export
* @class LoggingInterceptor
* @implements {NestInterceptor}
* 로깅 인터셉터 - Docker 환경에서 로그 출력 보장
*/
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const now = Date.now();
console.log(
`[${new Date().toISOString()}] Incoming Request: ${method} ${url} - ${ip} - ${userAgent}`,
);
const incomingLog = `[REQUEST] ${method} ${url} - IP: ${ip}`;
this.logger.log(incomingLog);
process.stdout.write(`${new Date().toISOString()} - ${incomingLog}\n`);
return next.handle().pipe(
tap({
next: (data) => {
next: () => {
const responseTime = Date.now() - now;
console.log(
`[${new Date().toISOString()}] Response: ${method} ${url} - ${responseTime}ms`,
);
const successLog = `[RESPONSE] ${method} ${url} - ${responseTime}ms - SUCCESS`;
this.logger.log(successLog);
process.stdout.write(`${new Date().toISOString()} - ${successLog}\n`);
},
error: (error) => {
const responseTime = Date.now() - now;
console.error(
`[${new Date().toISOString()}] Error Response: ${method} ${url} - ${responseTime}ms - ${error.message}`,
);
const errorLog = `[RESPONSE] ${method} ${url} - ${responseTime}ms - ERROR: ${error.message}`;
this.logger.error(errorLog);
process.stdout.write(`${new Date().toISOString()} - ${errorLog}\n`);
},
}),
);

View File

@@ -0,0 +1,75 @@
/**
* 날짜 문자열을 Date 객체로 변환
* @param value - 변환할 값
* @returns Date 객체 또는 null
*/
export function parseDate(value: any): Date | null {
if (value === null || value === undefined || value === '') {
return null;
}
// 이미 Date 객체인 경우
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
// 숫자인 경우 (엑셀 날짜 시리얼 번호)
if (typeof value === 'number') {
// Excel 날짜는 1900-01-01부터의 일수
// 하지만 XLSX 라이브러리가 이미 변환해줄 수 있으므로 일반 Date로 처리
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
// 문자열인 경우
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
// 다양한 날짜 형식 시도
// YYYY-MM-DD, YYYY/MM/DD, YYYYMMDD 등
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
return date;
}
// YYYYMMDD 형식 처리
if (/^\d{8}$/.test(trimmed)) {
const year = parseInt(trimmed.substring(0, 4), 10);
const month = parseInt(trimmed.substring(4, 6), 10) - 1; // 월은 0부터 시작
const day = parseInt(trimmed.substring(6, 8), 10);
const date = new Date(year, month, day);
return isNaN(date.getTime()) ? null : date;
}
}
return null;
}
/**
* 숫자 문자열을 숫자로 변환
* @param value - 변환할 값
* @returns 숫자 또는 null
*/
export function parseNumber(value: any): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'number') {
return isNaN(value) ? null : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = parseFloat(trimmed);
return isNaN(parsed) ? null : parsed;
}
return null;
}

View File

@@ -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';
}

View File

@@ -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);
}
}

View File

@@ -19,6 +19,8 @@ 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 { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
@Module({
@@ -27,6 +29,8 @@ import { FilterEngineModule } from '../shared/filter/filter-engine.module';
CowModel, // 개체 기본 정보 (tb_cow)
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
GeneDetailModel, // 유전자 상세 (tb_gene_detail)
MptModel, // 번식능력 (tb_mpt)
]),
FilterEngineModule, // 필터 엔진 모듈
],

View File

@@ -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';
@@ -19,20 +17,16 @@ 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 { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
import { FilterEngineService } from '../shared/filter/filter-engine.service';
import {
RankingRequestDto,
RankingCriteriaType,
TraitRankingCondition,
TraitRankingConditionDto,
} from './dto/ranking-request.dto';
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
/**
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
*/
const NEGATIVE_TRAITS = ['등지방두께'];
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
import { ALL_TRAITS, NEGATIVE_TRAITS } from '../common/const/TraitTypes';
/**
* 개체(소) 관리 서비스
@@ -57,69 +51,30 @@ export class CowService {
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 유전자 상세 Repository (SNP 데이터 접근용)
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
// 번식능력 Repository (MPT 데이터 접근용)
@InjectRepository(MptModel)
private readonly mptRepository: Repository<MptModel>,
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
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 포함)
* @returns 개체 정보 (farm 포함) + dataStatus (데이터 존재 여부)
* @throws NotFoundException - 개체를 찾을 수 없는 경우
*/
async findByCowId(cowId: string): Promise<CowModel> {
async findByCowId(cowId: string): Promise<CowModel & { dataStatus: { hasGenomeData: boolean; hasGeneData: boolean } }> {
const cow = await this.cowRepository.findOne({
where: { cowId: cowId, delDt: IsNull() },
relations: ['farm'],
@@ -127,7 +82,26 @@ export class CowService {
if (!cow) {
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
}
return cow;
// 데이터 존재 여부 확인 (가벼운 COUNT 쿼리)
const [genomeCount, geneCount] = await Promise.all([
this.genomeTraitDetailRepository.count({
where: { cowId, delDt: IsNull() },
take: 1,
}),
this.geneDetailRepository.count({
where: { cowId, delDt: IsNull() },
take: 1,
}),
]);
return {
...cow,
dataStatus: {
hasGenomeData: genomeCount > 0,
hasGeneData: geneCount > 0,
},
};
}
// ============================================================
@@ -152,61 +126,157 @@ export class CowService {
const { filterOptions, rankingOptions } = rankingRequest;
const { criteriaType } = rankingOptions;
// Step 2: 필터 조건에 맞는 개체 목록 조회
const cows = await this.getFilteredCows(filterOptions);
// Step 2: 필터 조건에 맞는 개체 목록 조회 (+ MPT cowId Set)
const { cows, mptCowIdMap } = await this.getFilteredCows(filterOptions);
// Step 3: 랭킹 기준에 따라 분기 처리
switch (criteriaType) {
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
// 지금은 유전체 형질만 기반으로 랭킹을 매기고 있음 추후 유전자와 유전체 복합 랭킹 변경될수있음
// case 추가 예정
case RankingCriteriaType.GENOME:
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
// 기본값: 랭킹 없이 순서대로 반환
default:
return {
items: cows.map((cow, index) => ({
entity: cow,
rank: index + 1,
sortValue: 0,
})),
items: cows.map((cow, index) => {
const mptData = mptCowIdMap.get(cow.cowId);
return {
entity: {
...cow,
hasMpt: mptCowIdMap.has(cow.cowId),
mptTestDt: mptData?.testDt || null,
mptMonthAge: mptData?.monthAge || null,
},
rank: index + 1,
sortValue: 0,
};
}),
total: cows.length,
criteriaType,
};
}
}
/**
* 필터 조건에 맞는 개체 목록 조회 (Private)
*
* @param filterOptions - 필터/정렬/페이지네이션 옵션
* @returns 필터링된 개체 목록
*/
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
// QueryBuilder로 기본 쿼리 구성
const queryBuilder = this.cowRepository
.createQueryBuilder('cow')
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
/**
* 필터 조건에 맞는 개체 목록 조회 (Private)
* 유전체 분석 의뢰/유전체 형질/유전자/번식능력(MPT) 데이터 중 하나라도 있는 개체 조회
*
* @param filterOptions - 필터/정렬/페이지네이션 옵션
* @returns { cows: 필터링된 개체 목록, mptCowIdMap: MPT cowId -> { testDt, monthAge } Map }
*/
private async getFilteredCows(filterOptions?: any): Promise<{ cows: CowModel[], mptCowIdMap: Map<string, { testDt: string; monthAge: number }> }> {
// Step 1: 4가지 데이터 소스에서 cowId 수집 (병렬 처리)
const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
// 유전체 분석 의뢰가 있는 개체의 cowId (cow 테이블 조인)
this.genomeRequestRepository
.createQueryBuilder('request')
.innerJoin('request.cow', 'cow')
.select('DISTINCT cow.cowId', 'cowId')
.where('request.delDt IS NULL')
.getRawMany(),
// 유전체 형질 데이터가 있는 cowId
this.genomeTraitDetailRepository
.createQueryBuilder('trait')
.select('DISTINCT trait.cowId', 'cowId')
.where('trait.delDt IS NULL')
.getRawMany(),
// 유전자 데이터가 있는 cowId
this.geneDetailRepository
.createQueryBuilder('gene')
.select('DISTINCT gene.cowId', 'cowId')
.where('gene.delDt IS NULL')
.getRawMany(),
// 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령
// cowId별 최신 검사일 기준으로 중복 제거 (GROUP BY)
this.mptRepository
.createQueryBuilder('mpt')
.select('mpt.cowId', 'cowId')
.addSelect('MAX(mpt.testDt)', 'testDt')
.addSelect('MAX(mpt.monthAge)', 'monthAge')
.where('mpt.delDt IS NULL')
.groupBy('mpt.cowId')
.getRawMany(),
]);
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
if (filterOptions?.farmNo) {
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
farmNo: filterOptions.farmNo
});
}
// Step 2: cowId 통합 (중복 제거)
const allCowIds = [...new Set([
...genomeRequestCowIds.map(c => c.cowId).filter(Boolean),
...genomeCowIds.map(c => c.cowId).filter(Boolean),
...geneCowIds.map(c => c.cowId).filter(Boolean),
...mptCowIds.map(c => c.cowId).filter(Boolean),
])];
// FilterEngine 사용하여 동적 필터 적용
if (filterOptions?.filters) {
const result = await this.filterEngineService.executeFilteredQuery(
queryBuilder,
filterOptions,
// MPT cowId -> { testDt, monthAge } Map 생성
const mptCowIdMap = new Map<string, { testDt: string; monthAge: number }>(
mptCowIds
.filter(c => c.cowId)
.map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }])
);
return result.data;
}
// 필터 없으면 전체 조회 (최신순)
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
}
// 데이터가 있는 개체가 없으면 빈 배열 반환 (단, 테스트 농장 예외)
const TEST_FARM_NO = 26; // 코쿤 테스트 농장
// farmNo 체크: filterOptions.farmNo 또는 filterOptions.filters에서 추출
let isTestFarm = Number(filterOptions?.farmNo) === TEST_FARM_NO;
if (!isTestFarm && filterOptions?.filters) {
const farmFilter = filterOptions.filters.find(
(f: { field: string; value: number | number[] }) => f.field === 'cow.fkFarmNo'
);
if (farmFilter) {
const farmNos = Array.isArray(farmFilter.value) ? farmFilter.value : [farmFilter.value];
// 숫자/문자열 모두 처리 (프론트에서 문자열로 올 수 있음)
isTestFarm = farmNos.map(Number).includes(TEST_FARM_NO);
}
}
if (allCowIds.length === 0 && !isTestFarm) {
return { cows: [], mptCowIdMap };
}
// Step 3: 해당 cowId로 개체 조회
const queryBuilder = this.cowRepository
.createQueryBuilder('cow')
.leftJoinAndSelect('cow.farm', 'farm')
.where('cow.delDt IS NULL');
// 테스트 농장(26번)은 tb_cow 전체 조회, 그 외는 데이터 있는 개체만
if (!isTestFarm && allCowIds.length > 0) {
queryBuilder.andWhere('cow.cowId IN (:...cowIds)', { cowIds: allCowIds });
}
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
if (filterOptions?.farmNo) {
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
farmNo: filterOptions.farmNo
});
}
// FilterEngine 사용하여 동적 필터 적용 (페이지네이션 없이 전체 조회)
if (filterOptions?.filters) {
const result = await this.filterEngineService.executeFilteredQuery(
queryBuilder,
{
...filterOptions,
pagination: { page: 1, limit: 10000 }, // 전체 조회 (프론트에서 페이지네이션 처리)
},
);
// cowId 기준 중복 제거 (tb_cow에 같은 cowId가 여러 row일 수 있음)
const uniqueCows = Array.from(
new Map(result.data.map((cow: CowModel) => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
}
// 필터 없으면 전체 조회 (최신순)
const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
// cowId 기준 중복 제거
const uniqueCows = Array.from(
new Map(cows.map(cow => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
}
// ============================================================
// 유전체(GENOME) 랭킹 메서드
@@ -230,19 +300,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
@@ -254,23 +314,47 @@ 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: 친자감별 확인 - 유효하지 않으면 분석 불가
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
// 분석불가 사유 결정
let unavailableReason = '분석불가';
if (latestRequest) {
if (latestRequest.chipSireName !== '일치') {
unavailableReason = '부 불일치';
} else if (latestRequest.chipDamName === '불일치') {
unavailableReason = '모 불일치';
} else if (latestRequest.chipDamName === '이력제부재') {
unavailableReason = '모 이력제부재';
}
let unavailableReason: string | null = null;
// EXCLUDED_COW_IDS에 포함된 개체 (모근 오염/불량 등 기타 사유)
if (EXCLUDED_COW_IDS.includes(cow.cowId)) {
unavailableReason = '분석불가';
} else if (!latestRequest || !latestRequest.chipSireName) {
// latestRequest 없거나 chipSireName이 null → '-' 표시 (프론트에서 null은 '-'로 표시)
unavailableReason = null;
} else if (latestRequest.chipSireName === '분석불가' || latestRequest.chipSireName === '정보없음') {
// 분석불가, 정보없음 → 분석불가
unavailableReason = '분석불가';
} else if (latestRequest.chipSireName !== '일치') {
// 불일치 등 그 외 → 부 불일치
unavailableReason = '부 불일치';
} else if (latestRequest.chipDamName === '불일치') {
unavailableReason = '모 불일치';
} else if (latestRequest.chipDamName === '이력제부재') {
unavailableReason = '모 이력제부재';
}
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
const mptData = mptCowIdMap.get(cow.cowId);
return {
entity: {
...cow,
unavailableReason,
hasMpt: mptCowIdMap.has(cow.cowId),
mptTestDt: mptData?.testDt || null,
mptMonthAge: mptData?.monthAge || null,
anlysDt: latestRequest?.requestDt ?? null,
},
sortValue: null,
details: [],
};
}
// Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
@@ -280,10 +364,21 @@ export class CowService {
// 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음)
if (traitDetails.length === 0) {
return { entity: { ...cow, unavailableReason: '형질정보없음' }, sortValue: null, details: [] };
const mptData = mptCowIdMap.get(cow.cowId);
return {
entity: {
...cow,
unavailableReason: '형질정보없음',
hasMpt: mptCowIdMap.has(cow.cowId),
mptTestDt: mptData?.testDt || null,
mptMonthAge: mptData?.monthAge || null,
},
sortValue: null,
details: [],
};
}
// Step 4: 가중 합계 계산
// Step 4: 가중 합계 계산 ====================================================
let weightedSum = 0; // 가중치 적용된 EBV 합계
let totalWeight = 0; // 총 가중치
let hasAllTraits = true; // 모든 선택 형질 존재 여부
@@ -322,11 +417,15 @@ export class CowService {
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
: null;
// Step 7: 응답 데이터 구성
// Step 7: 응답 데이터 구성 (반환 값)
const mptData = mptCowIdMap.get(cow.cowId);
return {
entity: {
...cow,
anlysDt: latestRequest.requestDt, // 분석일자 추가
hasMpt: mptCowIdMap.has(cow.cowId), // MPT 검사 여부
mptTestDt: mptData?.testDt || null, // MPT 검사일
mptMonthAge: mptData?.monthAge || null, // MPT 월령
},
sortValue, // 계산된 종합 점수 (선발지수)
details, // 점수 계산에 사용된 형질별 상세
@@ -387,45 +486,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);
}
}

View File

@@ -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; // 랭킹 조건
}

View File

@@ -35,7 +35,7 @@ export class CowModel extends BaseModel {
type: 'varchar',
length: 1,
nullable: true,
comment: '성별 (M/F)',
comment: '성별 (암/수)',
})
cowSex: string;

View File

@@ -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);
}
}

View File

@@ -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,
},
};
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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],

View File

@@ -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];
}
}

View File

View File

@@ -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);
}
}

View File

@@ -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,111 +16,15 @@ 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(),
},
relations: ['genomeRequest'],
order: {
chromosome: 'ASC',
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);
}
}

View File

View File

@@ -0,0 +1,25 @@
/**
* 카테고리별 평균 EBV 정보
*/
export interface CategoryAverageDto {
/** 카테고리명 (성장/생산/체형/무게/비율) */
category: string;
/** 평균 EBV 값 (표준화 육종가) */
avgEbv: number;
/** 평균 EPD 값 (원래 육종가) */
avgEpd: number;
/** 해당 카테고리의 데이터 개수 */
count: number;
}
/**
* 전국/지역/농가 비교 평균 데이터
*/
export interface ComparisonAveragesDto {
/** 전국 평균 */
nationwide: CategoryAverageDto[];
/** 지역 평균 */
region: CategoryAverageDto[];
/** 농가 평균 */
farm: CategoryAverageDto[];
}

View File

@@ -0,0 +1,102 @@
/**
* 대시보드 요약 정보 DTO
*/
export interface DashboardSummaryDto {
// 요약
summary: {
totalCows: number; // 검사 받은 전체 개체 수
genomeCowCount: number; // 유전체 분석 개체 수
geneCowCount: number; // 유전자검사 개체 수
mptCowCount: number; // 번식능력검사 개체 수
totalRequests: number; // 유전체 의뢰 건수
analyzedCount: number; // 분석 완료
pendingCount: number; // 대기
mismatchCount: number; // 불일치
maleCount: number; // 수컷 수
femaleCount: number; // 암컷 수
};
// 친자감별 결과 현황
paternityStats: {
analysisComplete: number; // 분석 완료
sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치
damNoRecord: number; // 모 이력제부재
notAnalyzed: number; // 미분석
};
// 검사 종류별 현황
testTypeStats: {
snp: { total: number; completed: number };
ms: { total: number; completed: number };
};
}
/**
* 연도별 통계 DTO
*/
export interface YearlyStatsDto {
// 연도별 분석 현황
yearlyStats: {
year: number;
totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[];
// 월별 접수 현황
monthlyStats: { month: number; count: number }[];
// 연도별 평균 EBV (농가 vs 보은군)
yearlyAvgEbv: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
traitCount: number;
}[];
}
/**
* 형질 평균 DTO
*/
export interface TraitAveragesDto {
traitAverages: {
traitName: string;
category: string;
avgEbv: number;
avgEpd: number;
avgPercentile: number;
count: number;
rank: number | null;
totalFarms: number;
percentile: number | null;
regionAvgEpd?: number;
}[];
// 연도별 형질 평균 (차트용)
yearlyTraitAverages: {
year: number;
traits: { traitName: string; avgEbv: number | null }[];
}[];
}
/**
* 접수 내역 DTO
*/
export interface RequestHistoryDto {
requestHistory: {
pkRequestNo: number;
cowId: string;
cowRemarks: string | null;
requestDt: string | null;
chipSireName: string | null;
chipReportDt: string | null;
status: string;
}[];
}
/**
* 칩/모근 통계 DTO
*/
export interface ChipStatsDto {
chipTypeStats: { chipType: string; count: number }[];
sampleAmountStats: { sampleAmount: string; count: number }[];
}

View File

@@ -0,0 +1,19 @@
/**
* 형질별 평균 EBV 응답 DTO
*/
export interface TraitAverageDto {
traitName: string; // 형질명
category: string; // 카테고리
avgEbv: number; // 평균 EBV (표준화 육종가)
avgEpd: number; // 평균 EPD (육종가 원본값)
count: number; // 데이터 개수
}
/**
* 형질별 비교 평균 응답 DTO
*/
export interface TraitComparisonAveragesDto {
nationwide: TraitAverageDto[]; // 전국 평균
region: TraitAverageDto[]; // 지역(군) 평균
farm: TraitAverageDto[]; // 농장 평균
}

View File

@@ -1,11 +1,13 @@
import { BaseModel } from 'src/common/entities/base.entity';
import { CowModel } from 'src/cow/entities/cow.entity';
import { FarmModel } from 'src/farm/entities/farm.entity';
import { GenomeTraitDetailModel } from './genome-trait-detail.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
@@ -189,4 +191,7 @@ export class GenomeRequestModel extends BaseModel {
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fk_farm_no' })
farm: FarmModel;
@OneToMany(() => GenomeTraitDetailModel, (trait) => trait.genomeRequest)
traitDetails: GenomeTraitDetailModel[];
}

View File

@@ -1,20 +1,6 @@
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;
avgEbv: number;
count: number;
}
export interface ComparisonAveragesDto {
nationwide: CategoryAverageDto[];
region: CategoryAverageDto[];
farm: CategoryAverageDto[];
}
import { ComparisonAveragesDto } from './dto/comparison-averages.dto';
@Controller('genome')
export class GenomeController {
@@ -30,16 +16,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 +43,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 +53,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,30 +89,14 @@ 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
* 특정 개체 상세 정보 조회 (디버깅용)
* GET /genome/yearly-ebv-stats/:farmNo
* 연도별 EBV 통계 (개체상세 > 유전체 통합비교용)
* @param farmNo - 농장 번호
*/
@Public()
@Get('check-cow/:cowId')
checkSpecificCow(@Param('cowId') cowId: string) {
return this.genomeService.checkSpecificCows([cowId]);
@Get('yearly-ebv-stats/:farmNo')
getYearlyEbvStats(@Param('farmNo') farmNo: string) {
return this.genomeService.getYearlyEbvStats(+farmNo);
}
/**
@@ -175,6 +115,16 @@ export class GenomeController {
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
}
/**
* GET /genome/latest-analysis-year/:farmNo
* 농장의 가장 최근 분석 연도 조회 (chip_report_dt 또는 ms_report_dt 기준)
* @param farmNo - 농장 번호
*/
@Get('latest-analysis-year/:farmNo')
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
return this.genomeService.getLatestAnalysisYear(+farmNo);
}
/**
* GET /genome/:cowId
* cowId(개체식별번호)로 유전체 데이터 조회

View File

@@ -6,6 +6,8 @@ import { GenomeRequestModel } from './entities/genome-request.entity';
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
import { CowModel } from '../cow/entities/cow.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
@Module({
imports: [
@@ -14,6 +16,8 @@ import { FarmModel } from '../farm/entities/farm.entity';
GenomeTraitDetailModel,
CowModel,
FarmModel,
MptModel,
GeneDetailModel,
]),
],
controllers: [GenomeController],

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {}

View File

@@ -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
}

View File

@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More