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