INIT
This commit is contained in:
102
.env
Normal file
102
.env
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# ==============================================
|
||||||
|
# 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=localhost
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=turbo123
|
||||||
|
POSTGRES_DB=genome_db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_SYNCHRONIZE=true
|
||||||
|
POSTGRES_LOGGING=true
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# REDIS CONFIGURATION
|
||||||
|
# ==============================================
|
||||||
|
REDIS_URL=redis://localhost: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.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
|
||||||
79
.env.example
Normal file
79
.env.example
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ==============================================
|
||||||
|
# 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
|
||||||
77
.env.production
Normal file
77
.env.production
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ==============================================
|
||||||
|
# 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
Normal file
101
.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 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
|
||||||
290
README.md
290
README.md
@@ -1 +1,289 @@
|
|||||||
test
|
# 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!** 🎉
|
||||||
56
backend/.gitignore
vendored
Normal file
56
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.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
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.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
|
||||||
20
backend/.prettierrc
Normal file
20
backend/.prettierrc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxBracketSameLine": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.ts",
|
||||||
|
"options": {
|
||||||
|
"parser": "typescript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
COPY package*.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
EXPOSE 4000
|
||||||
98
backend/README.md
Normal file
98
backend/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
19860
backend/database/full_data_dump.sql
Normal file
19860
backend/database/full_data_dump.sql
Normal file
File diff suppressed because it is too large
Load Diff
26080
backend/database/full_database_with_schema.sql
Normal file
26080
backend/database/full_database_with_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
559
backend/database/insert_cow.sql
Normal file
559
backend/database/insert_cow.sql
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
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 */;
|
||||||
17370
backend/database/seeds/dump_가져온거.sql
Normal file
17370
backend/database/seeds/dump_가져온거.sql
Normal file
File diff suppressed because it is too large
Load Diff
6686
backend/database/seeds/seed_all_fixed.sql
Normal file
6686
backend/database/seeds/seed_all_fixed.sql
Normal file
File diff suppressed because it is too large
Load Diff
1720
backend/database/seeds/seed_simple_v2.sql
Normal file
1720
backend/database/seeds/seed_simple_v2.sql
Normal file
File diff suppressed because it is too large
Load Diff
214
backend/database/seeds/seed_simple_v3.sql
Normal file
214
backend/database/seeds/seed_simple_v3.sql
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 간단 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 데이터 생성 완료
|
||||||
|
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정
|
||||||
2521
backend/doc/FRONTEND_IMPLEMENTATION_GUIDE.md
Normal file
2521
backend/doc/FRONTEND_IMPLEMENTATION_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
1044
backend/doc/ux-detail.md
Normal file
1044
backend/doc/ux-detail.md
Normal file
File diff suppressed because it is too large
Load Diff
1234
backend/doc/기능요구사항전체정리.md
Normal file
1234
backend/doc/기능요구사항전체정리.md
Normal file
File diff suppressed because it is too large
Load Diff
719
backend/doc/프론트엔드_API_연동_가이드.md
Normal file
719
backend/doc/프론트엔드_API_연동_가이드.md
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
# 프론트엔드 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
|
||||||
46
backend/eslint.config.mjs
Normal file
46
backend/eslint.config.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
ecmaVersion: 5,
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||||
|
'prettier/prettier': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14789
backend/package-lock.json
generated
Normal file
14789
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
104
backend/package.json
Normal file
104
backend/package.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"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",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.6",
|
||||||
|
"@nestjs/serve-static": "^5.0.3",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@nestjs/websockets": "^11.1.6",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"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",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"typeorm": "^0.3.27",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@swc/cli": "^0.6.0",
|
||||||
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/nodemailer": "^7.0.2",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
backend/src/app.controller.ts
Normal file
14
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { Public } from './common/decorators/public.decorator';
|
||||||
|
|
||||||
|
@Controller() // 루트 경로 '/'
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Public() // healthcheck를 위해 인증 제외
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/src/app.module.ts
Normal file
66
backend/src/app.module.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@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,
|
||||||
|
MptModule,
|
||||||
|
DashboardModule,
|
||||||
|
|
||||||
|
// 기타
|
||||||
|
HelpModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService, JwtStrategy],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
195
backend/src/auth/auth.controller.ts
Normal file
195
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { LoginResponseDto } from './dto/login-response.dto';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { SignupDto } from './dto/signup.dto';
|
||||||
|
import { SignupResponseDto } from './dto/signup-response.dto';
|
||||||
|
import { SendFindIdCodeDto } from './dto/send-find-id-code.dto';
|
||||||
|
import { VerifyFindIdCodeDto } from './dto/verify-find-id-code.dto';
|
||||||
|
import { FindIdResponseDto } from './dto/find-id-response.dto';
|
||||||
|
import { SendResetPasswordCodeDto } from './dto/send-reset-password-code.dto';
|
||||||
|
import { VerifyResetPasswordCodeDto } from './dto/verify-reset-password-code.dto';
|
||||||
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import { ResetPasswordResponseDto } from './dto/reset-password-response.dto';
|
||||||
|
import { SendSignupCodeDto } from './dto/send-signup-code.dto';
|
||||||
|
import { VerifySignupCodeDto } from './dto/verify-signup-code.dto';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 관련 컨트롤러
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 로그인, 회원가입, 아이디 찾기, 비밀번호 재설정 등 인증 관련 API
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class AuthController
|
||||||
|
* @typedef {AuthController}
|
||||||
|
*/
|
||||||
|
@Controller('auth')
|
||||||
|
@Public() // 모든 엔드포인트가 공개 (인증 불필요)
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/login - 사용자 로그인 처리
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {LoginDto} loginDto
|
||||||
|
* @returns {Promise<LoginResponseDto>}
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> {
|
||||||
|
return this.authService.login(loginDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/check-email - 이메일 중복 체크
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} email
|
||||||
|
* @returns {Promise<{ available: boolean; message: string }>}
|
||||||
|
*/
|
||||||
|
@Get('check-email')
|
||||||
|
async checkEmail(
|
||||||
|
@Query('email') email: string,
|
||||||
|
): Promise<{ available: boolean; message: string }> {
|
||||||
|
return this.authService.checkEmailDuplicate(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/signup/send-code - 회원가입 이메일 인증번호 발송
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SendSignupCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||||
|
*/
|
||||||
|
@Post('signup/send-code')
|
||||||
|
async sendSignupCode(
|
||||||
|
@Body() dto: SendSignupCodeDto,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
return this.authService.sendSignupCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/signup/verify-code - 회원가입 이메일 인증번호 검증
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {VerifySignupCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; verified: boolean }>}
|
||||||
|
*/
|
||||||
|
@Post('signup/verify-code')
|
||||||
|
async verifySignupCode(
|
||||||
|
@Body() dto: VerifySignupCodeDto,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
verified: boolean;
|
||||||
|
}> {
|
||||||
|
return this.authService.verifySignupCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/register - 회원가입
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 이메일 인증이 완료된 후에만 회원가입이 가능합니다.
|
||||||
|
* 먼저 /auth/signup/send-code로 인증번호를 받고,
|
||||||
|
* /auth/signup/verify-code로 인증을 완료한 후 호출하세요.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SignupDto} signupDto
|
||||||
|
* @param {Request} req
|
||||||
|
* @returns {Promise<SignupResponseDto>}
|
||||||
|
*/
|
||||||
|
@Post('register')
|
||||||
|
async register(
|
||||||
|
@Body() signupDto: SignupDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
): Promise<SignupResponseDto> {
|
||||||
|
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
return this.authService.register(signupDto, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/find-id/send-code - 아이디 찾기 인증번호 발송
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SendFindIdCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||||
|
*/
|
||||||
|
@Post('find-id/send-code')
|
||||||
|
async sendFindIdCode(
|
||||||
|
@Body() dto: SendFindIdCodeDto,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
return this.authService.sendFindIdCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/find-id/verify-code - 아이디 찾기 인증번호 검증
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {VerifyFindIdCodeDto} dto
|
||||||
|
* @returns {Promise<FindIdResponseDto>}
|
||||||
|
*/
|
||||||
|
@Post('find-id/verify-code')
|
||||||
|
async verifyFindIdCode(
|
||||||
|
@Body() dto: VerifyFindIdCodeDto,
|
||||||
|
): Promise<FindIdResponseDto> {
|
||||||
|
return this.authService.verifyFindIdCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/reset-password/send-code - 비밀번호 재설정 인증번호 발송
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SendResetPasswordCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||||
|
*/
|
||||||
|
@Post('reset-password/send-code')
|
||||||
|
async sendResetPasswordCode(
|
||||||
|
@Body() dto: SendResetPasswordCodeDto,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
return this.authService.sendResetPasswordCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/reset-password/verify-code - 비밀번호 재설정 인증번호 검증
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {VerifyResetPasswordCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; resetToken: string }>}
|
||||||
|
*/
|
||||||
|
@Post('reset-password/verify-code')
|
||||||
|
async verifyResetPasswordCode(
|
||||||
|
@Body() dto: VerifyResetPasswordCodeDto,
|
||||||
|
): Promise<{ success: boolean; message: string; resetToken: string }> {
|
||||||
|
return this.authService.verifyResetPasswordCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/reset-password - 비밀번호 재설정 실행
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {ResetPasswordDto} dto
|
||||||
|
* @returns {Promise<ResetPasswordResponseDto>}
|
||||||
|
*/
|
||||||
|
@Post('reset-password')
|
||||||
|
async resetPassword(
|
||||||
|
@Body() dto: ResetPasswordDto,
|
||||||
|
): Promise<ResetPasswordResponseDto> {
|
||||||
|
return this.authService.resetPassword(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/auth/auth.module.ts
Normal file
25
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { UserModel } from '../user/entities/user.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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 모듈
|
||||||
|
* 로그인, 회원가입, 비밀번호 재설정 등 인증 관련 기능 제공
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserModel]),
|
||||||
|
JwtModule,
|
||||||
|
EmailModule,
|
||||||
|
VerificationModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
493
backend/src/auth/auth.service.ts
Normal file
493
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { UserModel } from '../user/entities/user.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { LoginResponseDto } from './dto/login-response.dto';
|
||||||
|
import { SignupDto } from './dto/signup.dto';
|
||||||
|
import { SignupResponseDto } from './dto/signup-response.dto';
|
||||||
|
import { SendFindIdCodeDto } from './dto/send-find-id-code.dto';
|
||||||
|
import { VerifyFindIdCodeDto } from './dto/verify-find-id-code.dto';
|
||||||
|
import { FindIdResponseDto } from './dto/find-id-response.dto';
|
||||||
|
import { SendResetPasswordCodeDto } from './dto/send-reset-password-code.dto';
|
||||||
|
import { VerifyResetPasswordCodeDto } from './dto/verify-reset-password-code.dto';
|
||||||
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import { ResetPasswordResponseDto } from './dto/reset-password-response.dto';
|
||||||
|
import { SendSignupCodeDto } from './dto/send-signup-code.dto';
|
||||||
|
import { VerifySignupCodeDto } from './dto/verify-signup-code.dto';
|
||||||
|
|
||||||
|
import { EmailService } from 'src/shared/email/email.service';
|
||||||
|
import { VerificationService } from 'src/shared/verification/verification.service';
|
||||||
|
import { VERIFICATION_CONFIG } from 'src/common/config/VerificationConfig';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserModel)
|
||||||
|
private readonly userRepository: Repository<UserModel>,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
private readonly verificationService: VerificationService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유저 로그인
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {LoginDto} loginDto
|
||||||
|
* @returns {Promise<LoginResponseDto>}
|
||||||
|
*/
|
||||||
|
async login(loginDto: LoginDto): Promise<LoginResponseDto> {
|
||||||
|
const { userId, userPassword } = loginDto;
|
||||||
|
|
||||||
|
// 1. userId로 유저 찾기
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
// 2. user 없으면 에러
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); //HTTP 401 상태 코드 예외
|
||||||
|
}
|
||||||
|
// 3. 비밀번호 비교 (bcrypt)
|
||||||
|
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 탈퇴 여부 확인
|
||||||
|
if (user.delDt !== null) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 7. 로그인 응답 생성 (LoginResponseDto)
|
||||||
|
return {
|
||||||
|
message: '로그인 성공',
|
||||||
|
accessToken, // JWT 토큰 추가
|
||||||
|
refreshToken, // JWT 토큰 추가
|
||||||
|
user: {
|
||||||
|
pkUserNo: user.pkUserNo,
|
||||||
|
userId: user.userId,
|
||||||
|
userName: user.userName,
|
||||||
|
userEmail: user.userEmail,
|
||||||
|
userRole: user.userRole || 'USER',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SignupDto} signupDto
|
||||||
|
* @returns {Promise<SignupResponseDto>}
|
||||||
|
*/
|
||||||
|
async register(signupDto: SignupDto, clientIp: string): Promise<SignupResponseDto> {
|
||||||
|
const { userId, userEmail, userPhone } = signupDto;
|
||||||
|
|
||||||
|
// 0. 이메일 인증 확인 (Redis에 인증 완료 여부 체크)
|
||||||
|
const verifiedKey = `signup-verified:${userEmail}`;
|
||||||
|
const isEmailVerified = await this.verificationService.verifyCode(verifiedKey, 'true');
|
||||||
|
|
||||||
|
if (!isEmailVerified) {
|
||||||
|
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 상태 코드 예외
|
||||||
|
}
|
||||||
|
if (existingUser.userEmail === userEmail) {
|
||||||
|
throw new ConflictException('이미 사용 중인 이메일입니다');
|
||||||
|
}
|
||||||
|
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
|
||||||
|
regUserId: signupDto.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. DB에 저장
|
||||||
|
const savedUser = await this.userRepository.save(newUser);
|
||||||
|
|
||||||
|
// 5. 응답 구조 생성 (SignupResponseDto 반환)
|
||||||
|
return {
|
||||||
|
message: '회원가입이 완료되었습니다',
|
||||||
|
redirectUrl: '/dashboard',
|
||||||
|
userId: savedUser.userId,
|
||||||
|
userNo: savedUser.pkUserNo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 중복 체크
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} userEmail
|
||||||
|
* @returns {Promise<{ available: boolean; message: string }>}
|
||||||
|
*/
|
||||||
|
async checkEmailDuplicate(userEmail: string): Promise<{
|
||||||
|
available: boolean;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const existingUser = await this.userRepository.findOne({
|
||||||
|
where: { userEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
message: '이미 사용 중인 이메일입니다',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
message: '사용 가능한 이메일입니다',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 - 이메일 인증번호 발송
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {SendSignupCodeDto} dto
|
||||||
|
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||||
|
*/
|
||||||
|
async sendSignupCode(dto: SendSignupCodeDto): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
const { userEmail } = dto;
|
||||||
|
|
||||||
|
// 1. 이메일 중복 체크
|
||||||
|
const existingUser = await this.userRepository.findOne({
|
||||||
|
where: { userEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('이미 사용 중인 이메일입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
message: string;
|
||||||
|
verified: boolean;
|
||||||
|
}> {
|
||||||
|
const { userEmail, code } = dto;
|
||||||
|
console.log(`[DEBUG] Verifying code for ${userEmail}: ${code}`);
|
||||||
|
|
||||||
|
// Redis에서 검증
|
||||||
|
const key = `signup:${userEmail}`;
|
||||||
|
const isValid = await this.verificationService.verifyCode(key, code);
|
||||||
|
console.log(`[DEBUG] Verification result: ${isValid}`);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검증 완료 표시 (5분간 유효)
|
||||||
|
const verifiedKey = `signup-verified:${userEmail}`;
|
||||||
|
await this.verificationService.saveCode(verifiedKey, 'true');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '이메일 인증이 완료되었습니다',
|
||||||
|
verified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 찾기 - 인증번호 발송 (이메일 인증)
|
||||||
|
*
|
||||||
|
* @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}`);
|
||||||
|
|
||||||
|
// 1. 사용자 확인
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { userName, userEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '인증번호가 이메일로 발송되었습니다',
|
||||||
|
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 찾기 - 인증번호 검증
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {VerifyFindIdCodeDto} dto
|
||||||
|
* @returns {Promise<FindIdResponseDto>}
|
||||||
|
*/
|
||||||
|
async verifyFindIdCode(dto: VerifyFindIdCodeDto): Promise<FindIdResponseDto> {
|
||||||
|
const { userEmail, verificationCode } = dto;
|
||||||
|
console.log(`[아이디 찾기] 인증번호 검증 요청 - 이메일: ${userEmail}, 입력 코드: ${verificationCode}`);
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 아이디 마스킹
|
||||||
|
const maskedUserId = this.maskUserId(user.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: '인증이 완료되었습니다',
|
||||||
|
userId: user.userId,
|
||||||
|
maskedUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 마스킹 (앞 4자리만 표시)
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
private maskUserId(userId: string): string {
|
||||||
|
if (userId.length <= 4) {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
const visiblePart = userId.substring(0, 4);
|
||||||
|
const maskedPart = '*'.repeat(userId.length - 4);
|
||||||
|
return visiblePart + maskedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 - 인증번호 발송 (이메일 인증)
|
||||||
|
*
|
||||||
|
* @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}`);
|
||||||
|
|
||||||
|
// 1. 사용자 확인
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { userId, userEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '인증번호가 이메일로 발송되었습니다',
|
||||||
|
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 - 인증번호 검증 및 재설정 토큰 발급
|
||||||
|
*
|
||||||
|
* @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}`);
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 비밀번호 재설정 토큰 생성 및 저장 (30분 유효)
|
||||||
|
const resetToken = await this.verificationService.generateResetToken(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '인증이 완료되었습니다',
|
||||||
|
resetToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 - 새 비밀번호로 변경
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {ResetPasswordDto} dto
|
||||||
|
* @returns {Promise<ResetPasswordResponseDto>}
|
||||||
|
*/
|
||||||
|
async resetPassword(dto: ResetPasswordDto): Promise<ResetPasswordResponseDto> {
|
||||||
|
const { resetToken, newPassword } = dto; // 요청
|
||||||
|
|
||||||
|
// 1. 재설정 토큰 검증
|
||||||
|
const userId = await this.verificationService.verifyResetToken(resetToken);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException('유효하지 않거나 만료된 토큰입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 사용자 조회
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: '비밀번호가 변경되었습니다',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/auth/dto/find-id-response.dto.ts
Normal file
28
backend/src/auth/dto/find-id-response.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 아이디 찾기 응답 DTO
|
||||||
|
* 표준화된 응답 구조 정의
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class FindIdResponseDto
|
||||||
|
* @typedef {FindIdResponseDto}
|
||||||
|
*/
|
||||||
|
export class FindIdResponseDto {
|
||||||
|
/**
|
||||||
|
* 응답 메시지
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 찾은 사용자 ID
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스킹된 사용자 ID (선택 - 보안 강화)
|
||||||
|
* 예: "testuser" -> "test****"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
maskedUserId?: string;
|
||||||
|
}
|
||||||
15
backend/src/auth/dto/login-response.dto.ts
Normal file
15
backend/src/auth/dto/login-response.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 로그인 응답 DTO
|
||||||
|
*/
|
||||||
|
export class LoginResponseDto {
|
||||||
|
message: string;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user: {
|
||||||
|
pkUserNo: number;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
userRole: 'USER' | 'ADMIN';
|
||||||
|
};
|
||||||
|
}
|
||||||
16
backend/src/auth/dto/login.dto.ts
Normal file
16
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트 로그인 데이터 검증(Validation)
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class LoginDto
|
||||||
|
* @typedef {LoginDto}
|
||||||
|
*/
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
userId: string; // 사용자 ID (로그인 ID)
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
userPassword: string; // 비밀번호
|
||||||
|
}
|
||||||
21
backend/src/auth/dto/reset-password-response.dto.ts
Normal file
21
backend/src/auth/dto/reset-password-response.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 비밀번호 재설정 응답 DTO
|
||||||
|
* 표준화된 응답 구조 정의
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class ResetPasswordResponseDto
|
||||||
|
* @typedef {ResetPasswordResponseDto}
|
||||||
|
*/
|
||||||
|
export class ResetPasswordResponseDto {
|
||||||
|
/**
|
||||||
|
* 응답 메시지
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 비밀번호 (개발 환경에서만 반환, 실무에서는 SMS 발송)
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
tempPassword?: string;
|
||||||
|
}
|
||||||
18
backend/src/auth/dto/reset-password.dto.ts
Normal file
18
backend/src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 요청 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class ResetPasswordDto
|
||||||
|
*/
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
resetToken: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
17
backend/src/auth/dto/send-find-id-code.dto.ts
Normal file
17
backend/src/auth/dto/send-find-id-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 찾기 인증번호 발송 요청 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class SendFindIdCodeDto
|
||||||
|
*/
|
||||||
|
export class SendFindIdCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userName: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
17
backend/src/auth/dto/send-reset-password-code.dto.ts
Normal file
17
backend/src/auth/dto/send-reset-password-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 인증번호 발송 요청 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class SendResetPasswordCodeDto
|
||||||
|
*/
|
||||||
|
export class SendResetPasswordCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
13
backend/src/auth/dto/send-signup-code.dto.ts
Normal file
13
backend/src/auth/dto/send-signup-code.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 인증번호 발송 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class SendSignupCodeDto
|
||||||
|
*/
|
||||||
|
export class SendSignupCodeDto {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
33
backend/src/auth/dto/signup-response.dto.ts
Normal file
33
backend/src/auth/dto/signup-response.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 회원가입 응답 DTO
|
||||||
|
* 표준화된 응답 구조 정의
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class SignupResponseDto
|
||||||
|
* @typedef {SignupResponseDto}
|
||||||
|
*/
|
||||||
|
export class SignupResponseDto {
|
||||||
|
/**
|
||||||
|
* 응답 메시지
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리다이렉트 URL
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
redirectUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성된 사용자 ID (선택)
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성된 사용자 번호 (선택)
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
userNo?: number;
|
||||||
|
}
|
||||||
52
backend/src/auth/dto/signup.dto.ts
Normal file
52
backend/src/auth/dto/signup.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
import { UserSeType } from 'src/common/const/UserSeType';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트 회원가입 데이터 검증(Validation)
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class SignupDto
|
||||||
|
* @typedef {SignupDto}
|
||||||
|
*/
|
||||||
|
export class SignupDto {
|
||||||
|
@IsEnum(UserSeType)
|
||||||
|
@IsNotEmpty()
|
||||||
|
userSe: UserSeType; // 사용자 구분 (FARM/CNSLT/ORGAN)
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userInstName?: string; // 농장명/기관명
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(4)
|
||||||
|
userId: string; // 사용자 ID
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
userPassword: string; // 비밀번호
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userName: string; // 이름
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userPhone: string; // 휴대폰 번호
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
userBirth?: Date; // 생년월일
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string; // 이메일
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userAddress?: string; // 주소
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userBizNo?: string; // 사업자등록번호
|
||||||
|
}
|
||||||
18
backend/src/auth/dto/verify-find-id-code.dto.ts
Normal file
18
backend/src/auth/dto/verify-find-id-code.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 찾기 인증번호 검증 요청 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class VerifyFindIdCodeDto
|
||||||
|
*/
|
||||||
|
export class VerifyFindIdCodeDto {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(6, 6)
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
22
backend/src/auth/dto/verify-reset-password-code.dto.ts
Normal file
22
backend/src/auth/dto/verify-reset-password-code.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 인증번호 검증 요청 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class VerifyResetPasswordCodeDto
|
||||||
|
*/
|
||||||
|
export class VerifyResetPasswordCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(6, 6)
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
17
backend/src/auth/dto/verify-signup-code.dto.ts
Normal file
17
backend/src/auth/dto/verify-signup-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 인증번호 검증 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class VerifySignupCodeDto
|
||||||
|
*/
|
||||||
|
export class VerifySignupCodeDto {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userEmail: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
20
backend/src/common/common.controller.spec.ts
Normal file
20
backend/src/common/common.controller.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CommonController } from './common.controller';
|
||||||
|
import { CommonService } from './common.service';
|
||||||
|
|
||||||
|
describe('CommonController', () => {
|
||||||
|
let controller: CommonController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [CommonController],
|
||||||
|
providers: [CommonService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<CommonController>(CommonController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
7
backend/src/common/common.controller.ts
Normal file
7
backend/src/common/common.controller.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Controller } from '@nestjs/common';
|
||||||
|
import { CommonService } from './common.service';
|
||||||
|
|
||||||
|
@Controller('common')
|
||||||
|
export class CommonController {
|
||||||
|
constructor(private readonly commonService: CommonService) {}
|
||||||
|
}
|
||||||
32
backend/src/common/common.module.ts
Normal file
32
backend/src/common/common.module.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CommonController],
|
||||||
|
providers: [
|
||||||
|
CommonService,
|
||||||
|
JwtStrategy,
|
||||||
|
JwtAuthGuard,
|
||||||
|
HttpExceptionFilter,
|
||||||
|
AllExceptionsFilter,
|
||||||
|
LoggingInterceptor,
|
||||||
|
TransformInterceptor,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
JwtStrategy,
|
||||||
|
JwtAuthGuard,
|
||||||
|
HttpExceptionFilter,
|
||||||
|
AllExceptionsFilter,
|
||||||
|
LoggingInterceptor,
|
||||||
|
TransformInterceptor,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CommonModule {}
|
||||||
18
backend/src/common/common.service.spec.ts
Normal file
18
backend/src/common/common.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CommonService } from './common.service';
|
||||||
|
|
||||||
|
describe('CommonService', () => {
|
||||||
|
let service: CommonService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [CommonService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<CommonService>(CommonService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
backend/src/common/common.service.ts
Normal file
4
backend/src/common/common.service.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommonService {}
|
||||||
61
backend/src/common/config/CowPurposeConfig.ts
Normal file
61
backend/src/common/config/CowPurposeConfig.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 소 용도 분류 설정
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
85
backend/src/common/config/GenomeAnalysisConfig.ts
Normal file
85
backend/src/common/config/GenomeAnalysisConfig.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 유전체 분석 유효성 조건 설정
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
|
||||||
|
*
|
||||||
|
* 유효 조건:
|
||||||
|
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
|
||||||
|
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
|
||||||
|
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
|
||||||
|
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
||||||
|
*
|
||||||
|
* 제외되는 경우:
|
||||||
|
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
|
||||||
|
* - chipDamName === '불일치' (어미 불일치)
|
||||||
|
* - chipDamName === '이력제부재' (어미 이력제 부재)
|
||||||
|
* - 분석불가 개체 (EXCLUDED_COW_IDS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 유효한 아비 칩 이름 값 */
|
||||||
|
export const VALID_CHIP_SIRE_NAME = '일치';
|
||||||
|
|
||||||
|
/** 제외할 어미 칩 이름 값 목록 */
|
||||||
|
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
|
||||||
|
|
||||||
|
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
||||||
|
export const EXCLUDED_COW_IDS = [
|
||||||
|
'KOR002191642861', // 1회 분석 반려내역서 재분석 불가능
|
||||||
|
];
|
||||||
|
//=================================================================================================================
|
||||||
|
|
||||||
|
/** @deprecated INVALID_CHIP_DAM_NAMES 사용 권장 */
|
||||||
|
export const INVALID_CHIP_DAM_NAME = '불일치';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전체 분석 데이터 유효성 검사
|
||||||
|
*
|
||||||
|
* @param chipSireName - 아비 칩 이름 (친자감별 결과)
|
||||||
|
* @param chipDamName - 어미 칩 이름 (친자감별 결과)
|
||||||
|
* @param cowId - 개체식별번호 (선택, 개별 제외 목록 확인용)
|
||||||
|
* @returns 유효한 분석 데이터인지 여부
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 유효한 경우
|
||||||
|
* isValidGenomeAnalysis('일치', '일치') // true
|
||||||
|
* isValidGenomeAnalysis('일치', null) // true
|
||||||
|
* isValidGenomeAnalysis('일치', '정보없음') // true
|
||||||
|
*
|
||||||
|
* // 유효하지 않은 경우
|
||||||
|
* isValidGenomeAnalysis('불일치', '일치') // false (아비 불일치)
|
||||||
|
* isValidGenomeAnalysis('일치', '불일치') // false (어미 불일치)
|
||||||
|
* isValidGenomeAnalysis('일치', '이력제부재') // false (어미 이력제부재)
|
||||||
|
* isValidGenomeAnalysis('일치', '일치', 'KOR002191642861') // false (제외 개체)
|
||||||
|
*/
|
||||||
|
export function isValidGenomeAnalysis(
|
||||||
|
chipSireName: string | null | undefined,
|
||||||
|
chipDamName: string | null | undefined,
|
||||||
|
cowId?: string | null,
|
||||||
|
): boolean {
|
||||||
|
// 1. 아비 일치 확인
|
||||||
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
||||||
|
|
||||||
|
// 2. 어미 제외 조건 확인
|
||||||
|
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 조건 문자열
|
||||||
|
*
|
||||||
|
* @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}))`;
|
||||||
|
}
|
||||||
73
backend/src/common/config/InbreedingConfig.ts
Normal file
73
backend/src/common/config/InbreedingConfig.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 근친도 관련 설정 상수
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
90
backend/src/common/config/MptNormalRanges.ts
Normal file
90
backend/src/common/config/MptNormalRanges.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
62
backend/src/common/config/PaginationConfig.ts
Normal file
62
backend/src/common/config/PaginationConfig.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 페이징 설정 상수
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 목록 조회, 검색, 가상 스크롤링 등의 페이징 기본값
|
||||||
|
*/
|
||||||
|
export const PAGINATION_CONFIG = {
|
||||||
|
/**
|
||||||
|
* 목록별 기본 페이지당 항목 수
|
||||||
|
*/
|
||||||
|
DEFAULTS: {
|
||||||
|
/**
|
||||||
|
* 개체 목록
|
||||||
|
* 일반 개체 목록 조회 시 기본값
|
||||||
|
*/
|
||||||
|
COW_LIST: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 교배조합 목록
|
||||||
|
*/
|
||||||
|
BREED_SAVE: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 결과
|
||||||
|
* 키워드 검색 결과 표시 기본값
|
||||||
|
*/
|
||||||
|
SEARCH_RESULT: 20,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 검색
|
||||||
|
* 5000개 이상 유전자 검색 시 기본값
|
||||||
|
*/
|
||||||
|
GENE_SEARCH: 50,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가상 스크롤링
|
||||||
|
* 무한 스크롤 방식 로딩 단위
|
||||||
|
*/
|
||||||
|
VIRTUAL_SCROLL: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제한값
|
||||||
|
*/
|
||||||
|
LIMITS: {
|
||||||
|
/**
|
||||||
|
* 최소 페이지당 항목 수
|
||||||
|
*/
|
||||||
|
MIN: 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 페이지당 항목 수
|
||||||
|
* 성능 및 메모리 고려
|
||||||
|
*/
|
||||||
|
MAX: 100,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 페이지 번호
|
||||||
|
*/
|
||||||
|
DEFAULT_PAGE: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
105
backend/src/common/config/RecommendationConfig.ts
Normal file
105
backend/src/common/config/RecommendationConfig.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 추천 시스템 설정 상수
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
36
backend/src/common/config/VerificationConfig.ts
Normal file
36
backend/src/common/config/VerificationConfig.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 인증 관련 설정 (이메일)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 이메일 인증, 비밀번호 재설정 등의 인증 관련 상수 값 정의
|
||||||
|
*/
|
||||||
|
export const VERIFICATION_CONFIG = {
|
||||||
|
/**
|
||||||
|
* 인증 코드 만료 시간 (초)
|
||||||
|
* 인증시간 3분 = 180초
|
||||||
|
*/
|
||||||
|
CODE_EXPIRY_SECONDS: 180,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 토큰 만료 시간 (초)
|
||||||
|
* 30분 = 1800초
|
||||||
|
*/
|
||||||
|
RESET_TOKEN_EXPIRY_SECONDS: 1800,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랜덤 토큰 생성을 위한 바이트 길이
|
||||||
|
* 32바이트 = 64자의 16진수 문자열
|
||||||
|
*/
|
||||||
|
TOKEN_BYTES_LENGTH: 32,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 코드 길이 (6자리 숫자)
|
||||||
|
*/
|
||||||
|
CODE_LENGTH: 6,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 코드 재전송 대기 시간 (초)
|
||||||
|
* 1분 = 60초
|
||||||
|
*/
|
||||||
|
RESEND_DELAY_SECONDS: 60,
|
||||||
|
} as const;
|
||||||
6
backend/src/common/const/AccountStatusType.ts
Normal file
6
backend/src/common/const/AccountStatusType.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// 계정 상태 Enum
|
||||||
|
export enum AccountStatusType {
|
||||||
|
ACTIVE = "ACTIVE", // 정상
|
||||||
|
INACTIVE = "INACTIVE", // 비활성
|
||||||
|
SUSPENDED = "SUSPENDED", // 정지
|
||||||
|
}
|
||||||
5
backend/src/common/const/AnimalType.ts
Normal file
5
backend/src/common/const/AnimalType.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// 개체 타입 Enum
|
||||||
|
export enum AnimalType {
|
||||||
|
COW = 'COW', // 개체
|
||||||
|
KPN = 'KPN', // KPN
|
||||||
|
}
|
||||||
12
backend/src/common/const/AnlysStatType.ts
Normal file
12
backend/src/common/const/AnlysStatType.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 분석 현황 상태 값 Enum
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export enum AnlysStatType {
|
||||||
|
MATCH = '친자일치',
|
||||||
|
MISMATCH = '친자불일치',
|
||||||
|
IMPOSSIBLE = '분석불가',
|
||||||
|
NO_HISTORY = '이력제부재',
|
||||||
|
}
|
||||||
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 사육/도태 추천 타입 Enum
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export enum BreedingRecommendationType {
|
||||||
|
/** 사육 추천 */
|
||||||
|
BREED = '사육추천',
|
||||||
|
|
||||||
|
/** 도태 추천 */
|
||||||
|
CULL = '도태추천',
|
||||||
|
}
|
||||||
7
backend/src/common/const/CowReproType.ts
Normal file
7
backend/src/common/const/CowReproType.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// 개체 번식 타입 Enum
|
||||||
|
export enum CowReproType {
|
||||||
|
DONOR = "공란우",
|
||||||
|
RECIPIENT = "수란우",
|
||||||
|
AI = "인공수정",
|
||||||
|
CULL = "도태대상",
|
||||||
|
}
|
||||||
7
backend/src/common/const/CowStatusType.ts
Normal file
7
backend/src/common/const/CowStatusType.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// 개체 상태 Enum
|
||||||
|
export enum CowStatusType {
|
||||||
|
NORMAL = "정상",
|
||||||
|
DEAD = "폐사",
|
||||||
|
SLAUGHTER = "도축",
|
||||||
|
SALE = "매각",
|
||||||
|
}
|
||||||
55
backend/src/common/const/FileType.ts
Normal file
55
backend/src/common/const/FileType.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 파일 타입 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 = '마커정보',
|
||||||
|
}
|
||||||
11
backend/src/common/const/UserSeType.ts
Normal file
11
backend/src/common/const/UserSeType.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 사용자 타입 구분 Enum
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export enum UserSeType {
|
||||||
|
FARM = 'FARM', // 농가
|
||||||
|
CNSLT = 'CNSLT', // 컨설턴트
|
||||||
|
ORGAN = 'ORGAN', // 기관담당자
|
||||||
|
}
|
||||||
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
29
backend/src/common/decorators/public.decorator.ts
Normal file
29
backend/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public 데코레이터
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 인증이 필요 없는 공개 엔드포인트를 표시하는 데코레이터입니다.
|
||||||
|
* JwtAuthGuard와 함께 사용하여 특정 엔드포인트의 인증을 건너뜁니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 로그인, 회원가입 등 인증 없이 접근 가능한 엔드포인트
|
||||||
|
* @Public()
|
||||||
|
* @Post('login')
|
||||||
|
* async login(@Body() loginDto: LoginDto) {
|
||||||
|
* return this.authService.login(loginDto);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 전체 컨트롤러를 공개로 설정
|
||||||
|
* @Public()
|
||||||
|
* @Controller('public')
|
||||||
|
* export class PublicController {
|
||||||
|
* // 모든 엔드포인트가 인증 없이 접근 가능
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @constant Public
|
||||||
|
*/
|
||||||
|
export const Public = () => SetMetadata('isPublic', true);
|
||||||
37
backend/src/common/decorators/user.decorator.ts
Normal file
37
backend/src/common/decorators/user.decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
87
backend/src/common/entities/base.entity.ts
Normal file
87
backend/src/common/entities/base.entity.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Column, CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeORM Entity
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @abstract
|
||||||
|
* @class BaseModel
|
||||||
|
* @typedef {BaseModel}
|
||||||
|
*/
|
||||||
|
export abstract class BaseModel {
|
||||||
|
/**
|
||||||
|
* 모든 테이블의 기본 키 (Primary Key)
|
||||||
|
*/
|
||||||
|
// @PrimaryGeneratedColumn()
|
||||||
|
// no: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터가 처음 생성된 시간
|
||||||
|
*/
|
||||||
|
@CreateDateColumn({
|
||||||
|
name: 'reg_dt',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: false,
|
||||||
|
default: () => 'NOW()',
|
||||||
|
comment: '등록일시',
|
||||||
|
})
|
||||||
|
regDt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*데이터가 마지막으로 업데이트된 시간
|
||||||
|
*/
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updt_dt',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
comment: '수정일시',
|
||||||
|
})
|
||||||
|
updtDt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft Delete 삭제일시 (NULL이면 활성 데이터)
|
||||||
|
*/
|
||||||
|
@DeleteDateColumn({
|
||||||
|
name: 'del_dt',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
comment: '삭제일시 (Soft Delete, NULL=활성)',
|
||||||
|
})
|
||||||
|
delDt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'reg_ip',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '등록 IP',
|
||||||
|
})
|
||||||
|
regIp: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'reg_user_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '등록자 ID',
|
||||||
|
})
|
||||||
|
regUserId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'updt_ip',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '수정 IP',
|
||||||
|
})
|
||||||
|
updtIp: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'updt_user_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '수정자 ID',
|
||||||
|
})
|
||||||
|
updtUserId: string;
|
||||||
|
}
|
||||||
80
backend/src/common/filters/all-exceptions.filter.ts
Normal file
80
backend/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 예외 필터
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* HTTP 예외뿐만 아니라 모든 예외를 잡아서 처리합니다.
|
||||||
|
* 예상치 못한 에러도 일관된 형식으로 응답합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // main.ts에서 전역 적용
|
||||||
|
* app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class AllExceptionsFilter
|
||||||
|
* @implements {ExceptionFilter}
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
let status: number;
|
||||||
|
let message: string | string[];
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
// HTTP 예외 처리
|
||||||
|
status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
message = exceptionResponse;
|
||||||
|
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||||
|
message = (exceptionResponse as any).message || exception.message;
|
||||||
|
} else {
|
||||||
|
message = exception.message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 예상치 못한 에러 처리
|
||||||
|
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
message = '서버 내부 오류가 발생했습니다.';
|
||||||
|
|
||||||
|
// 개발 환경에서는 실제 에러 메시지 표시
|
||||||
|
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 환경에서는 스택 트레이스 포함
|
||||||
|
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
|
||||||
|
(errorResponse as any).stack = exception.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 로깅
|
||||||
|
console.error(
|
||||||
|
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
||||||
|
exception,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/src/common/filters/http-exception.filter.ts
Normal file
93
backend/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/src/common/guards/jwt-auth.guard.ts
Normal file
78
backend/src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 인증 가드
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* JWT 토큰 검증 가드입니다.
|
||||||
|
* @Public() 데코레이터가 있는 엔드포인트는 인증을 건너뜁니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 사용법 1: 특정 엔드포인트에 적용
|
||||||
|
* @UseGuards(JwtAuthGuard)
|
||||||
|
* @Get('profile')
|
||||||
|
* getProfile(@Req() req) {
|
||||||
|
* return req.user;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 사용법 2: 전역 적용 (main.ts)
|
||||||
|
* app.useGlobalGuards(new JwtAuthGuard(new Reflector()));
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 사용법 3: 인증 제외
|
||||||
|
* @Public()
|
||||||
|
* @Get('public')
|
||||||
|
* getPublicData() {
|
||||||
|
* return 'Anyone can access';
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class JwtAuthGuard
|
||||||
|
* @extends {AuthGuard('jwt')}
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 확인
|
||||||
|
*
|
||||||
|
* @param {ExecutionContext} context - 실행 컨텍스트
|
||||||
|
* @returns {boolean | Promise<boolean>}
|
||||||
|
*/
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
// @Public() 데코레이터 확인
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Public 엔드포인트는 인증 건너뜀
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT 검증 수행
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 처리
|
||||||
|
*
|
||||||
|
* @param {any} err - 에러
|
||||||
|
* @param {any} user - 사용자 정보
|
||||||
|
* @param {any} info - 추가 정보
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
handleRequest(err: any, user: any, info: any) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new UnauthorizedException('인증이 필요합니다. 로그인 후 이용해주세요.');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/src/common/interceptors/logging.interceptor.ts
Normal file
54
backend/src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} 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}
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: (data) => {
|
||||||
|
const responseTime = Date.now() - now;
|
||||||
|
console.log(
|
||||||
|
`[${new Date().toISOString()}] Response: ${method} ${url} - ${responseTime}ms`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
const responseTime = Date.now() - now;
|
||||||
|
console.error(
|
||||||
|
`[${new Date().toISOString()}] Error Response: ${method} ${url} - ${responseTime}ms - ${error.message}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/common/interceptors/transform.interceptor.ts
Normal file
56
backend/src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 인터페이스 (이메일)
|
||||||
|
*/
|
||||||
|
export interface Response<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 변환 인터셉터
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 모든 API 응답을 일관된 형식으로 변환합니다.
|
||||||
|
* { success: true, data: ..., timestamp: ... } 형태로 래핑합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // main.ts에서 전역 적용
|
||||||
|
* app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 원래 응답: { name: "홍길동" }
|
||||||
|
* // 변환 후: { success: true, data: { name: "홍길동" }, timestamp: "2024-01-01T00:00:00.000Z" }
|
||||||
|
* TransformInterceptor는 모든 응답을 { success, data, timestamp } 구조로 감싼다.
|
||||||
|
* response.data.data.verified → true
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class TransformInterceptor
|
||||||
|
* @implements {NestInterceptor<T, Response<T>>}
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T>
|
||||||
|
implements NestInterceptor<T, Response<T>>
|
||||||
|
{
|
||||||
|
intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Observable<Response<T>> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => ({
|
||||||
|
success: true,
|
||||||
|
data, //여기에 return 데이터 객체 전체 응답
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/common/jwt/jwt.module.ts
Normal file
23
backend/src/common/jwt/jwt.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule as NestJwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
NestJwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService): JwtModuleOptions => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '24h',
|
||||||
|
} as any,
|
||||||
|
global: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [NestJwtModule, PassportModule],
|
||||||
|
})
|
||||||
|
export class JwtModule {}
|
||||||
45
backend/src/common/jwt/jwt.strategy.ts
Normal file
45
backend/src/common/jwt/jwt.strategy.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 전략
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* JWT 토큰을 검증하고 사용자 정보를 추출하는 전략입니다.
|
||||||
|
* Bearer 토큰에서 JWT를 추출하여 검증합니다.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class JwtStrategy
|
||||||
|
* @extends {PassportStrategy(Strategy)}
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false, //false로 설정하면 토큰 만료 시 인증 실패
|
||||||
|
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 페이로드 검증 및 사용자 정보 반환
|
||||||
|
*
|
||||||
|
* @param {any} payload - JWT 페이로드
|
||||||
|
* @returns {any} 검증된 사용자 정보
|
||||||
|
*/
|
||||||
|
validate(payload: any) {
|
||||||
|
if (!payload.userId) {
|
||||||
|
throw new UnauthorizedException('유효하지 않은 토큰입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 객체의 user 속성에 저장됨
|
||||||
|
return {
|
||||||
|
userId: payload.userId,
|
||||||
|
userNo: payload.userNo,
|
||||||
|
role: payload.role || 'user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/src/common/utils/get-client-ip.ts
Normal file
52
backend/src/common/utils/get-client-ip.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
90
backend/src/cow/cow.controller.ts
Normal file
90
backend/src/cow/cow.controller.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 개체(Cow) 컨트롤러
|
||||||
|
* ============================================================
|
||||||
|
*
|
||||||
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
|
*
|
||||||
|
* 엔드포인트:
|
||||||
|
* - GET /cow - 기본 개체 목록 조회
|
||||||
|
* - GET /cow/:id - 개체 상세 조회
|
||||||
|
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
||||||
|
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { CowService } from './cow.service';
|
||||||
|
import { CowModel } from './entities/cow.entity';
|
||||||
|
import { RankingRequestDto } from './dto/ranking-request.dto';
|
||||||
|
|
||||||
|
@Controller('cow')
|
||||||
|
export class CowController {
|
||||||
|
constructor(private readonly cowService: CowService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cow
|
||||||
|
* 기본 개체 목록 조회
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('farmId') farmId?: string) {
|
||||||
|
if (farmId) {
|
||||||
|
return this.cowService.findByFarmId(+farmId);
|
||||||
|
}
|
||||||
|
return this.cowService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /cow/ranking
|
||||||
|
* 랭킹이 적용된 개체 목록 조회
|
||||||
|
*
|
||||||
|
* 사용 페이지: 개체 목록 페이지
|
||||||
|
*/
|
||||||
|
@Post('ranking')
|
||||||
|
findAllWithRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||||
|
return this.cowService.findAllWithRanking(rankingRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /cow/ranking/global
|
||||||
|
* 전체 개체 랭킹 조회 (모든 농장 포함)
|
||||||
|
*
|
||||||
|
* 사용 페이지: 대시보드 (농장 순위 비교)
|
||||||
|
*/
|
||||||
|
@Post('ranking/global')
|
||||||
|
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||||
|
// farmNo 필터 없이 전체 개체 랭킹 조회
|
||||||
|
const globalRequest = {
|
||||||
|
...rankingRequest,
|
||||||
|
filterOptions: {
|
||||||
|
...rankingRequest.filterOptions,
|
||||||
|
farmNo: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.cowService.findAllWithRanking(globalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cow/:cowId
|
||||||
|
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
||||||
|
*/
|
||||||
|
@Get(':cowId')
|
||||||
|
findOne(@Param('cowId') cowId: string) {
|
||||||
|
return this.cowService.findByCowId(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() data: Partial<CowModel>) {
|
||||||
|
return this.cowService.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
|
||||||
|
return this.cowService.update(+id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.cowService.remove(+id);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/cow/cow.module.ts
Normal file
37
backend/src/cow/cow.module.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 개체(Cow) 모듈
|
||||||
|
* ============================================================
|
||||||
|
*
|
||||||
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
|
*
|
||||||
|
* 등록된 엔티티:
|
||||||
|
* - CowModel: 개체 기본 정보
|
||||||
|
* - GenomeRequestModel: 유전체 분석 의뢰
|
||||||
|
* - GenomeTraitDetailModel: 유전체 형질 상세 (35개 형질)
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CowController } from './cow.controller';
|
||||||
|
import { CowService } from './cow.service';
|
||||||
|
import { CowModel } from './entities/cow.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
CowModel, // 개체 기본 정보 (tb_cow)
|
||||||
|
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
||||||
|
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
||||||
|
]),
|
||||||
|
FilterEngineModule, // 필터 엔진 모듈
|
||||||
|
],
|
||||||
|
controllers: [CowController],
|
||||||
|
providers: [CowService],
|
||||||
|
exports: [CowService],
|
||||||
|
})
|
||||||
|
export class CowModule {}
|
||||||
394
backend/src/cow/cow.service.ts
Normal file
394
backend/src/cow/cow.service.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 개체(Cow) 서비스
|
||||||
|
* ============================================================
|
||||||
|
*
|
||||||
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
||||||
|
* 2. 개체 단건 조회 (findOne, findByCowId)
|
||||||
|
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||||
|
* - GENOME: 35개 형질 EBV 가중 평균
|
||||||
|
* 4. 개체 CRUD (create, update, remove)
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { CowModel } from './entities/cow.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||||
|
import {
|
||||||
|
RankingRequestDto,
|
||||||
|
RankingCriteriaType,
|
||||||
|
TraitRankingCondition,
|
||||||
|
} from './dto/ranking-request.dto';
|
||||||
|
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체(소) 관리 서비스
|
||||||
|
*
|
||||||
|
* 담당 기능:
|
||||||
|
* - 개체 CRUD 작업
|
||||||
|
* - 유전체 기반 랭킹 계산
|
||||||
|
* - 필터링 및 정렬
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CowService {
|
||||||
|
constructor(
|
||||||
|
// 개체(소) 테이블 Repository
|
||||||
|
@InjectRepository(CowModel)
|
||||||
|
private readonly cowRepository: Repository<CowModel>,
|
||||||
|
|
||||||
|
// 유전체 분석 의뢰 Repository (형질 데이터 접근용)
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
|
|
||||||
|
// 유전체 형질 상세 Repository (EBV 값 접근용)
|
||||||
|
@InjectRepository(GenomeTraitDetailModel)
|
||||||
|
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||||
|
|
||||||
|
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||||
|
private readonly filterEngineService: FilterEngineService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기본 조회 메서드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 개체 목록 조회
|
||||||
|
*
|
||||||
|
* @returns 삭제되지 않은 모든 개체 목록
|
||||||
|
* - farm 관계 데이터 포함
|
||||||
|
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||||
|
*/
|
||||||
|
async findAll(): Promise<CowModel[]> {
|
||||||
|
return this.cowRepository.find({
|
||||||
|
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||||
|
relations: ['farm'], // 농장 정보 JOIN
|
||||||
|
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장별 개체 목록 조회
|
||||||
|
*
|
||||||
|
* @param farmNo - 농장 PK 번호
|
||||||
|
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
||||||
|
*/
|
||||||
|
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
||||||
|
return this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
order: { regDt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 PK로 단건 조회
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호 (pkCowNo)
|
||||||
|
* @returns 개체 정보 (farm 포함)
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async findOne(id: number): Promise<CowModel> {
|
||||||
|
const cow = await this.cowRepository.findOne({
|
||||||
|
where: { pkCowNo: id, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
});
|
||||||
|
if (!cow) {
|
||||||
|
throw new NotFoundException(`Cow #${id} not found`);
|
||||||
|
}
|
||||||
|
return cow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체식별번호(cowId)로 단건 조회
|
||||||
|
*
|
||||||
|
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
||||||
|
* @returns 개체 정보 (farm 포함)
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async findByCowId(cowId: string): Promise<CowModel> {
|
||||||
|
const cow = await this.cowRepository.findOne({
|
||||||
|
where: { cowId: cowId, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
});
|
||||||
|
if (!cow) {
|
||||||
|
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
||||||
|
}
|
||||||
|
return cow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 랭킹 적용 조회 메서드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랭킹 적용 개체 목록 조회 (메인 API)
|
||||||
|
*
|
||||||
|
* POST /cow/ranking 에서 호출
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
|
* 1. 필터 조건으로 개체 목록 조회
|
||||||
|
* 2. 랭킹 기준(GENOME/GENE)에 따라 점수 계산
|
||||||
|
* 3. 점수 기준 정렬 후 순위 부여
|
||||||
|
*
|
||||||
|
* @param rankingRequest - 필터 옵션 + 랭킹 옵션
|
||||||
|
* @returns 순위가 적용된 개체 목록
|
||||||
|
*/
|
||||||
|
async findAllWithRanking(rankingRequest: RankingRequestDto): Promise<any> {
|
||||||
|
// Step 1: 요청에서 필터 옵션과 랭킹 옵션 추출
|
||||||
|
const { filterOptions, rankingOptions } = rankingRequest;
|
||||||
|
const { criteriaType } = rankingOptions;
|
||||||
|
|
||||||
|
// Step 2: 필터 조건에 맞는 개체 목록 조회
|
||||||
|
const cows = await this.getFilteredCows(filterOptions);
|
||||||
|
|
||||||
|
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||||
|
switch (criteriaType) {
|
||||||
|
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||||
|
case RankingCriteriaType.GENOME:
|
||||||
|
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
|
||||||
|
|
||||||
|
// 기본값: 랭킹 없이 순서대로 반환
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
items: cows.map((cow, index) => ({
|
||||||
|
entity: cow,
|
||||||
|
rank: index + 1,
|
||||||
|
sortValue: 0,
|
||||||
|
})),
|
||||||
|
total: cows.length,
|
||||||
|
criteriaType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건에 맞는 개체 목록 조회 (Private)
|
||||||
|
*
|
||||||
|
* @param filterOptions - 필터/정렬/페이지네이션 옵션
|
||||||
|
* @returns 필터링된 개체 목록
|
||||||
|
*/
|
||||||
|
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
|
||||||
|
// QueryBuilder로 기본 쿼리 구성
|
||||||
|
const queryBuilder = this.cowRepository
|
||||||
|
.createQueryBuilder('cow')
|
||||||
|
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
|
||||||
|
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
|
||||||
|
|
||||||
|
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
|
||||||
|
if (filterOptions?.farmNo) {
|
||||||
|
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
|
||||||
|
farmNo: filterOptions.farmNo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterEngine 사용하여 동적 필터 적용
|
||||||
|
if (filterOptions?.filters) {
|
||||||
|
const result = await this.filterEngineService.executeFilteredQuery(
|
||||||
|
queryBuilder,
|
||||||
|
filterOptions,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 없으면 전체 조회 (최신순)
|
||||||
|
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 유전체(GENOME) 랭킹 메서드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전체 형질 기반 랭킹 적용 (Private)
|
||||||
|
*
|
||||||
|
* 계산 방식: 선택한 형질들의 EBV 가중 평균
|
||||||
|
* - 각 형질에 weight(가중치) 적용 가능
|
||||||
|
* - 모든 선택 형질이 있어야 점수 계산
|
||||||
|
*
|
||||||
|
* @param cows - 필터링된 개체 목록
|
||||||
|
* @param traitConditions - 형질별 가중치 조건 배열
|
||||||
|
* @returns 순위가 적용된 개체 목록
|
||||||
|
* @example
|
||||||
|
* traitConditions = [
|
||||||
|
* { traitNm: '도체중', weight: 8 },
|
||||||
|
* { traitNm: '근내지방도', weight: 10 }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
private async applyGenomeRanking(
|
||||||
|
cows: CowModel[],
|
||||||
|
traitConditions: TraitRankingCondition[],
|
||||||
|
): Promise<any> {
|
||||||
|
// 각 개체별로 점수 계산
|
||||||
|
const cowsWithScore = await Promise.all(
|
||||||
|
cows.map(async (cow) => {
|
||||||
|
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 형질 데이터가 없으면 점수 null
|
||||||
|
if (traitDetails.length === 0) {
|
||||||
|
return { entity: cow, sortValue: null, details: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||||
|
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||||
|
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||||
|
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가
|
||||||
|
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||||
|
return { entity: cow, sortValue: null, details: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: 가중 평균 계산
|
||||||
|
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||||
|
let totalWeight = 0; // 총 가중치
|
||||||
|
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||||
|
const details: any[] = []; // 계산 상세 내역
|
||||||
|
|
||||||
|
// 사용자가 선택한 각 형질에 대해 처리
|
||||||
|
for (const condition of traitConditions) {
|
||||||
|
// 형질명으로 해당 형질 데이터 찾기
|
||||||
|
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
|
||||||
|
const weight = condition.weight || 1; // 가중치 (기본값: 1)
|
||||||
|
|
||||||
|
if (trait && trait.traitEbv !== null) {
|
||||||
|
// EBV 값이 있으면 가중치 적용하여 합산
|
||||||
|
const ebv = Number(trait.traitEbv);
|
||||||
|
weightedSum += ebv * weight; // EBV × 가중치
|
||||||
|
totalWeight += weight; // 가중치 누적
|
||||||
|
|
||||||
|
// 상세 내역 저장 (응답용)
|
||||||
|
details.push({
|
||||||
|
code: condition.traitNm, // 형질명
|
||||||
|
value: ebv, // EBV 값
|
||||||
|
weight, // 적용된 가중치
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 형질이 없으면 플래그 설정
|
||||||
|
hasAllTraits = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: 최종 점수 계산 (가중 평균)
|
||||||
|
// 모든 선택 형질이 있어야만 점수 계산
|
||||||
|
const sortValue = (hasAllTraits && totalWeight > 0)
|
||||||
|
? weightedSum / totalWeight // 가중 평균 = 가중합 / 총가중치
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Step 7: 응답 데이터 구성
|
||||||
|
return {
|
||||||
|
entity: {
|
||||||
|
...cow,
|
||||||
|
anlysDt: latestRequest.requestDt, // 분석일자 추가
|
||||||
|
},
|
||||||
|
sortValue, // 계산된 종합 점수 (선발지수)
|
||||||
|
details, // 점수 계산에 사용된 형질별 상세
|
||||||
|
ranking: {
|
||||||
|
requestNo: latestRequest.pkRequestNo, // 분석 의뢰 번호
|
||||||
|
requestDt: latestRequest.requestDt, // 분석 의뢰일
|
||||||
|
traits: traitDetails.map((d) => ({ // 전체 형질 데이터
|
||||||
|
traitName: d.traitName, // 형질명
|
||||||
|
traitVal: d.traitVal, // 실측값
|
||||||
|
traitEbv: d.traitEbv, // EBV (표준화육종가)
|
||||||
|
traitPercentile: d.traitPercentile, // 백분위
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 백엔드 응답 예시
|
||||||
|
// ========================================
|
||||||
|
// {
|
||||||
|
// "items": [
|
||||||
|
// {
|
||||||
|
// "entity": { "cowId": "KOR123456", "cowNm": "뽀삐", ... },
|
||||||
|
// "sortValue": 85.5, // 가중 평균 점수
|
||||||
|
// "rank": 1, // 순위
|
||||||
|
// "ranking": {
|
||||||
|
// "requestNo": 100,
|
||||||
|
// "traits": [
|
||||||
|
// { "traitName": "도체중", "traitVal": 450, "traitEbv": 12.5 },
|
||||||
|
// { "traitName": "등심단면적", "traitVal": 95, "traitEbv": 8.3 }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// "total": 100,
|
||||||
|
// "criteriaType": "GENOME"
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Step 8: 점수 기준 내림차순 정렬
|
||||||
|
const sorted = cowsWithScore.sort((a, b) => {
|
||||||
|
// null 값은 맨 뒤로
|
||||||
|
if (a.sortValue === null && b.sortValue === null) return 0;
|
||||||
|
if (a.sortValue === null) return 1;
|
||||||
|
if (b.sortValue === null) return -1;
|
||||||
|
// 점수 높은 순 (내림차순)
|
||||||
|
return b.sortValue - a.sortValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 9: 순위 부여 후 반환
|
||||||
|
return {
|
||||||
|
items: sorted.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
rank: index + 1, // 1부터 시작하는 순위
|
||||||
|
})),
|
||||||
|
total: sorted.length,
|
||||||
|
criteriaType: RankingCriteriaType.GENOME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CRUD 메서드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새로운 개체 생성
|
||||||
|
*
|
||||||
|
* @param data - 생성할 개체 데이터
|
||||||
|
* @returns 생성된 개체 엔티티
|
||||||
|
*/
|
||||||
|
async create(data: Partial<CowModel>): Promise<CowModel> {
|
||||||
|
const cow = this.cowRepository.create(data);
|
||||||
|
return this.cowRepository.save(cow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 정보 수정
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호
|
||||||
|
* @param data - 수정할 데이터
|
||||||
|
* @returns 수정된 개체 엔티티
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
||||||
|
await this.findOne(id); // 존재 여부 확인
|
||||||
|
await this.cowRepository.update(id, data);
|
||||||
|
return this.findOne(id); // 수정된 데이터 반환
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 삭제 (Soft Delete)
|
||||||
|
*
|
||||||
|
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const cow = await this.findOne(id); // 존재 여부 확인
|
||||||
|
await this.cowRepository.softRemove(cow);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
backend/src/cow/dto/ranking-request.dto.ts
Normal file
129
backend/src/cow/dto/ranking-request.dto.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 랭킹 요청 DTO
|
||||||
|
* ============================================================
|
||||||
|
*
|
||||||
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
|
*
|
||||||
|
* 프론트에서 POST /cow/ranking 호출 시 사용
|
||||||
|
*
|
||||||
|
* 지원하는 랭킹 기준:
|
||||||
|
* 1. GENOME - 35개 유전체 형질 EBV 가중치 기반
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랭킹 기준 타입
|
||||||
|
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||||
|
*/
|
||||||
|
export enum RankingCriteriaType {
|
||||||
|
GENOME = 'GENOME',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 필터 관련 타입 (FilterEngine에서 사용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type FilterOperator =
|
||||||
|
| 'eq' // 같음
|
||||||
|
| 'ne' // 같지 않음
|
||||||
|
| 'gt' // 초과
|
||||||
|
| 'gte' // 이상
|
||||||
|
| 'lt' // 미만
|
||||||
|
| 'lte' // 이하
|
||||||
|
| 'like' // 포함 (문자열)
|
||||||
|
| 'in' // 배열 내 포함
|
||||||
|
| 'between'; // 범위
|
||||||
|
|
||||||
|
export type SortOrder = 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건
|
||||||
|
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
|
||||||
|
*/
|
||||||
|
export interface FilterCondition {
|
||||||
|
field: string;
|
||||||
|
operator: FilterOperator;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 옵션
|
||||||
|
*/
|
||||||
|
export interface SortOption {
|
||||||
|
field: string;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지네이션 옵션
|
||||||
|
*/
|
||||||
|
export interface PaginationOption {
|
||||||
|
page: number; // 페이지 번호 (1부터 시작)
|
||||||
|
limit: number; // 페이지당 개수
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 엔진 옵션
|
||||||
|
* - 개체 목록 필터링에 사용
|
||||||
|
*/
|
||||||
|
export interface FilterEngineOptions {
|
||||||
|
filters?: FilterCondition[];
|
||||||
|
sorts?: SortOption[];
|
||||||
|
pagination?: PaginationOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 랭킹 조건 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전체 형질 랭킹 조건
|
||||||
|
* - 35개 형질 중 사용자가 선택한 형질만 대상
|
||||||
|
* - weight: 1~10 가중치 (10이 100%)
|
||||||
|
*
|
||||||
|
* 예: { traitNm: '도체중', weight: 8 }
|
||||||
|
*/
|
||||||
|
export interface TraitRankingCondition {
|
||||||
|
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||||
|
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랭킹 옵션
|
||||||
|
*/
|
||||||
|
export interface RankingOptions {
|
||||||
|
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||||
|
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 요청 DTO
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랭킹 요청 DTO
|
||||||
|
*
|
||||||
|
* 프론트에서 POST /cow/ranking 호출 시 Body로 전송
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* {
|
||||||
|
* filterOptions: {
|
||||||
|
* filters: [{ field: 'cowSex', operator: 'eq', value: 'F' }],
|
||||||
|
* pagination: { page: 1, limit: 20 }
|
||||||
|
* },
|
||||||
|
* rankingOptions: {
|
||||||
|
* criteriaType: 'GENOME',
|
||||||
|
* traitConditions: [
|
||||||
|
* { traitNm: '도체중', weight: 8 },
|
||||||
|
* { traitNm: '근내지방도', weight: 10 }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface RankingRequestDto {
|
||||||
|
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
|
||||||
|
rankingOptions: RankingOptions; // 랭킹 조건
|
||||||
|
}
|
||||||
89
backend/src/cow/entities/cow.entity.ts
Normal file
89
backend/src/cow/entities/cow.entity.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { BaseModel } from 'src/common/entities/base.entity';
|
||||||
|
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 기본 정보 (tb_cow)
|
||||||
|
* 암소/수소, 부모 혈통 포함
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'tb_cow' })
|
||||||
|
export class CowModel extends BaseModel {
|
||||||
|
@PrimaryGeneratedColumn({
|
||||||
|
name: 'pk_cow_no',
|
||||||
|
type: 'int',
|
||||||
|
comment: '내부 PK (자동증가)',
|
||||||
|
})
|
||||||
|
pkCowNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
comment: '개체식별번호 (KOR 또는 KPN)',
|
||||||
|
})
|
||||||
|
cowId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_sex',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 1,
|
||||||
|
nullable: true,
|
||||||
|
comment: '성별 (M/F)',
|
||||||
|
})
|
||||||
|
cowSex: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_birth_dt',
|
||||||
|
type: 'date',
|
||||||
|
nullable: true,
|
||||||
|
comment: '생년월일',
|
||||||
|
})
|
||||||
|
cowBirthDt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'sire_kpn',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
comment: '부(씨수소) KPN번호',
|
||||||
|
})
|
||||||
|
sireKpn: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'dam_cow_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
comment: '모(어미소) 개체식별번호 (KOR)',
|
||||||
|
})
|
||||||
|
damCowId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'fk_farm_no',
|
||||||
|
type: 'int',
|
||||||
|
nullable: true,
|
||||||
|
comment: '농장번호 FK',
|
||||||
|
})
|
||||||
|
fkFarmNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
comment: '개체상태',
|
||||||
|
})
|
||||||
|
cowStatus: string;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'fk_farm_no' })
|
||||||
|
farm: FarmModel;
|
||||||
|
}
|
||||||
150
backend/src/dashboard/dashboard.controller.ts
Normal file
150
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/dashboard/dashboard.module.ts
Normal file
23
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
CowModel,
|
||||||
|
FarmModel,
|
||||||
|
GenomeRequestModel,
|
||||||
|
GenomeTraitDetailModel,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
exports: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
548
backend/src/dashboard/dashboard.service.ts
Normal file
548
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
81
backend/src/farm/entities/farm.entity.ts
Normal file
81
backend/src/farm/entities/farm.entity.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BaseModel } from 'src/common/entities/base.entity';
|
||||||
|
import { UserModel } from 'src/user/entities/user.entity';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장 정보 (tb_farm)
|
||||||
|
* 1사용자 N농장 관계
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'tb_farm' })
|
||||||
|
export class FarmModel extends BaseModel {
|
||||||
|
@PrimaryGeneratedColumn({
|
||||||
|
name: 'pk_farm_no',
|
||||||
|
type: 'int',
|
||||||
|
comment: '내부 PK (자동증가)',
|
||||||
|
})
|
||||||
|
pkFarmNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'trace_farm_no',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '축평원 농장번호 (나중에 입력)',
|
||||||
|
})
|
||||||
|
traceFarmNo: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'fk_user_no',
|
||||||
|
type: 'int',
|
||||||
|
nullable: true,
|
||||||
|
comment: '사용자정보 FK',
|
||||||
|
})
|
||||||
|
fkUserNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'farmer_name',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '농장주명 (농가명을 농장주로 사용)',
|
||||||
|
})
|
||||||
|
farmerName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'region_si',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '시군',
|
||||||
|
})
|
||||||
|
regionSi: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'region_gu',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '시/군/구 (지역)',
|
||||||
|
})
|
||||||
|
regionGu: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'road_address',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: '도로명 주소',
|
||||||
|
})
|
||||||
|
roadAddress: string;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => UserModel, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'fk_user_no' })
|
||||||
|
user: UserModel;
|
||||||
|
}
|
||||||
52
backend/src/farm/farm.controller.ts
Normal file
52
backend/src/farm/farm.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { FarmService } from './farm.service';
|
||||||
|
import { FarmModel } from './entities/farm.entity';
|
||||||
|
|
||||||
|
@Controller('farm')
|
||||||
|
export class FarmController {
|
||||||
|
constructor(private readonly farmService: FarmService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('userId') userId?: string) {
|
||||||
|
if (userId) {
|
||||||
|
return this.farmService.findByUserId(+userId);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/src/farm/farm.module.ts
Normal file
21
backend/src/farm/farm.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
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,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [FarmController],
|
||||||
|
providers: [FarmService],
|
||||||
|
exports: [FarmService],
|
||||||
|
})
|
||||||
|
export class FarmModule {}
|
||||||
128
backend/src/farm/farm.service.ts
Normal file
128
backend/src/farm/farm.service.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Injectable, NotFoundException } 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>,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// 전체 농장 조회
|
||||||
|
async findAll(): Promise<FarmModel[]> {
|
||||||
|
return this.farmRepository.find({
|
||||||
|
where: { delDt: IsNull() },
|
||||||
|
relations: ['user'],
|
||||||
|
order: { regDt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자별 농장 조회
|
||||||
|
async findByUserId(userNo: number): Promise<FarmModel[]> {
|
||||||
|
return this.farmRepository.find({
|
||||||
|
where: { fkUserNo: userNo, delDt: IsNull() },
|
||||||
|
relations: ['user'],
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
192
backend/src/genome/entities/genome-request.entity.ts
Normal file
192
backend/src/genome/entities/genome-request.entity.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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 {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전체 분석 의뢰 (tb_genome_request)
|
||||||
|
* 1개체 N의뢰 관계
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'tb_genome_request' })
|
||||||
|
export class GenomeRequestModel extends BaseModel {
|
||||||
|
@PrimaryGeneratedColumn({
|
||||||
|
name: 'pk_request_no',
|
||||||
|
type: 'int',
|
||||||
|
comment: 'No (PK)',
|
||||||
|
})
|
||||||
|
pkRequestNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'fk_farm_no',
|
||||||
|
type: 'int',
|
||||||
|
nullable: true,
|
||||||
|
comment: '농장번호 FK',
|
||||||
|
})
|
||||||
|
fkFarmNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'fk_cow_no',
|
||||||
|
type: 'int',
|
||||||
|
nullable: true,
|
||||||
|
comment: '개체번호 FK',
|
||||||
|
})
|
||||||
|
fkCowNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_remarks',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: '개체 비고',
|
||||||
|
})
|
||||||
|
cowRemarks: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'request_dt',
|
||||||
|
type: 'date',
|
||||||
|
nullable: true,
|
||||||
|
comment: '접수일자',
|
||||||
|
})
|
||||||
|
requestDt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'snp_test',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 10,
|
||||||
|
nullable: true,
|
||||||
|
comment: 'SNP 검사',
|
||||||
|
})
|
||||||
|
snpTest: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'ms_test',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 10,
|
||||||
|
nullable: true,
|
||||||
|
comment: 'MS 검사',
|
||||||
|
})
|
||||||
|
msTest: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'sample_amount',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '모근량',
|
||||||
|
})
|
||||||
|
sampleAmount: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'sample_remarks',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: '모근 비고',
|
||||||
|
})
|
||||||
|
sampleRemarks: string;
|
||||||
|
|
||||||
|
// 칩 분석 정보
|
||||||
|
@Column({
|
||||||
|
name: 'chip_no',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '분석 Chip 번호',
|
||||||
|
})
|
||||||
|
chipNo: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_type',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: '분석 칩 종류',
|
||||||
|
})
|
||||||
|
chipType: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_info',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 200,
|
||||||
|
nullable: true,
|
||||||
|
comment: '칩정보',
|
||||||
|
})
|
||||||
|
chipInfo: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_remarks',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: '칩 비고',
|
||||||
|
})
|
||||||
|
chipRemarks: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_sire_name',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '칩분석 아비명',
|
||||||
|
})
|
||||||
|
chipSireName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_dam_name',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '칩분석 어미명',
|
||||||
|
})
|
||||||
|
chipDamName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'chip_report_dt',
|
||||||
|
type: 'date',
|
||||||
|
nullable: true,
|
||||||
|
comment: '칩분석 보고일자',
|
||||||
|
})
|
||||||
|
chipReportDt: Date;
|
||||||
|
|
||||||
|
// MS 검사 결과
|
||||||
|
@Column({
|
||||||
|
name: 'ms_result_status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
comment: 'MS 감정결과',
|
||||||
|
})
|
||||||
|
msResultStatus: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'ms_father_estimate',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: 'MS 추정부',
|
||||||
|
})
|
||||||
|
msFatherEstimate: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'ms_report_dt',
|
||||||
|
type: 'date',
|
||||||
|
nullable: true,
|
||||||
|
comment: 'MS 보고일자',
|
||||||
|
})
|
||||||
|
msReportDt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => CowModel, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'fk_cow_no' })
|
||||||
|
cow: CowModel;
|
||||||
|
|
||||||
|
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'fk_farm_no' })
|
||||||
|
farm: FarmModel;
|
||||||
|
}
|
||||||
88
backend/src/genome/entities/genome-trait-detail.entity.ts
Normal file
88
backend/src/genome/entities/genome-trait-detail.entity.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { BaseModel } from 'src/common/entities/base.entity';
|
||||||
|
import { GenomeRequestModel } from './genome-request.entity';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전체 형질 상세 정보 (tb_genome_trait_detail)
|
||||||
|
* 1개체당 35개 형질 → 35개 행으로 저장
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'tb_genome_trait_detail' })
|
||||||
|
@Index('idx_genome_trait_cow_id', ['cowId'])
|
||||||
|
@Index('idx_genome_trait_name', ['traitName'])
|
||||||
|
@Index('idx_genome_trait_cow_trait', ['cowId', 'traitName'])
|
||||||
|
export class GenomeTraitDetailModel extends BaseModel {
|
||||||
|
@PrimaryGeneratedColumn({
|
||||||
|
name: 'pk_trait_detail_no',
|
||||||
|
type: 'int',
|
||||||
|
comment: '형질상세번호 PK',
|
||||||
|
})
|
||||||
|
pkTraitDetailNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'fk_request_no',
|
||||||
|
type: 'int',
|
||||||
|
nullable: true,
|
||||||
|
comment: '의뢰번호 FK',
|
||||||
|
})
|
||||||
|
fkRequestNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'cow_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
comment: '개체식별번호 (KOR)',
|
||||||
|
})
|
||||||
|
cowId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'trait_name',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
comment: '형질명 (예: "12개월령체중", "도체중", "등심단면적")',
|
||||||
|
})
|
||||||
|
traitName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'trait_val',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
comment: '실측값',
|
||||||
|
})
|
||||||
|
traitVal: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'trait_ebv',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
comment: '표준화육종가 (EBV: Estimated Breeding Value)',
|
||||||
|
})
|
||||||
|
traitEbv: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'trait_percentile',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 10,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true,
|
||||||
|
comment: '백분위수 (전국 대비 순위)',
|
||||||
|
})
|
||||||
|
traitPercentile: number;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => GenomeRequestModel, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'fk_request_no' })
|
||||||
|
genomeRequest: GenomeRequestModel;
|
||||||
|
}
|
||||||
185
backend/src/genome/genome.controller.ts
Normal file
185
backend/src/genome/genome.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('genome')
|
||||||
|
export class GenomeController {
|
||||||
|
constructor(private readonly genomeService: GenomeService) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/dashboard-stats/:farmNo
|
||||||
|
* 대시보드용 유전체 분석 통계 데이터
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
*/
|
||||||
|
@Get('dashboard-stats/:farmNo')
|
||||||
|
getDashboardStats(@Param('farmNo') farmNo: string) {
|
||||||
|
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
|
||||||
|
* 농가의 보은군 내 순위 조회 (대시보드용)
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
*/
|
||||||
|
@Get('farm-region-ranking/:farmNo')
|
||||||
|
getFarmRegionRanking(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.genomeService.getFarmRegionRanking(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/trait-rank/:cowId/:traitName
|
||||||
|
* 개별 형질 기준 순위 조회
|
||||||
|
* @param cowId - 개체식별번호 (KOR...)
|
||||||
|
* @param traitName - 형질명 (도체중, 근내지방도 등)
|
||||||
|
*/
|
||||||
|
@Get('trait-rank/:cowId/:traitName')
|
||||||
|
getTraitRank(
|
||||||
|
@Param('cowId') cowId: string,
|
||||||
|
@Param('traitName') traitName: string
|
||||||
|
) {
|
||||||
|
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...)로 유전체 분석 의뢰 정보 조회
|
||||||
|
* @param cowId - 개체식별번호
|
||||||
|
*/
|
||||||
|
@Get('request/:cowId')
|
||||||
|
findRequestByCowIdentifier(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.findRequestByCowIdentifier(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('request')
|
||||||
|
createRequest(@Body() data: Partial<GenomeRequestModel>) {
|
||||||
|
return this.genomeService.createRequest(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/comparison-averages/:cowId
|
||||||
|
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
||||||
|
* @param cowId - 개체식별번호 (KOR...)
|
||||||
|
*/
|
||||||
|
@Get('comparison-averages/:cowId')
|
||||||
|
getComparisonAverages(@Param('cowId') cowId: string): Promise<ComparisonAveragesDto> {
|
||||||
|
return this.genomeService.getComparisonAverages(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/trait-comparison-averages/:cowId
|
||||||
|
* 개체 기준 전국/지역/농장 형질별 평균 EBV 비교 데이터
|
||||||
|
* (폴리곤 차트용 - 형질 단위 비교)
|
||||||
|
* @param cowId - 개체식별번호 (KOR...)
|
||||||
|
*/
|
||||||
|
@Get('trait-comparison-averages/:cowId')
|
||||||
|
getTraitComparisonAverages(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.getTraitComparisonAverages(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /genome/selection-index/:cowId
|
||||||
|
* 선발지수(가중 평균) 계산 + 농가/지역 순위
|
||||||
|
* @param cowId - 개체식별번호 (KOR...)
|
||||||
|
* @param body.traitConditions - 형질별 가중치 조건
|
||||||
|
*/
|
||||||
|
@Post('selection-index/:cowId')
|
||||||
|
getSelectionIndex(
|
||||||
|
@Param('cowId') cowId: string,
|
||||||
|
@Body() body: { traitConditions: { traitNm: string; weight?: number }[] }
|
||||||
|
) {
|
||||||
|
return this.genomeService.getSelectionIndex(cowId, body.traitConditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Genome Trait Detail endpoints
|
||||||
|
@Get('trait-detail/:requestId')
|
||||||
|
findTraitDetailsByRequestId(@Param('requestId') requestId: string) {
|
||||||
|
return this.genomeService.findTraitDetailsByRequestId(+requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('trait-detail/cow/:cowId')
|
||||||
|
findTraitDetailsByCowId(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.findTraitDetailsByCowId(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('trait-detail')
|
||||||
|
createTraitDetail(@Body() data: Partial<GenomeTraitDetailModel>) {
|
||||||
|
return this.genomeService.createTraitDetail(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/check-cow/:cowId
|
||||||
|
* 특정 개체 상세 정보 조회 (디버깅용)
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Get('check-cow/:cowId')
|
||||||
|
checkSpecificCow(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.checkSpecificCows([cowId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/yearly-trait-trend/:farmNo
|
||||||
|
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
* @param category - 카테고리명 (성장/생산/체형/무게/비율)
|
||||||
|
* @param traitName - 형질명 (선택, 없으면 카테고리 전체)
|
||||||
|
*/
|
||||||
|
@Get('yearly-trait-trend/:farmNo')
|
||||||
|
getYearlyTraitTrend(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query('category') category: string,
|
||||||
|
@Query('traitName') traitName?: string,
|
||||||
|
) {
|
||||||
|
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/:cowId
|
||||||
|
* cowId(개체식별번호)로 유전체 데이터 조회
|
||||||
|
* @Get(':cowId')가 /genome/request 요청을 가로챔
|
||||||
|
* 구체적인 경로들(request)이 위에, 와일드카드 경로(@Get(':cowId'))가 맨 아래
|
||||||
|
*/
|
||||||
|
@Get(':cowId')
|
||||||
|
findByCowId(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.findByCowId(cowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/genome/genome.module.ts
Normal file
23
backend/src/genome/genome.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { GenomeController } from './genome.controller';
|
||||||
|
import { GenomeService } from './genome.service';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
GenomeRequestModel,
|
||||||
|
GenomeTraitDetailModel,
|
||||||
|
CowModel,
|
||||||
|
FarmModel,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [GenomeController],
|
||||||
|
providers: [GenomeService],
|
||||||
|
exports: [GenomeService],
|
||||||
|
})
|
||||||
|
export class GenomeModule {}
|
||||||
2043
backend/src/genome/genome.service.ts
Normal file
2043
backend/src/genome/genome.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
58
backend/src/help/dto/create-help.dto.ts
Normal file
58
backend/src/help/dto/create-help.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
26
backend/src/help/dto/filter-help.dto.ts
Normal file
26
backend/src/help/dto/filter-help.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
11
backend/src/help/dto/update-help.dto.ts
Normal file
11
backend/src/help/dto/update-help.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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) {}
|
||||||
108
backend/src/help/entities/help.entity.ts
Normal file
108
backend/src/help/entities/help.entity.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
185
backend/src/help/help.controller.ts
Normal file
185
backend/src/help/help.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/help/help.module.ts
Normal file
28
backend/src/help/help.module.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { HelpController } from './help.controller';
|
||||||
|
import { HelpService } from './help.service';
|
||||||
|
import { HelpModel } from './entities/help.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help Module
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말/툴팁 시스템 모듈입니다.
|
||||||
|
* SNP, GENOME, MPT 등의 용어에 대한 설명을 제공합니다.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - 도움말 CRUD
|
||||||
|
* - 카테고리별 조회
|
||||||
|
* - 툴팁/사이드패널 데이터 제공
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class HelpModule
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([HelpModel])],
|
||||||
|
controllers: [HelpController],
|
||||||
|
providers: [HelpService],
|
||||||
|
exports: [HelpService],
|
||||||
|
})
|
||||||
|
export class HelpModule {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user