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

102
.env Normal file
View 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
View 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
View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 */;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 데이터 생성 완료
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정

File diff suppressed because it is too large Load Diff

1044
backend/doc/ux-detail.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

104
backend/package.json Normal file
View 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"
}
}
}

View 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!');
});
});
});

View 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
View 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 {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

View 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 {}

View 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: '비밀번호가 변경되었습니다',
};
}
}

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

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

View 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; // 비밀번호
}

View File

@@ -0,0 +1,21 @@
/**
* 비밀번호 재설정 응답 DTO
* 표준화된 응답 구조 정의
*
* @export
* @class ResetPasswordResponseDto
* @typedef {ResetPasswordResponseDto}
*/
export class ResetPasswordResponseDto {
/**
* 응답 메시지
* @type {string}
*/
message: string;
/**
* 임시 비밀번호 (개발 환경에서만 반환, 실무에서는 SMS 발송)
* @type {string}
*/
tempPassword?: string;
}

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

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

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

View File

@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
/**
* 회원가입 인증번호 발송 DTO
*
* @export
* @class SendSignupCodeDto
*/
export class SendSignupCodeDto {
@IsEmail()
@IsNotEmpty()
userEmail: string;
}

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

View 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; // 사업자등록번호
}

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

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

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

View 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();
});
});

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

View 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 {}

View 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();
});
});

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CommonService {}

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

View 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}))`;
}

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,11 @@
/**
* 사용자 타입 구분 Enum
*
* @export
* @enum {number}
*/
export enum UserSeType {
FARM = 'FARM', // 농가
CNSLT = 'CNSLT', // 컨설턴트
ORGAN = 'ORGAN', // 기관담당자
}

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

View 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);

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

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

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

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

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

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

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

View 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 {}

View 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',
};
}
}

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

View File

@@ -0,0 +1,90 @@
/**
* ============================================================
* 개체(Cow) 컨트롤러
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 엔드포인트:
* - GET /cow - 기본 개체 목록 조회
* - GET /cow/:id - 개체 상세 조회
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
* ============================================================
*/
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { CowService } from './cow.service';
import { CowModel } from './entities/cow.entity';
import { RankingRequestDto } from './dto/ranking-request.dto';
@Controller('cow')
export class CowController {
constructor(private readonly cowService: CowService) {}
/**
* GET /cow
* 기본 개체 목록 조회
*/
@Get()
findAll(@Query('farmId') farmId?: string) {
if (farmId) {
return this.cowService.findByFarmId(+farmId);
}
return this.cowService.findAll();
}
/**
* POST /cow/ranking
* 랭킹이 적용된 개체 목록 조회
*
* 사용 페이지: 개체 목록 페이지
*/
@Post('ranking')
findAllWithRanking(@Body() rankingRequest: RankingRequestDto) {
return this.cowService.findAllWithRanking(rankingRequest);
}
/**
* POST /cow/ranking/global
* 전체 개체 랭킹 조회 (모든 농장 포함)
*
* 사용 페이지: 대시보드 (농장 순위 비교)
*/
@Post('ranking/global')
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
// farmNo 필터 없이 전체 개체 랭킹 조회
const globalRequest = {
...rankingRequest,
filterOptions: {
...rankingRequest.filterOptions,
farmNo: undefined,
},
};
return this.cowService.findAllWithRanking(globalRequest);
}
/**
* GET /cow/:cowId
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
*/
@Get(':cowId')
findOne(@Param('cowId') cowId: string) {
return this.cowService.findByCowId(cowId);
}
@Post()
create(@Body() data: Partial<CowModel>) {
return this.cowService.create(data);
}
@Put(':id')
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
return this.cowService.update(+id, data);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.cowService.remove(+id);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
/**
* ============================================================
* 랭킹 요청 DTO
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
*
* 프론트에서 POST /cow/ranking 호출 시 사용
*
* 지원하는 랭킹 기준:
* 1. GENOME - 35개 유전체 형질 EBV 가중치 기반
* ============================================================
*/
/**
* 랭킹 기준 타입
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
*/
export enum RankingCriteriaType {
GENOME = 'GENOME',
}
// ============================================================
// 필터 관련 타입 (FilterEngine에서 사용)
// ============================================================
export type FilterOperator =
| 'eq' // 같음
| 'ne' // 같지 않음
| 'gt' // 초과
| 'gte' // 이상
| 'lt' // 미만
| 'lte' // 이하
| 'like' // 포함 (문자열)
| 'in' // 배열 내 포함
| 'between'; // 범위
export type SortOrder = 'ASC' | 'DESC';
/**
* 필터 조건
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
*/
export interface FilterCondition {
field: string;
operator: FilterOperator;
value: any;
}
/**
* 정렬 옵션
*/
export interface SortOption {
field: string;
order: SortOrder;
}
/**
* 페이지네이션 옵션
*/
export interface PaginationOption {
page: number; // 페이지 번호 (1부터 시작)
limit: number; // 페이지당 개수
}
/**
* 필터 엔진 옵션
* - 개체 목록 필터링에 사용
*/
export interface FilterEngineOptions {
filters?: FilterCondition[];
sorts?: SortOption[];
pagination?: PaginationOption;
}
// ============================================================
// 랭킹 조건 타입
// ============================================================
/**
* 유전체 형질 랭킹 조건
* - 35개 형질 중 사용자가 선택한 형질만 대상
* - weight: 1~10 가중치 (10이 100%)
*
* 예: { traitNm: '도체중', weight: 8 }
*/
export interface TraitRankingCondition {
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
weight?: number; // 가중치 1~10 (기본값: 1)
}
/**
* 랭킹 옵션
*/
export interface RankingOptions {
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
limit?: number;
offset?: number;
}
// ============================================================
// 메인 요청 DTO
// ============================================================
/**
* 랭킹 요청 DTO
*
* 프론트에서 POST /cow/ranking 호출 시 Body로 전송
*
* @example
* {
* filterOptions: {
* filters: [{ field: 'cowSex', operator: 'eq', value: 'F' }],
* pagination: { page: 1, limit: 20 }
* },
* rankingOptions: {
* criteriaType: 'GENOME',
* traitConditions: [
* { traitNm: '도체중', weight: 8 },
* { traitNm: '근내지방도', weight: 10 }
* ]
* }
* }
*/
export interface RankingRequestDto {
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
rankingOptions: RankingOptions; // 랭킹 조건
}

View File

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

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

View 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 {}

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

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

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

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

View 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 {}

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

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

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

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

View 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 {}

File diff suppressed because it is too large Load Diff

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

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

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

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

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

View 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