Compare commits
97 Commits
790d12fe13
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1c7bfb88 | ||
|
|
1548253f56 | ||
|
|
0ea253befd | ||
|
|
bea0fec4f1 | ||
|
|
665a5b1b7d | ||
|
|
170adcc149 | ||
|
|
36f110c90a | ||
|
|
7c38c555f5 | ||
|
|
b8ccde7f17 | ||
|
|
d1aec517a6 | ||
|
|
4d83f10b07 | ||
|
|
90d3e32992 | ||
|
|
2e9d24faf2 | ||
|
|
359d5dd7dd | ||
|
|
c42c9f4fa3 | ||
|
|
4b158de1eb | ||
|
|
09b3cf8e65 | ||
|
|
5f1791443a | ||
|
|
9998d9df96 | ||
|
|
74d3a78aa3 | ||
|
|
b6485e3140 | ||
|
|
e1227a69fe | ||
|
|
9c98c44d8a | ||
|
|
397485e150 | ||
|
|
ad7088d840 | ||
|
|
45f80e206b | ||
|
|
1fc9dff69f | ||
|
|
6ff5c443be | ||
|
|
566a38562c | ||
|
|
7acb835c39 | ||
|
|
9c636bf6ad | ||
|
|
f27728b168 | ||
|
|
ffabcaf579 | ||
|
|
485ce7d276 | ||
|
|
f3386a54c7 | ||
|
|
cbf1ad9dad | ||
|
|
ca33736ed4 | ||
|
|
35aa4a840e | ||
|
|
4ee07bc95c | ||
|
|
94bccf3b67 | ||
|
|
67e9c0886d | ||
|
|
480206912b | ||
|
|
f8138685a1 | ||
|
|
4d0c4c0801 | ||
|
|
0ec099b493 | ||
|
|
6b7f9d4627 | ||
|
|
73e5eff7bd | ||
|
|
a6ab9e395d | ||
|
|
33e9e2afec | ||
|
|
05a9de8d2f | ||
|
|
e8829a0bc7 | ||
|
|
4f0af62d8c | ||
|
|
8a5480177b | ||
|
|
4ac0605887 | ||
|
|
6933f67a2e | ||
|
|
1e4dbf10db | ||
|
|
bea62dfdee | ||
|
|
25cca1482e | ||
|
|
70630b380a | ||
|
|
337cd14a15 | ||
|
|
b0d17cd53b | ||
|
|
d810a8b339 | ||
|
|
344ad35651 | ||
|
|
79bd9324ca | ||
|
|
7d89ec448c | ||
|
|
b4037c9395 | ||
|
|
48fff7df64 | ||
|
|
3549710325 | ||
|
|
770fa91366 | ||
|
|
05843da1c4 | ||
|
|
8a8307edfc | ||
|
|
bc5df77595 | ||
|
|
b67362a733 | ||
|
|
9206672b63 | ||
|
|
146854e8fe | ||
|
|
b6f79d7ca7 | ||
|
|
ffe9619abd | ||
|
|
19e8fd9a35 | ||
|
|
349ab60561 | ||
|
|
405bf0dc65 | ||
|
|
09f6756da7 | ||
|
|
4264e5f8b3 | ||
|
|
16f1d7fae5 | ||
|
|
b2461502e7 | ||
|
|
4569791f9d | ||
|
|
1e7155b864 | ||
|
|
5c8f553f87 | ||
|
|
a2669e08c4 | ||
|
|
a9bce9d20b | ||
|
|
beaffcad49 | ||
|
|
ed40eec261 | ||
|
|
052e868599 | ||
|
|
26f9a4dea2 | ||
|
|
de427c457b | ||
|
|
746e09420b | ||
|
|
33307bb243 | ||
|
|
bad5584988 |
115
.env.example
Normal file
115
.env.example
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# TK-FB-Project 환경 변수 설정 예시
|
||||||
|
# 이 파일을 복사하여 .env 파일을 생성하고 실제 값으로 변경하세요
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# 1. cp .env.example .env
|
||||||
|
# 2. .env 파일을 편집하여 실제 비밀번호로 변경
|
||||||
|
# 3. .env 파일은 절대 Git에 커밋하지 마세요!
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 데이터베이스 설정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# MariaDB/MySQL Root 비밀번호 (최소 12자 이상, 영문/숫자/특수문자 조합)
|
||||||
|
MYSQL_ROOT_PASSWORD=change_this_root_password_min_12_chars
|
||||||
|
|
||||||
|
# 데이터베이스 이름
|
||||||
|
MYSQL_DATABASE=hyungi
|
||||||
|
|
||||||
|
# 데이터베이스 사용자명
|
||||||
|
MYSQL_USER=hyungi_user
|
||||||
|
|
||||||
|
# 데이터베이스 사용자 비밀번호 (최소 12자 이상, 영문/숫자/특수문자 조합)
|
||||||
|
MYSQL_PASSWORD=change_this_user_password_min_12_chars
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API 서버 설정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Node.js 환경 (development | production)
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# API 서버 포트 (컨테이너 내부)
|
||||||
|
PORT=3005
|
||||||
|
|
||||||
|
# 데이터베이스 연결 정보
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=hyungi_user
|
||||||
|
DB_PASSWORD=change_this_user_password_min_12_chars
|
||||||
|
DB_NAME=hyungi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JWT 인증 설정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# JWT Secret Key (최소 32자 이상의 랜덤 문자열)
|
||||||
|
# 생성 방법: openssl rand -base64 32
|
||||||
|
# 또는: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
JWT_SECRET=change_this_to_random_string_min_32_chars
|
||||||
|
|
||||||
|
# JWT 액세스 토큰 만료 시간
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# JWT 리프레시 토큰 만료 시간
|
||||||
|
JWT_REFRESH_SECRET=change_this_to_another_random_string_min_32_chars
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FastAPI 설정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# API 서버 URL (컨테이너 간 통신)
|
||||||
|
API_BASE_URL=http://api:3005
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# phpMyAdmin 설정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# phpMyAdmin에서 사용할 데이터베이스 호스트
|
||||||
|
PMA_HOST=db
|
||||||
|
|
||||||
|
# phpMyAdmin 사용자 (일반적으로 root)
|
||||||
|
PMA_USER=root
|
||||||
|
|
||||||
|
# phpMyAdmin 비밀번호 (MYSQL_ROOT_PASSWORD와 동일하게)
|
||||||
|
PMA_PASSWORD=change_this_root_password_min_12_chars
|
||||||
|
|
||||||
|
# 파일 업로드 제한
|
||||||
|
UPLOAD_LIMIT=50M
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 외부 서비스 (선택사항)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# OpenAI API (AI 기능 사용 시)
|
||||||
|
# OPENAI_API_KEY=sk-your-openai-api-key-here
|
||||||
|
|
||||||
|
# 이메일 발송 (SMTP 설정)
|
||||||
|
# EMAIL_HOST=smtp.gmail.com
|
||||||
|
# EMAIL_PORT=587
|
||||||
|
# EMAIL_USER=your-email@gmail.com
|
||||||
|
# EMAIL_PASSWORD=your-app-password
|
||||||
|
# EMAIL_FROM=noreply@tkfb.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 보안 참고사항
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# 강력한 비밀번호 생성 방법:
|
||||||
|
#
|
||||||
|
# 1. 터미널에서 랜덤 비밀번호 생성:
|
||||||
|
# openssl rand -base64 24
|
||||||
|
#
|
||||||
|
# 2. Node.js로 랜덤 문자열 생성:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
#
|
||||||
|
# 3. 온라인 도구 사용:
|
||||||
|
# https://www.lastpass.com/features/password-generator
|
||||||
|
#
|
||||||
|
# 주의사항:
|
||||||
|
# - .env 파일은 절대 Git에 커밋하지 마세요
|
||||||
|
# - 프로덕션 환경에서는 더 강력한 비밀번호를 사용하세요
|
||||||
|
# - 정기적으로 비밀번호를 변경하세요
|
||||||
|
# - JWT_SECRET은 유출되면 모든 토큰이 무효화됩니다
|
||||||
|
#
|
||||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
**/venv/
|
||||||
|
**/__pycache__/
|
||||||
|
|
||||||
# Databases
|
# Databases
|
||||||
*.db
|
*.db
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -20,4 +23,44 @@ Thumbs.db
|
|||||||
|
|
||||||
# IDEs
|
# IDEs
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.backup
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
*복사본*
|
||||||
|
*이전*
|
||||||
|
*백업*
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Deployment files
|
||||||
|
synology_deployment/
|
||||||
|
deployment_*.tar.gz
|
||||||
|
TK-FB-Project_*.tar.gz
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.new
|
||||||
|
|
||||||
|
# Security files
|
||||||
|
secrets/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
|
||||||
|
# Coverage & Test
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
43
CHECKLIST.md
Normal file
43
CHECKLIST.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 프로젝트 체크리스트 및 컨텍스트
|
||||||
|
|
||||||
|
이 문서는 개발 시 확인해야 할 스키마 정보, 남은 작업, 삭제된 리소스 목록을 포함합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 현재 작업 (To-Do)
|
||||||
|
- [x] **WorkAnalysis 리팩토링** (Controller -> Service 분리)
|
||||||
|
- [ ] **테스트 코드 작성** (`tests/unit/services/workAnalysis.test.js` 추가 필요)
|
||||||
|
- [ ] **기타 컨트롤러 리팩토링** (Phase 3 진행 중)
|
||||||
|
- `monthlyStatusController.js` 확인 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄 데이터베이스 스키마 요약
|
||||||
|
*(상세 내용은 원본 스키마 파일 참조, 여기는 자주 찾는 정보 요약)*
|
||||||
|
|
||||||
|
### 주요 테이블
|
||||||
|
- `daily_work_reports`: 작업 보고서 메인 (Date, Worker, Project, WorkType, Status, Hours)
|
||||||
|
- `workers`: 작업자 마스터
|
||||||
|
- `projects`: 프로젝트 마스터
|
||||||
|
- `work_types`: 작업 유형 (1:Base, 2:Vessel, 3:Piping, 4:Wait)
|
||||||
|
- `work_status_types`: 상태 (1:정규, 2:에러)
|
||||||
|
- `error_types`: 에러 유형 (WorkStatus=2일 때 필수)
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
- **스키마 버전**: v1 (JSON 호환)
|
||||||
|
- **Timezone**: 모든 시간은 KST 기준 처리 확인 필요
|
||||||
|
- **Soft Delete**: `deleted_at` 컬럼 사용 여부 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗑 삭제/정리된 리소스 (참조용)
|
||||||
|
|
||||||
|
### 삭제된 페이지 (2025-11-03)
|
||||||
|
- **대시보드 통일**: `group-leader.html` 하나로 모든 권한 통합됨.
|
||||||
|
- **삭제 대상**:
|
||||||
|
- `dashboard/admin.html`, `dashboard/system.html` (미사용)
|
||||||
|
- `admin/factory-upload.html` 등 미사용 관리페이지
|
||||||
|
- `issue-reports/` 폴더 전체
|
||||||
|
|
||||||
|
### 삭제된 테이블 (히스토리)
|
||||||
|
*(필요 시 `DELETED_TABLES.md` 참고)*
|
||||||
439
CODING_GUIDE.md
Normal file
439
CODING_GUIDE.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# TK-FB-Project 통합 개발 가이드
|
||||||
|
|
||||||
|
이 문서는 프로젝트의 실행, 규칙, 테스트, 호환성 등 모든 개발 관련 사항을 통합한 가이드입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 프로젝트 개요 및 아키텍처
|
||||||
|
**생산팀 내부 포털 개발 및 유지보수**
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
- **Backend**: Node.js, Express.js (Port: 20005)
|
||||||
|
- **Frontend**: Vanilla HTML/CSS/JS (Port: 20000)
|
||||||
|
- **Database**: MariaDB (Port: 20306), phpMyAdmin (Port: 20080)
|
||||||
|
- **Infra**: Docker Compose (Synology NAS, Mac Mini)
|
||||||
|
- **Bridge**: FastAPI (Port: 20010, Python 3.11+) - *2025.07 도입*
|
||||||
|
|
||||||
|
### 아키텍처 모식도
|
||||||
|
```
|
||||||
|
브라우저 → FastAPI (8000) → Express (3005) → MariaDB
|
||||||
|
↓
|
||||||
|
정적 파일 서빙
|
||||||
|
```
|
||||||
|
*Note: 현재 개발 환경 포트는 위 기술 스택 섹션 참조*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 실행 가이드
|
||||||
|
|
||||||
|
### 필수: 환경 변수 설정
|
||||||
|
`.env.example`을 `.env`로 복사하고 설정하세요. **절대 커밋 금지!**
|
||||||
|
|
||||||
|
### Docker 실행
|
||||||
|
```bash
|
||||||
|
./start.sh # 간편 실행 (권장)
|
||||||
|
./stop.sh # 중지
|
||||||
|
docker-compose up -d # 수동 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📏 코딩 컨벤션
|
||||||
|
|
||||||
|
### 네이밍
|
||||||
|
| 대상 | 스타일 | 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| JS 변수/함수 | camelCase | `calculateTotal` |
|
||||||
|
| JS 클래스 | PascalCase | `UserReport` |
|
||||||
|
| 파일명 | kebab-case | `work-report.js` |
|
||||||
|
| DB 테이블/컬럼 | snake_case | `user_accounts` |
|
||||||
|
| API URL | plural, kebab | `/api/work-reports` |
|
||||||
|
|
||||||
|
### 코드 품질
|
||||||
|
- **파일 분리**: 750줄 초과 시 리팩토링 고려 (Controller/Service/Model 분리 필수).
|
||||||
|
- **Early Return**: 중첩 조건문 지양.
|
||||||
|
- **주석**: JSDoc 활용 권장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI/UX 디자인 가이드
|
||||||
|
|
||||||
|
### 디자인 원칙
|
||||||
|
- **모던하고 깔끔한 디자인**: 이모지 사용 지양, 아이콘 또는 심플한 텍스트 사용
|
||||||
|
- **일관성**: 모든 페이지에서 동일한 디자인 시스템 적용
|
||||||
|
- **컴포넌트 재사용**: navbar, footer 등은 컴포넌트로 관리
|
||||||
|
|
||||||
|
### 이모지 사용 금지
|
||||||
|
❌ **금지**:
|
||||||
|
```html
|
||||||
|
<!-- 나쁜 예 -->
|
||||||
|
<button>📊 대시보드</button>
|
||||||
|
<h1>🔧 작업 관리</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **권장**:
|
||||||
|
```html
|
||||||
|
<!-- 좋은 예 - 아이콘 라이브러리 사용 또는 심플한 텍스트 -->
|
||||||
|
<button class="btn-primary">
|
||||||
|
<i class="icon-dashboard"></i>
|
||||||
|
대시보드
|
||||||
|
</button>
|
||||||
|
<h1>작업 관리</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 색상 가이드
|
||||||
|
- **Primary**: 하늘색 계열 (`#0ea5e9`, `#38bdf8`, `#7dd3fc`)
|
||||||
|
- **기본 배경**: 흰색/밝은 회색 계열
|
||||||
|
- **텍스트**: `#1f2937` (dark gray)
|
||||||
|
- **보조 텍스트**: `#6b7280` (medium gray)
|
||||||
|
|
||||||
|
### 컴포넌트 구조
|
||||||
|
- **네비게이션**: `web-ui/components/navbar.html` 참조
|
||||||
|
- **일관된 헤더**: 모든 페이지에서 `<div id="navbar-container"></div>` 사용
|
||||||
|
- **CSS 로딩 순서**: `design-system.css` → 페이지별 CSS
|
||||||
|
|
||||||
|
### 페이지 구조 (2026-02-03 현행)
|
||||||
|
```
|
||||||
|
web-ui/pages/
|
||||||
|
├── dashboard.html # 메인 대시보드 (작업장 현황 지도 포함)
|
||||||
|
├── work/ # 작업 관리 (현장 입력/생산)
|
||||||
|
│ ├── tbm.html # TBM(Tool Box Meeting) 관리
|
||||||
|
│ ├── report-create.html # 작업보고서 작성
|
||||||
|
│ ├── report-view.html # 작업보고서 조회
|
||||||
|
│ ├── nonconformity.html # 부적합 현황
|
||||||
|
│ └── analysis.html # 작업 분석
|
||||||
|
├── [일간작업장 점검] # 일간작업장 점검 (사이드바 카테고리)
|
||||||
|
│ ├── attendance/checkin.html # 출근 체크
|
||||||
|
│ └── attendance/work-status.html # 근무 현황 (휴가/연장근무)
|
||||||
|
├── safety/ # 안전 관리 페이지
|
||||||
|
│ ├── report.html # 신고 (공통)
|
||||||
|
│ ├── report-status.html # 안전신고 현황
|
||||||
|
│ ├── issue-detail.html # 이슈 상세
|
||||||
|
│ ├── visit-request.html # 출입 신청
|
||||||
|
│ ├── management.html # 안전 관리 (출입 승인)
|
||||||
|
│ └── checklist-manage.html # 안전 체크리스트 관리
|
||||||
|
├── attendance/ # 근태 관리 페이지
|
||||||
|
│ ├── monthly.html # 월별 출퇴근 현황
|
||||||
|
│ ├── vacation-request.html # 휴가 신청
|
||||||
|
│ ├── vacation-management.html # 휴가 관리 (통합)
|
||||||
|
│ ├── vacation-approval.html # 휴가 승인 관리
|
||||||
|
│ ├── vacation-input.html # 휴가 직접 입력
|
||||||
|
│ ├── vacation-allocation.html # 휴가 발생 입력
|
||||||
|
│ └── annual-overview.html # 연간 연차 현황
|
||||||
|
├── admin/ # 시스템 관리 페이지
|
||||||
|
│ ├── accounts.html # 계정 관리
|
||||||
|
│ ├── page-access.html # 페이지 접근 권한 관리
|
||||||
|
│ ├── workers.html # 작업자 관리
|
||||||
|
│ ├── projects.html # 프로젝트 관리
|
||||||
|
│ ├── tasks.html # 작업 관리
|
||||||
|
│ ├── workplaces.html # 작업장 관리 (지도 구역 설정)
|
||||||
|
│ ├── equipments.html # 설비 관리
|
||||||
|
│ ├── codes.html # 코드 관리
|
||||||
|
│ ├── issue-categories.html # 신고 카테고리 관리
|
||||||
|
│ └── attendance-report.html # 출퇴근-작업보고서 대조
|
||||||
|
├── profile/ # 사용자 프로필
|
||||||
|
│ ├── info.html # 내 정보
|
||||||
|
│ └── password.html # 비밀번호 변경
|
||||||
|
└── .archived-*/ # 미사용 페이지 보관
|
||||||
|
```
|
||||||
|
|
||||||
|
**폴더 분류 기준** (2026-02-03 변경):
|
||||||
|
- `work/`: 현장 입력/생산 활동 (TBM, 작업보고서, 부적합)
|
||||||
|
- `[일간작업장 점검]`: 일일 작업장 점검 관련 (사이드바 카테고리)
|
||||||
|
- `safety/`: 안전 관리/분석 (신고, 출입)
|
||||||
|
- `attendance/`: 근태/휴가 관리
|
||||||
|
- `admin/`: 시스템 관리 (관리자 전용)
|
||||||
|
- `profile/`: 개인 설정 페이지
|
||||||
|
|
||||||
|
**네이밍 규칙**:
|
||||||
|
- 메인 페이지: 단일 명사 (`dashboard.html`)
|
||||||
|
- 관리 페이지: 복수형 명사 (`projects.html`, `workers.html`)
|
||||||
|
- 기능 페이지: 동사-명사 또는 명사 (`report-create.html`, `daily.html`)
|
||||||
|
- 폴더명: 단수형, 소문자 (`work/`, `safety/`, `attendance/`, `admin/`, `profile/`)
|
||||||
|
|
||||||
|
**네비게이션 구조**:
|
||||||
|
- 1차: `dashboard.html` (메인 진입점, 작업장 현황 지도)
|
||||||
|
- 2차: 사이드 메뉴 또는 빠른 작업 카드를 통한 각 기능 페이지 이동
|
||||||
|
- 모든 페이지: navbar를 통해 profile, 로그아웃 가능
|
||||||
|
|
||||||
|
### 대기 중인 DB 마이그레이션
|
||||||
|
페이지 구조 변경에 따른 DB 마이그레이션이 필요합니다:
|
||||||
|
```bash
|
||||||
|
cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net
|
||||||
|
npx knex migrate:latest
|
||||||
|
```
|
||||||
|
- 마이그레이션 파일: `db/migrations/20260202200000_reorganize_pages.js`
|
||||||
|
- 내용: pages 테이블 경로 업데이트, role_default_pages 테이블 생성
|
||||||
|
|
||||||
|
### 표준 컴포넌트 (2026-01-20 업데이트)
|
||||||
|
|
||||||
|
#### 네비게이션 헤더
|
||||||
|
모든 페이지는 표준 navbar 컴포넌트를 사용합니다:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- HTML에 컨테이너 추가 -->
|
||||||
|
<div id="navbar-container"></div>
|
||||||
|
|
||||||
|
<!-- 스크립트로 로드 -->
|
||||||
|
<script src="/js/load-navbar.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징**:
|
||||||
|
- 자동으로 사용자 정보 표시 (이름, 역할)
|
||||||
|
- 프로필 메뉴 (내 프로필, 비밀번호 변경, 로그아웃)
|
||||||
|
- 관리자 전용 메뉴 자동 표시/숨김
|
||||||
|
- 현재 시각 실시간 표시
|
||||||
|
- 대시보드 버튼
|
||||||
|
|
||||||
|
#### CSS 변수 시스템
|
||||||
|
모든 스타일은 `design-system.css`의 CSS 변수를 사용합니다:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 색상 - 하늘색 계열 primary */
|
||||||
|
var(--primary-500) /* 기본 하늘색: #0ea5e9 */
|
||||||
|
var(--primary-400) /* 밝은 하늘색: #38bdf8 */
|
||||||
|
var(--header-gradient) /* 헤더 그라디언트 */
|
||||||
|
|
||||||
|
/* 간격 */
|
||||||
|
var(--space-2) /* 8px */
|
||||||
|
var(--space-4) /* 16px */
|
||||||
|
var(--space-6) /* 24px */
|
||||||
|
|
||||||
|
/* 타이포그래피 */
|
||||||
|
var(--text-sm) /* 14px */
|
||||||
|
var(--text-base) /* 16px */
|
||||||
|
var(--font-medium) /* 500 */
|
||||||
|
|
||||||
|
/* 기타 */
|
||||||
|
var(--radius-md) /* 8px 둥근 모서리 */
|
||||||
|
var(--shadow-md) /* 그림자 */
|
||||||
|
var(--transition-fast) /* 150ms */
|
||||||
|
```
|
||||||
|
|
||||||
|
**금지**: 하드코딩된 색상 값 사용 (`#0ea5e9` 대신 `var(--primary-500)` 사용)
|
||||||
|
|
||||||
|
#### 페이지 레이아웃 템플릿
|
||||||
|
`web-ui/templates/` 디렉토리에 4가지 표준 템플릿 제공:
|
||||||
|
|
||||||
|
1. **dashboard-layout.html**: 메인 대시보드, 통계 페이지
|
||||||
|
2. **work-layout.html**: 작업 관련 페이지 (보고서, 분석)
|
||||||
|
3. **admin-layout.html**: 관리자 페이지 (테이블, CRUD)
|
||||||
|
4. **simple-layout.html**: 프로필, 설정 등 단순 페이지
|
||||||
|
|
||||||
|
새 페이지 생성 시:
|
||||||
|
```bash
|
||||||
|
# 템플릿 복사
|
||||||
|
cp web-ui/templates/work-layout.html web-ui/pages/work/new-page.html
|
||||||
|
|
||||||
|
# 내용 수정
|
||||||
|
# - <title> 변경
|
||||||
|
# - 페이지별 CSS/JS 추가
|
||||||
|
# - 콘텐츠 작성
|
||||||
|
```
|
||||||
|
|
||||||
|
상세한 사용법은 `web-ui/templates/README.md` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 페이지 접근 권한 관리
|
||||||
|
|
||||||
|
### 권한 체크 방식
|
||||||
|
1. **관리자 전용 페이지**: `admin-only` 클래스 사용
|
||||||
|
2. **페이지별 권한 체크**: `pages` 테이블 기반 권한 확인
|
||||||
|
3. **클라이언트 측**: `auth-check.js`에서 자동 권한 검증
|
||||||
|
|
||||||
|
### 페이지 등록 (pages 테이블)
|
||||||
|
새 페이지 생성 시 반드시 `pages` 테이블에 등록:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 마이그레이션 예시
|
||||||
|
INSERT INTO pages (page_name, page_url, page_category, description, display_order, is_active)
|
||||||
|
VALUES
|
||||||
|
('출입 신청', '/pages/work/visit-request.html', 'work', '작업장 출입 및 안전교육 신청', 150, 1),
|
||||||
|
('안전관리', '/pages/admin/safety-management.html', 'admin', '출입 신청 승인 및 안전교육 관리', 210, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 페이지 권한 할당
|
||||||
|
- **Admin**: 모든 페이지 자동 접근 가능
|
||||||
|
- **일반 사용자**: `page_access` 테이블에 명시적 권한 부여 필요
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 특정 사용자에게 페이지 권한 부여
|
||||||
|
INSERT INTO page_access (user_id, page_id, granted_by, granted_at)
|
||||||
|
VALUES (123, 45, 1, NOW());
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML 페이지 설정
|
||||||
|
```html
|
||||||
|
<!-- 관리자 전용 페이지 -->
|
||||||
|
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only">
|
||||||
|
<div class="action-content">
|
||||||
|
<h3>안전관리</h3>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 모든 사용자 접근 가능 -->
|
||||||
|
<a href="/pages/work/visit-request.html" class="quick-action-card">
|
||||||
|
<div class="action-content">
|
||||||
|
<h3>출입 신청</h3>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 라우트 보호
|
||||||
|
```javascript
|
||||||
|
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 필요
|
||||||
|
router.use(verifyToken);
|
||||||
|
|
||||||
|
// 관리자 전용 라우트
|
||||||
|
router.put('/requests/:id/approve', (req, res) => {
|
||||||
|
// verifyToken에서 req.user 제공
|
||||||
|
// 필요시 추가 권한 체크
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API 개발 가이드
|
||||||
|
- **RESTful**: 명사형 리소스 사용 (`POST /users` O, `/createUser` X).
|
||||||
|
- **응답 포맷**:
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": {...}, "message": "..." }
|
||||||
|
```
|
||||||
|
- **계층 구조**:
|
||||||
|
- `Controller`: 요청/응답 처리, 유효성 검사.
|
||||||
|
- `Service`: 비즈니스 로직, 트랜잭션 관리.
|
||||||
|
- `Model`: DB 쿼리 실행.
|
||||||
|
|
||||||
|
### MySQL 8.0 호환성 주의사항 (중요)
|
||||||
|
Synology NAS(MySQL 8.0)의 `Strict Mode`로 인해 `db.execute()` 사용 시 `Incorrect arguments` 에러가 발생할 수 있습니다.
|
||||||
|
- **해결책**: `db.query()` 사용 권장 (특히 복잡한 JOIN/Subquery).
|
||||||
|
- **가이드**: `models/WorkAnalysis.js` 등의 `getRecentWork` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🕐 시간대(Timezone) 처리 가이드
|
||||||
|
|
||||||
|
### 핵심 원칙
|
||||||
|
이 프로젝트는 **한국 시간(KST, UTC+9)** 기준으로 운영됩니다.
|
||||||
|
|
||||||
|
### 문제 상황
|
||||||
|
서버가 UTC 시간대로 설정된 경우, `NOW()`, `CURRENT_TIMESTAMP`, `new Date()`를 사용하면 **한국 시간과 9시간 차이**가 발생합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
예시: 한국 시간 2026-02-03 08:00 AM에 신고 등록
|
||||||
|
- UTC 시간: 2026-02-02 11:00 PM
|
||||||
|
- DB 저장: 2026-02-02 (잘못된 날짜!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결 방법
|
||||||
|
|
||||||
|
#### 백엔드 (Node.js)
|
||||||
|
**공용 유틸리티 사용** (`utils/dateUtils.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { getKoreaDatetime, getKoreaDateString } = require('../utils/dateUtils');
|
||||||
|
|
||||||
|
// 비즈니스 날짜 저장 시
|
||||||
|
const reportDate = getKoreaDatetime(); // '2026-02-03 08:00:00'
|
||||||
|
await db.query('INSERT INTO reports (report_date, ...) VALUES (?, ...)', [reportDate, ...]);
|
||||||
|
|
||||||
|
// 날짜만 필요한 경우
|
||||||
|
const today = getKoreaDateString(); // '2026-02-03'
|
||||||
|
```
|
||||||
|
|
||||||
|
**제공되는 함수들**:
|
||||||
|
| 함수 | 반환값 | 용도 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `getKoreaDatetime()` | `'2026-02-03 08:00:00'` | DB DATETIME 저장 |
|
||||||
|
| `getKoreaDateString()` | `'2026-02-03'` | DB DATE 저장 |
|
||||||
|
| `getKoreaTimeString()` | `'08:00:00'` | DB TIME 저장 |
|
||||||
|
| `getKoreaYear()` | `2026` | 연도 |
|
||||||
|
| `getKoreaMonth()` | `2` | 월 (1-12) |
|
||||||
|
| `toKoreaDatetime(date)` | `'2026-02-03 08:00:00'` | Date 객체 변환 |
|
||||||
|
|
||||||
|
#### 프론트엔드 (JavaScript)
|
||||||
|
```javascript
|
||||||
|
// 로컬 시간대 기준 날짜 문자열
|
||||||
|
function getLocalDateString() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 잘못된 방법 (UTC 변환됨)
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// ✅ 올바른 방법 (로컬 시간)
|
||||||
|
const today = getLocalDateString();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컬럼별 적용 기준
|
||||||
|
|
||||||
|
| 컬럼 유형 | 처리 방법 | 비고 |
|
||||||
|
|-----------|-----------|------|
|
||||||
|
| `created_at`, `updated_at` | `NOW()` 또는 `CURRENT_TIMESTAMP` 사용 가능 | 감사용 메타데이터, UTC로 저장해도 무방 |
|
||||||
|
| `report_date`, `session_date` | **반드시 `getKoreaDatetime()` 사용** | 비즈니스 날짜, 사용자에게 표시됨 |
|
||||||
|
| `visit_date`, `attendance_date` | **반드시 한국 시간 기준** | 필터링/조회에 사용됨 |
|
||||||
|
| API 응답 `timestamp` | `new Date().toISOString()` 사용 가능 | 디버깅용 |
|
||||||
|
|
||||||
|
### 마이그레이션 주의사항
|
||||||
|
새 테이블 생성 시 비즈니스 날짜 컬럼은 **default 값을 사용하지 말고** 애플리케이션에서 명시적으로 설정:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ 잘못된 방법
|
||||||
|
table.datetime('report_date').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// ✅ 올바른 방법
|
||||||
|
table.datetime('report_date').notNullable(); // default 없음
|
||||||
|
// 애플리케이션에서 getKoreaDatetime()으로 값 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기존 데이터 보정 (필요시)
|
||||||
|
UTC로 잘못 저장된 데이터를 한국 시간으로 보정:
|
||||||
|
```sql
|
||||||
|
-- 주의: 백업 후 실행
|
||||||
|
UPDATE work_issue_reports
|
||||||
|
SET report_date = DATE_ADD(report_date, INTERVAL 9 HOUR)
|
||||||
|
WHERE report_date < '2026-02-03';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 가이드 (Jest)
|
||||||
|
|
||||||
|
### 중요도
|
||||||
|
1. **Service Layer** ⭐ (최우선: 비즈니스 로직 검증)
|
||||||
|
2. Controller Layer (요청 처리 검증)
|
||||||
|
3. Integration (E2E)
|
||||||
|
|
||||||
|
### 실행
|
||||||
|
```bash
|
||||||
|
npm test # 전체 실행
|
||||||
|
npm run test:watch # 변경 감지
|
||||||
|
npm run test:coverage # 커버리지 측정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작성 패턴 (Service 예시)
|
||||||
|
```javascript
|
||||||
|
// Service는 DB Model을 Mocking하여 테스트
|
||||||
|
const service = require('../service');
|
||||||
|
jest.mock('../model');
|
||||||
|
|
||||||
|
it('should create report', async () => {
|
||||||
|
Model.create.mockResolvedValue(123); // Mock DB response
|
||||||
|
const result = await service.createReport(...);
|
||||||
|
expect(result).toEqual(...);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Git 관리
|
||||||
|
- **커밋 메시지**: `type: subject` (예: `feat: 작업보고서 API 추가`)
|
||||||
|
- **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||||
|
- **브랜치**: `main`(배포), `develop`(통합), `feature/*`(기능)
|
||||||
210
DEV_LOG.md
Normal file
210
DEV_LOG.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 개발 진행 로그
|
||||||
|
|
||||||
|
## 📅 Recent Updates (2026-01-29)
|
||||||
|
|
||||||
|
### 🗺️ 대시보드 작업장 현황 지도 구현 (2026-01-29)
|
||||||
|
**개요**: 대시보드에 실시간 작업장 현황을 지도로 시각화하여 작업자 및 방문자 현황을 한눈에 파악
|
||||||
|
|
||||||
|
**배경**:
|
||||||
|
- 기존 "오늘의 작업 현황" 테이블 방식은 직관성 부족
|
||||||
|
- 작업장별 위치와 인원 현황을 시각적으로 표시할 필요
|
||||||
|
- 작업장 관리 페이지에서 설정한 구역 정보 활용
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
1. **대시보드 UI 개편**
|
||||||
|
- **제거**: "오늘의 작업 현황" 테이블 섹션
|
||||||
|
- **추가**: "작업장 현황" 지도 섹션
|
||||||
|
- 공장 선택 드롭다운 (제1공장 기본 선택)
|
||||||
|
- 실시간 새로고침 버튼
|
||||||
|
|
||||||
|
2. **작업장 현황 지도 기능** (`web-ui/js/workplace-status.js`)
|
||||||
|
- **데이터 소스**:
|
||||||
|
- `map-regions` API: 작업장 관리 페이지에서 정의한 구역 정보
|
||||||
|
- `tbm/sessions` API: 금일 TBM 작업 정보
|
||||||
|
- `workplace-visits/requests` API: 금일 방문자 신청 정보
|
||||||
|
|
||||||
|
- **시각화 방식**:
|
||||||
|
- 모든 작업장 구역을 사각형으로 표시
|
||||||
|
- 인원 없음: 회색 테두리 + 작업장 이름
|
||||||
|
- 내부 작업자만: 파란색 영역 + 인원 수 배지
|
||||||
|
- 외부 방문자만: 보라색 영역 + 인원 수 배지
|
||||||
|
- 작업자+방문자: 초록색 영역 + 총 인원 수 배지
|
||||||
|
|
||||||
|
- **상호작용**:
|
||||||
|
- 작업장 구역 클릭 → 상세 정보 모달 표시
|
||||||
|
- 내부 작업자: 작업명 + 인원 수 + 작업 위치 + 프로젝트명
|
||||||
|
- 외부 방문자: 업체명 + 인원 수 + 방문 시간 + 목적
|
||||||
|
|
||||||
|
3. **TBM 데이터 통합**
|
||||||
|
- 세션별 작업 정보 집계 (`task_name`, `team_member_count`)
|
||||||
|
- 조장 포함 총 인원 계산
|
||||||
|
- Draft 상태 세션도 예정 작업으로 표시
|
||||||
|
|
||||||
|
4. **기술적 구현**:
|
||||||
|
- Canvas API 기반 지도 렌더링
|
||||||
|
- 사각형 좌표 변환 (퍼센트 → 픽셀)
|
||||||
|
- 마우스 클릭 위치를 영역 좌표로 매핑
|
||||||
|
- 작업장 관리 페이지와 동일한 `map-regions` API 사용으로 데이터 일관성 보장
|
||||||
|
|
||||||
|
**수정 파일**:
|
||||||
|
- `web-ui/pages/dashboard.html` - 작업장 현황 섹션 추가
|
||||||
|
- `web-ui/js/workplace-status.js` - 신규 생성 (지도 렌더링 및 데이터 로직)
|
||||||
|
- `web-ui/js/modern-dashboard.js` - 삭제된 DOM 요소 조건부 체크 추가
|
||||||
|
|
||||||
|
**효과**:
|
||||||
|
- 작업장별 실시간 인원 현황을 시각적으로 파악 가능
|
||||||
|
- 작업 배치 및 안전 관리 의사결정 지원
|
||||||
|
- 외부 방문자 위치 추적으로 안전 관리 강화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🏖️ 휴가 관리 시스템 리팩토링 및 페이지 분리 (2026-01-29)
|
||||||
|
**개요**: 코딩 가이드 준수를 위해 536줄의 단일 파일을 2개 페이지로 분리하고 공통 함수 라이브러리 생성
|
||||||
|
|
||||||
|
**배경**:
|
||||||
|
- 기존 `vacation-management.html` (536줄) - 코딩 가이드 위반 (파일 길이 초과)
|
||||||
|
- 단일 파일에 작업자/관리자 기능이 혼재
|
||||||
|
- 코드 중복 발생
|
||||||
|
|
||||||
|
**구조 개선**:
|
||||||
|
1. **공통 함수 라이브러리 생성**
|
||||||
|
- **파일**: `web-ui/js/vacation-common.js`
|
||||||
|
- **역할**: 모든 휴가 페이지에서 사용하는 공통 함수 모음
|
||||||
|
- **주요 함수**:
|
||||||
|
- `loadWorkers()`: 작업자 목록 로드
|
||||||
|
- `loadVacationTypes()`: 휴가 유형 로드
|
||||||
|
- `getCurrentUser()`: 현재 사용자 정보 조회
|
||||||
|
- `renderVacationRequests()`: 휴가 신청 목록 렌더링
|
||||||
|
- `approveVacationRequest()`: 휴가 승인
|
||||||
|
- `rejectVacationRequest()`: 휴가 거부
|
||||||
|
- `deleteVacationRequest()`: 휴가 신청 삭제
|
||||||
|
|
||||||
|
2. **2개 페이지로 분리**
|
||||||
|
- **`vacation-request.html`** (작업자 휴가 신청)
|
||||||
|
- 역할: 휴가 신청 및 본인 신청 내역 확인
|
||||||
|
- 권한: 모든 작업자 (자동으로 본인 선택됨)
|
||||||
|
- 기능:
|
||||||
|
- 휴가 잔여 현황 표시
|
||||||
|
- 휴가 신청 폼
|
||||||
|
- 내 신청 내역 (삭제 가능 - pending만)
|
||||||
|
|
||||||
|
- **`vacation-management.html`** (관리자 휴가 관리)
|
||||||
|
- 역할: 휴가 승인/직접입력/전체내역 관리 (3개 탭)
|
||||||
|
- 권한: 관리자 전용 (system/admin)
|
||||||
|
- 기능:
|
||||||
|
- **탭 1: 승인 대기 목록** - 승인/거부 버튼
|
||||||
|
- **탭 2: 직접 입력** - 승인 절차 없이 휴가 정보 직접 입력
|
||||||
|
- 작업자 선택 시 휴가 잔여 표시
|
||||||
|
- 입력 즉시 승인 상태로 저장
|
||||||
|
- 최근 입력 내역 표시
|
||||||
|
- **탭 3: 전체 신청 내역** - 날짜 필터링 지원
|
||||||
|
|
||||||
|
3. **데이터베이스 페이지 등록**
|
||||||
|
- **마이그레이션**: `20260129000003_update_vacation_pages.js`
|
||||||
|
- **변경사항**:
|
||||||
|
- 기존 `vacation-management` 페이지 삭제
|
||||||
|
- 신규 2개 페이지 등록 (vacation-request, vacation-management)
|
||||||
|
- `is_admin_only` 플래그로 권한 구분 (vacation-request: 0, vacation-management: 1)
|
||||||
|
- `display_order`로 표시 순서 관리
|
||||||
|
|
||||||
|
4. **파일 정리**
|
||||||
|
- 기존: `vacation-management.html` → `.old` 확장자로 이름 변경
|
||||||
|
- 향후: 충분한 테스트 후 삭제 예정
|
||||||
|
|
||||||
|
**기술적 개선사항**:
|
||||||
|
- 코드 중복 제거: 공통 함수를 vacation-common.js로 추출
|
||||||
|
- 권한 체크 강화: 페이지 로드 시 access_level 확인 및 리다이렉트
|
||||||
|
- 이벤트 기반 UI 업데이트: 'vacation-updated' 이벤트로 페이지 간 동기화
|
||||||
|
- 역할 기반 접근 제어: 작업자는 본인 정보만, 관리자는 전체 관리
|
||||||
|
- 탭 기반 UI: 관리자 페이지는 3개 탭으로 기능 구분
|
||||||
|
|
||||||
|
**파일 변경사항**:
|
||||||
|
```
|
||||||
|
[NEW] web-ui/js/vacation-common.js (공통 함수 라이브러리)
|
||||||
|
[NEW] web-ui/pages/common/vacation-request.html (작업자 휴가 신청)
|
||||||
|
[NEW] web-ui/pages/common/vacation-management.html (관리자 3-탭 관리)
|
||||||
|
[NEW] api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js
|
||||||
|
[UPDATED] web-ui/pages/dashboard.html (휴가 관련 링크 업데이트)
|
||||||
|
[RENAMED] web-ui/pages/common/vacation-management.html → vacation-management.html.old
|
||||||
|
```
|
||||||
|
|
||||||
|
**코딩 가이드 준수**:
|
||||||
|
- ✅ 파일 길이 제한 준수 (기존 536줄 → 각 350줄 이하)
|
||||||
|
- ✅ 공통 로직 분리 (DRY 원칙)
|
||||||
|
- ✅ 단일 책임 원칙 (각 페이지가 명확한 역할)
|
||||||
|
- ✅ 역할 기반 접근 제어 명확화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Previous Updates (2025-12-19)
|
||||||
|
|
||||||
|
### WorkAnalysis 리팩토링 완료
|
||||||
|
**내용**: 복잡한 통계 로직을 포함하던 `workAnalysisController.js`를 리팩토링함.
|
||||||
|
- **[NEW] Service Layer**: `services/workAnalysisService.js` 생성. 비즈니스 로직 이관.
|
||||||
|
- **[UPDATE] Model Layer**: Raw SQL 쿼리를 Controller에서 Model(`models/WorkAnalysis.js`)로 이동. `getProjectWorkTypeRawData` 메서드 추가.
|
||||||
|
- **[CLEANUP] Controller**: `getProjectWorkTypeAnalysis` 메서드가 Service를 호출하도록 단순화.
|
||||||
|
|
||||||
|
### 🐛 심각한 버그 수정 및 시스템 정상화 (2025-12-19)
|
||||||
|
**개요**: 로그인 500 에러 및 대시보드 데이터 미표시 문제 해결.
|
||||||
|
|
||||||
|
1. **DB 정상화 (Login 500 Fix)**
|
||||||
|
- **원인**: 초기화된 DB(Empty) 및 테이블명 대소문자 불일치(`Users` vs `users`).
|
||||||
|
- **조치**: `hyungi.sql` 복원 후, 컨벤션에 맞춰 테이블명 일괄 변경(`Users`→`users`, `Projects`→`projects`, `Workers`→`workers`, `Tasks`→`tasks`).
|
||||||
|
- **코드 수정**: `userModel.js`, `authController.js` 등 관련 코드의 테이블 참조 수정.
|
||||||
|
|
||||||
|
2. **프로젝트 조회 오류 수정 (Project API 500 Fix)**
|
||||||
|
- **원인**: 구버전 스키마 복원으로 인한 `projects` 테이블 컬럼 부족(`is_active`, `project_status`, `completed_date`).
|
||||||
|
- **조치**: 마이그레이션 실행하여 누락된 컬럼 추가.
|
||||||
|
|
||||||
|
3. **대시보드 작업자 미표시 수정**
|
||||||
|
- **원인 1 (Data)**: `workers` 테이블 내 `status` 값이 유효하지 않음(`..`). → `active`로 일괄 수정.
|
||||||
|
- **원인 2 (Logic)**: `INNER JOIN` 사용으로 통계가 없는 작업자 누락. → `LEFT JOIN`으로 쿼리 개선(`MonthlyStatusModel.js`).
|
||||||
|
|
||||||
|
4. **테스트 계정 생성**
|
||||||
|
- `tester` / `000000` 관리자(Leader) 계정 생성.
|
||||||
|
|
||||||
|
### 🛠️ 작업자 및 프로젝트 관리 기능 개선 (2026-01-06)
|
||||||
|
**개요**: 작업자/프로젝트의 비활성화(퇴사/종료) 처리가 즉시 반영되지 않는 문제 및 로직 오류 수정.
|
||||||
|
|
||||||
|
1. **캐시 무효화 및 필터링 적용 (Cache & Filtering)**
|
||||||
|
- **문제**: 작업자/프로젝트 상태 변경 후에도 캐시가 남아있어 드롭다운 목록에서 사라지지 않음.
|
||||||
|
- **해결**:
|
||||||
|
- `WorkerController`, `ProjectController`: 생성/수정/삭제 시 `request` 단위의 캐시 즉시 무효화 로직 추가.
|
||||||
|
- `WorkerController`: 목록 조회 시 `status` 파라미터 지원 추가.
|
||||||
|
- `daily-work-report.js`: 작업보고서 작성 시 `active` 상태인 작업자만 필터링하여 조회하도록 수정.
|
||||||
|
|
||||||
|
2. **작업자 비활성화 오류 수정 (Bug Fix)**
|
||||||
|
- **원인**: `workerModel.update` 쿼리에 DB에 존재하지 않는 `join_date` 컬럼을 업데이트하려는 시도가 있어 SQL 에러 발생.
|
||||||
|
- **해결**: `workerModel.js`에서 잘못된 컬럼(`join_date`) 참조 제거. (올바른 컬럼 `hire_date`는 유지)
|
||||||
|
|
||||||
|
|
||||||
|
3. **일일 근태 추적 시스템 구현 (Daily Attendance Tracking)**
|
||||||
|
- **Backend**:
|
||||||
|
- `AttendanceModel.initializeDailyRecords` 추가: 모든 활성 작업자에 대해 'incomplete' 상태의 근태 기록 자동 생성 (Lazy Initialization).
|
||||||
|
- `AttendanceModel.syncWithWorkReports` 추가: 작업 보고서 작성/수정/삭제 시 근태 상태(미제출/부분/완료/초과) 자동 동기화.
|
||||||
|
- `dailyWorkReportModel.js`에 동기화 로직 통합 (트랜잭션 후 처리).
|
||||||
|
- `attendanceService`에서 상태 조회 시 초기화 로직 수행.
|
||||||
|
- **Frontend**:
|
||||||
|
- `group-leader-dashboard.js` 리팩토링: 모의 데이터 대신 실제 API(`/attendance/daily-status`) 연동.
|
||||||
|
- `modern-dashboard.css`: 근태 현황 카드(`worker-card`) 및 그리드 스타일 추가.
|
||||||
|
- `group-leader.html`: 스크립트 로드 추가 및 DOM 구조 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡보안 및 검토 리포트 (History)
|
||||||
|
|
||||||
|
### 2025-07-29 보안 취약점 분석
|
||||||
|
`api.hyungi.net` 백엔드 서버 취약점 점검 결과.
|
||||||
|
|
||||||
|
| 라이브러리 | 심각도 | 상태 | 조치사항 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tar-fs` | **High** | 해결가능 | `npm audit fix` 권장 (완료됨) |
|
||||||
|
| `brace-expansion` | Low | 해결가능 | `npm audit fix` 권장 (완료됨) |
|
||||||
|
| `pm2` | Low | 미해결 | 업데이트 대기 필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 향후 계획
|
||||||
|
1. **테스트 커버리지 확보**: 리팩토링된 Service Layer에 대한 Unit Test 보강.
|
||||||
|
2. **Knex 마이그레이션**: 남은 Raw SQL(Model Layer)을 Knex 쿼리빌더로 점진적 전환.
|
||||||
|
3. **API 문서화**: Swagger/OpenAPI 도입 검토.
|
||||||
81
_archive/MYSQL_COMPATIBILITY_NOTES.md
Normal file
81
_archive/MYSQL_COMPATIBILITY_NOTES.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# MySQL 8.0 호환성 문제 해결 가이드
|
||||||
|
|
||||||
|
## 🚨 문제 상황
|
||||||
|
- **환경**: 시놀로지 NAS Docker 환경 (MySQL 8.0.44)
|
||||||
|
- **오류**: `Incorrect arguments to mysqld_stmt_execute`
|
||||||
|
- **증상**: 개발 환경(맥미니)에서는 정상 작동하지만 프로덕션 환경에서만 실패
|
||||||
|
|
||||||
|
## 🔍 원인 분석
|
||||||
|
|
||||||
|
### MySQL 설정 차이
|
||||||
|
```sql
|
||||||
|
-- 시놀로지 MySQL 8.0 설정
|
||||||
|
@@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
|
||||||
|
@@version: 8.0.44
|
||||||
|
character_set_database: utf8mb4
|
||||||
|
collation_database: utf8mb4_unicode_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js MySQL 드라이버 호환성 문제
|
||||||
|
- **문제**: `db.execute()` 메서드의 파라미터 바인딩이 MySQL 8.0의 엄격한 모드에서 호환성 문제 발생
|
||||||
|
- **해결**: `db.query()` 메서드 사용으로 변경
|
||||||
|
|
||||||
|
## 🛠️ 해결 방법
|
||||||
|
|
||||||
|
### 변경 전 (문제 있음)
|
||||||
|
```javascript
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate, parseInt(limit)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변경 후 (해결됨)
|
||||||
|
```javascript
|
||||||
|
const [results] = await this.db.query(query, [startDate, endDate, parseInt(limit)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 db.execute vs db.query 차이점
|
||||||
|
|
||||||
|
| 구분 | db.execute | db.query |
|
||||||
|
|------|------------|----------|
|
||||||
|
| **파라미터 바인딩** | Prepared Statement 방식 | 전통적인 쿼리 방식 |
|
||||||
|
| **성능** | 반복 실행 시 더 빠름 | 단발성 실행에 적합 |
|
||||||
|
| **보안** | SQL Injection 방지 강화 | 기본적인 방지 |
|
||||||
|
| **MySQL 8.0 호환성** | 엄격한 모드에서 문제 발생 가능 | 안정적 |
|
||||||
|
| **메모리 사용** | 더 효율적 | 상대적으로 많음 |
|
||||||
|
|
||||||
|
## 🎯 권장사항
|
||||||
|
|
||||||
|
### 1. 환경별 대응
|
||||||
|
- **개발 환경**: `db.execute` 사용 가능
|
||||||
|
- **프로덕션 환경 (MySQL 8.0)**: `db.query` 사용 권장
|
||||||
|
|
||||||
|
### 2. 코드 작성 가이드
|
||||||
|
```javascript
|
||||||
|
// ✅ 권장: 환경에 따른 분기 처리
|
||||||
|
const executeQuery = async (db, query, params) => {
|
||||||
|
try {
|
||||||
|
// MySQL 8.0 엄격 모드 대응
|
||||||
|
return await db.query(query, params);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Query execution failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 테스트 전략
|
||||||
|
- 개발 환경과 프로덕션 환경에서 모두 테스트
|
||||||
|
- MySQL 버전별 호환성 확인
|
||||||
|
- 복잡한 JOIN 쿼리는 특히 주의
|
||||||
|
|
||||||
|
## 🔧 적용된 파일
|
||||||
|
- `synology_deployment/api/models/WorkAnalysis.js` - `getRecentWork()` 함수
|
||||||
|
|
||||||
|
## 📝 참고사항
|
||||||
|
- 이 문제는 MySQL 8.0의 `ONLY_FULL_GROUP_BY` 모드와 Node.js MySQL2 드라이버의 호환성 문제로 추정
|
||||||
|
- 향후 유사한 문제 발생 시 이 가이드 참조
|
||||||
|
- 다른 복잡한 쿼리에서도 동일한 문제가 발생할 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
**작성일**: 2025-11-05
|
||||||
|
**해결 완료**: ✅
|
||||||
|
**테스트 완료**: ✅
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
# TK-FB-Project - 통합 실행 가이드
|
# TK-FB-Project - 통합 실행 가이드
|
||||||
|
|
||||||
|
## ⚙️ 사전 준비
|
||||||
|
|
||||||
|
### 환경 변수 설정 (필수)
|
||||||
|
처음 실행하기 전에 환경 변수 파일을 생성해야 합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. .env.example을 복사하여 .env 파일 생성
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. .env 파일을 편집하여 실제 비밀번호로 변경
|
||||||
|
nano .env # 또는 vi, code 등 사용
|
||||||
|
|
||||||
|
# 3. 강력한 비밀번호로 변경 (예시)
|
||||||
|
# MYSQL_ROOT_PASSWORD=your_secure_password_here
|
||||||
|
# MYSQL_PASSWORD=your_secure_password_here
|
||||||
|
# JWT_SECRET=your_random_jwt_secret_min_32_chars
|
||||||
|
```
|
||||||
|
|
||||||
|
**중요**: `.env` 파일은 절대 Git에 커밋하지 마세요!
|
||||||
|
|
||||||
## 🚀 한 번에 모든 서비스 실행
|
## 🚀 한 번에 모든 서비스 실행
|
||||||
|
|
||||||
### 🎯 간편 실행 (권장)
|
### 🎯 간편 실행 (권장)
|
||||||
@@ -50,10 +70,12 @@ docker-compose logs -f
|
|||||||
## 💾 데이터베이스 정보
|
## 💾 데이터베이스 정보
|
||||||
|
|
||||||
- **호스트**: localhost:20306
|
- **호스트**: localhost:20306
|
||||||
- **데이터베이스**: hyungi
|
- **데이터베이스**: hyungi (`.env` 파일의 `MYSQL_DATABASE`)
|
||||||
- **사용자**: hyungi
|
- **사용자**: hyungi_user (`.env` 파일의 `MYSQL_USER`)
|
||||||
- **비밀번호**: hyungi_password_2025
|
- **비밀번호**: `.env` 파일에서 설정한 `MYSQL_PASSWORD`
|
||||||
- **Root 비밀번호**: hyungi_root_password_2025
|
- **Root 비밀번호**: `.env` 파일에서 설정한 `MYSQL_ROOT_PASSWORD`
|
||||||
|
|
||||||
|
**참고**: 실제 비밀번호는 `.env` 파일을 확인하세요.
|
||||||
|
|
||||||
## ✨ 주요 개선사항
|
## ✨ 주요 개선사항
|
||||||
|
|
||||||
917
_archive/TESTING_GUIDE.md
Normal file
917
_archive/TESTING_GUIDE.md
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
# TK-FB-Project 테스트 가이드
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [개요](#개요)
|
||||||
|
2. [테스트 환경 설정](#테스트-환경-설정)
|
||||||
|
3. [테스트 작성 가이드](#테스트-작성-가이드)
|
||||||
|
4. [실전 예제](#실전-예제)
|
||||||
|
5. [테스트 실행](#테스트-실행)
|
||||||
|
6. [모범 사례](#모범-사례)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
이 프로젝트는 **Jest**를 사용하여 테스트를 작성합니다.
|
||||||
|
|
||||||
|
### 테스트 계층
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ API 통합 테스트 (E2E) │ ← supertest로 실제 HTTP 요청
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 컨트롤러 테스트 │ ← req/res 모킹
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 서비스 레이어 테스트 │ ← 비즈니스 로직 (DB 모킹)
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 모델 테스트 │ ← DB 쿼리 로직
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 우선순위
|
||||||
|
1. **서비스 레이어 테스트** ⭐ (비즈니스 로직, 가장 중요)
|
||||||
|
2. **컨트롤러 테스트** (API 엔드포인트)
|
||||||
|
3. **통합 테스트** (실제 DB 사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 환경 설정
|
||||||
|
|
||||||
|
### 1단계: 필요한 패키지 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net
|
||||||
|
|
||||||
|
npm install --save-dev jest supertest @types/jest
|
||||||
|
npm install --save-dev jest-mock-extended
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: Jest 설정 파일 생성
|
||||||
|
|
||||||
|
**`jest.config.js`** 파일을 프로젝트 루트에 생성:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'controllers/**/*.js',
|
||||||
|
'services/**/*.js',
|
||||||
|
'models/**/*.js',
|
||||||
|
'!**/node_modules/**',
|
||||||
|
'!**/coverage/**',
|
||||||
|
'!**/tests/**'
|
||||||
|
],
|
||||||
|
testMatch: [
|
||||||
|
'**/tests/**/*.test.js',
|
||||||
|
'**/tests/**/*.spec.js'
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||||
|
verbose: true,
|
||||||
|
collectCoverage: true,
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
testTimeout: 10000
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 테스트 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
api.hyungi.net/
|
||||||
|
├── tests/
|
||||||
|
│ ├── setup.js # 전역 테스트 설정
|
||||||
|
│ ├── helpers/
|
||||||
|
│ │ ├── dbHelper.js # DB 모킹 헬퍼
|
||||||
|
│ │ └── mockData.js # 테스트용 더미 데이터
|
||||||
|
│ ├── unit/
|
||||||
|
│ │ ├── services/ # 서비스 단위 테스트
|
||||||
|
│ │ │ ├── workReportService.test.js
|
||||||
|
│ │ │ ├── attendanceService.test.js
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── controllers/ # 컨트롤러 테스트
|
||||||
|
│ │ │ ├── workReportController.test.js
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ └── models/ # 모델 테스트
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── integration/ # 통합 테스트
|
||||||
|
│ ├── workReport.test.js
|
||||||
|
│ └── ...
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4단계: package.json에 스크립트 추가
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:unit": "jest tests/unit",
|
||||||
|
"test:integration": "jest tests/integration",
|
||||||
|
"test:verbose": "jest --verbose"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 작성 가이드
|
||||||
|
|
||||||
|
### 서비스 레이어 테스트 패턴
|
||||||
|
|
||||||
|
서비스 레이어는 **DB를 모킹**하여 테스트합니다.
|
||||||
|
|
||||||
|
#### 기본 구조
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const serviceName = require('../../services/serviceName');
|
||||||
|
const { ValidationError, NotFoundError, DatabaseError } = require('../../utils/errors');
|
||||||
|
|
||||||
|
// DB 모킹
|
||||||
|
jest.mock('../../models/modelName');
|
||||||
|
const ModelName = require('../../models/modelName');
|
||||||
|
|
||||||
|
describe('ServiceName', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('functionName', () => {
|
||||||
|
it('should ... (성공 케이스)', async () => {
|
||||||
|
// Arrange (준비)
|
||||||
|
const mockData = { /* ... */ };
|
||||||
|
ModelName.methodName.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
// Act (실행)
|
||||||
|
const result = await serviceName.functionName(params);
|
||||||
|
|
||||||
|
// Assert (검증)
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
expect(ModelName.methodName).toHaveBeenCalledWith(expectedParams);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when ... (실패 케이스)', async () => {
|
||||||
|
// Arrange
|
||||||
|
const invalidParams = null;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(serviceName.functionName(invalidParams))
|
||||||
|
.rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨트롤러 테스트 패턴
|
||||||
|
|
||||||
|
컨트롤러는 **서비스 레이어를 모킹**하여 테스트합니다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const controllerName = require('../../controllers/controllerName');
|
||||||
|
|
||||||
|
// 서비스 모킹
|
||||||
|
jest.mock('../../services/serviceName');
|
||||||
|
const serviceName = require('../../services/serviceName');
|
||||||
|
|
||||||
|
describe('ControllerName', () => {
|
||||||
|
let req, res;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// req, res 모킹 객체 초기화
|
||||||
|
req = {
|
||||||
|
body: {},
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
user: { id: 1, role: 'admin' }
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
json: jest.fn().mockReturnThis(),
|
||||||
|
status: jest.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /endpoint', () => {
|
||||||
|
it('should return data successfully', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockData = { /* ... */ };
|
||||||
|
serviceName.getData.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await controllerName.getData(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockData,
|
||||||
|
message: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(serviceName.getData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 패턴 (E2E)
|
||||||
|
|
||||||
|
실제 HTTP 요청으로 테스트합니다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../../index');
|
||||||
|
const { getDb } = require('../../dbPool');
|
||||||
|
|
||||||
|
describe('WorkReport API Integration Tests', () => {
|
||||||
|
let db;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await getDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/work-reports', () => {
|
||||||
|
it('should create work report', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/work-reports')
|
||||||
|
.set('Authorization', 'Bearer ' + testToken)
|
||||||
|
.send({
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1,
|
||||||
|
// ... 기타 필드
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data).toHaveProperty('workReport_ids');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실전 예제
|
||||||
|
|
||||||
|
### 예제 1: workReportService 테스트
|
||||||
|
|
||||||
|
**`tests/unit/services/workReportService.test.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const workReportService = require('../../../services/workReportService');
|
||||||
|
const { ValidationError, NotFoundError, DatabaseError } = require('../../../utils/errors');
|
||||||
|
|
||||||
|
// 모델 모킹
|
||||||
|
jest.mock('../../../models/workReportModel');
|
||||||
|
const workReportModel = require('../../../models/workReportModel');
|
||||||
|
|
||||||
|
describe('WorkReportService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWorkReportService', () => {
|
||||||
|
it('단일 보고서를 성공적으로 생성해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
const reportData = {
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1,
|
||||||
|
project_id: 1,
|
||||||
|
work_hours: 8
|
||||||
|
};
|
||||||
|
|
||||||
|
// workReportModel.create가 콜백 형태이므로 모킹 설정
|
||||||
|
workReportModel.create = jest.fn((data, callback) => {
|
||||||
|
callback(null, 123); // insertId = 123
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await workReportService.createWorkReportService(reportData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ workReport_ids: [123] });
|
||||||
|
expect(workReportModel.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workReportModel.create).toHaveBeenCalledWith(
|
||||||
|
reportData,
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다중 보고서를 성공적으로 생성해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
const reportsData = [
|
||||||
|
{ report_date: '2025-12-11', worker_id: 1, work_hours: 8 },
|
||||||
|
{ report_date: '2025-12-11', worker_id: 2, work_hours: 7 }
|
||||||
|
];
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
workReportModel.create = jest.fn((data, callback) => {
|
||||||
|
callCount++;
|
||||||
|
callback(null, 100 + callCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await workReportService.createWorkReportService(reportsData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ workReport_ids: [101, 102] });
|
||||||
|
expect(workReportModel.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 배열이면 ValidationError를 던져야 함', async () => {
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.createWorkReportService([]))
|
||||||
|
.rejects.toThrow(ValidationError);
|
||||||
|
|
||||||
|
await expect(workReportService.createWorkReportService([]))
|
||||||
|
.rejects.toThrow('보고서 데이터가 필요합니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
const reportData = { report_date: '2025-12-11', worker_id: 1 };
|
||||||
|
|
||||||
|
workReportModel.create = jest.fn((data, callback) => {
|
||||||
|
callback(new Error('DB connection failed'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.createWorkReportService(reportData))
|
||||||
|
.rejects.toThrow(DatabaseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkReportByIdService', () => {
|
||||||
|
it('ID로 보고서를 조회해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockReport = {
|
||||||
|
id: 123,
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1,
|
||||||
|
work_hours: 8
|
||||||
|
};
|
||||||
|
|
||||||
|
workReportModel.getById = jest.fn((id, callback) => {
|
||||||
|
callback(null, mockReport);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await workReportService.getWorkReportByIdService(123);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(mockReport);
|
||||||
|
expect(workReportModel.getById).toHaveBeenCalledWith(123, expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보고서가 없으면 NotFoundError를 던져야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
workReportModel.getById = jest.fn((id, callback) => {
|
||||||
|
callback(null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.getWorkReportByIdService(999))
|
||||||
|
.rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
await expect(workReportService.getWorkReportByIdService(999))
|
||||||
|
.rejects.toThrow('작업 보고서를 찾을 수 없습니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ID가 없으면 ValidationError를 던져야 함', async () => {
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.getWorkReportByIdService(null))
|
||||||
|
.rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateWorkReportService', () => {
|
||||||
|
it('보고서를 성공적으로 수정해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
const updateData = { work_hours: 9 };
|
||||||
|
|
||||||
|
workReportModel.update = jest.fn((id, data, callback) => {
|
||||||
|
callback(null, 1); // affectedRows = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await workReportService.updateWorkReportService(123, updateData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ changes: 1 });
|
||||||
|
expect(workReportModel.update).toHaveBeenCalledWith(
|
||||||
|
123,
|
||||||
|
updateData,
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('수정할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
workReportModel.update = jest.fn((id, data, callback) => {
|
||||||
|
callback(null, 0); // affectedRows = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.updateWorkReportService(999, {}))
|
||||||
|
.rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeWorkReportService', () => {
|
||||||
|
it('보고서를 성공적으로 삭제해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
workReportModel.remove = jest.fn((id, callback) => {
|
||||||
|
callback(null, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await workReportService.removeWorkReportService(123);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ changes: 1 });
|
||||||
|
expect(workReportModel.remove).toHaveBeenCalledWith(123, expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('삭제할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
workReportModel.remove = jest.fn((id, callback) => {
|
||||||
|
callback(null, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(workReportService.removeWorkReportService(999))
|
||||||
|
.rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예제 2: workReportController 테스트
|
||||||
|
|
||||||
|
**`tests/unit/controllers/workReportController.test.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const workReportController = require('../../../controllers/workReportController');
|
||||||
|
|
||||||
|
// 서비스 모킹
|
||||||
|
jest.mock('../../../services/workReportService');
|
||||||
|
const workReportService = require('../../../services/workReportService');
|
||||||
|
|
||||||
|
describe('WorkReportController', () => {
|
||||||
|
let req, res;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
body: {},
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
user: { id: 1, username: 'test_user', role: 'admin' }
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
json: jest.fn().mockReturnThis(),
|
||||||
|
status: jest.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWorkReport', () => {
|
||||||
|
it('단일 작업 보고서를 생성해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
req.body = {
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1,
|
||||||
|
work_hours: 8
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResult = { workReport_ids: [123] };
|
||||||
|
workReportService.createWorkReportService.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await workReportController.createWorkReport(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(workReportService.createWorkReportService).toHaveBeenCalledWith(req.body);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockResult,
|
||||||
|
message: '작업 보고서가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkReportsByDate', () => {
|
||||||
|
it('날짜별 작업 보고서를 조회해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
req.params = { date: '2025-12-11' };
|
||||||
|
const mockReports = [
|
||||||
|
{ id: 1, report_date: '2025-12-11', work_hours: 8 },
|
||||||
|
{ id: 2, report_date: '2025-12-11', work_hours: 7 }
|
||||||
|
];
|
||||||
|
|
||||||
|
workReportService.getWorkReportsByDateService.mockResolvedValue(mockReports);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await workReportController.getWorkReportsByDate(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(workReportService.getWorkReportsByDateService).toHaveBeenCalledWith('2025-12-11');
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockReports,
|
||||||
|
message: '작업 보고서 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkReportById', () => {
|
||||||
|
it('ID로 작업 보고서를 조회해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
req.params = { id: '123' };
|
||||||
|
const mockReport = { id: 123, work_hours: 8 };
|
||||||
|
|
||||||
|
workReportService.getWorkReportByIdService.mockResolvedValue(mockReport);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await workReportController.getWorkReportById(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(workReportService.getWorkReportByIdService).toHaveBeenCalledWith('123');
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockReport,
|
||||||
|
message: '작업 보고서 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateWorkReport', () => {
|
||||||
|
it('작업 보고서를 수정해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
req.params = { id: '123' };
|
||||||
|
req.body = { work_hours: 9 };
|
||||||
|
const mockResult = { changes: 1 };
|
||||||
|
|
||||||
|
workReportService.updateWorkReportService.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await workReportController.updateWorkReport(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(workReportService.updateWorkReportService).toHaveBeenCalledWith('123', req.body);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockResult,
|
||||||
|
message: '작업 보고서가 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeWorkReport', () => {
|
||||||
|
it('작업 보고서를 삭제해야 함', async () => {
|
||||||
|
// Arrange
|
||||||
|
req.params = { id: '123' };
|
||||||
|
const mockResult = { changes: 1 };
|
||||||
|
|
||||||
|
workReportService.removeWorkReportService.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await workReportController.removeWorkReport(req, res);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(workReportService.removeWorkReportService).toHaveBeenCalledWith('123');
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: mockResult,
|
||||||
|
message: '작업 보고서가 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예제 3: 통합 테스트 (E2E)
|
||||||
|
|
||||||
|
**`tests/integration/workReport.test.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../../index');
|
||||||
|
const { getDb } = require('../../dbPool');
|
||||||
|
|
||||||
|
describe('WorkReport API Integration Tests', () => {
|
||||||
|
let db;
|
||||||
|
let authToken;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await getDb();
|
||||||
|
|
||||||
|
// 테스트용 인증 토큰 생성 (실제 로그인 API 호출 또는 JWT 직접 생성)
|
||||||
|
const loginResponse = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
username: 'test_admin',
|
||||||
|
password: 'test_password'
|
||||||
|
});
|
||||||
|
|
||||||
|
authToken = loginResponse.body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 테스트 데이터 정리
|
||||||
|
await db.query('DELETE FROM daily_work_reports WHERE report_date = ?', ['2025-12-11']);
|
||||||
|
await db.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/work-reports', () => {
|
||||||
|
it('작업 보고서를 생성해야 함', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/work-reports')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1,
|
||||||
|
project_id: 1,
|
||||||
|
work_type_id: 1,
|
||||||
|
work_hours: 8,
|
||||||
|
work_content: '테스트 작업'
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data).toHaveProperty('workReport_ids');
|
||||||
|
expect(response.body.data.workReport_ids).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('인증 토큰 없이 요청하면 401을 반환해야 함', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/work-reports')
|
||||||
|
.send({
|
||||||
|
report_date: '2025-12-11',
|
||||||
|
worker_id: 1
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/work-reports/:date', () => {
|
||||||
|
it('날짜별 작업 보고서를 조회해야 함', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/work-reports/2025-12-11')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 실행
|
||||||
|
|
||||||
|
### 기본 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 모든 테스트 실행
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Watch 모드 (파일 변경 시 자동 재실행)
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# 커버리지 리포트와 함께 실행
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# 특정 파일만 테스트
|
||||||
|
npm test -- tests/unit/services/workReportService.test.js
|
||||||
|
|
||||||
|
# 특정 describe 블록만 테스트
|
||||||
|
npm test -- --testNamePattern="createWorkReportService"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 커버리지 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# 커버리지 리포트 HTML 보기
|
||||||
|
open coverage/lcov-report/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**목표 커버리지:**
|
||||||
|
- 서비스 레이어: 80% 이상
|
||||||
|
- 컨트롤러: 70% 이상
|
||||||
|
- 전체: 75% 이상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 모범 사례
|
||||||
|
|
||||||
|
### 1. 테스트 이름 규칙
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
```javascript
|
||||||
|
it('should create work report when valid data is provided', async () => {});
|
||||||
|
it('should throw ValidationError when report_date is missing', async () => {});
|
||||||
|
it('should return 404 when work report not found', async () => {});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:**
|
||||||
|
```javascript
|
||||||
|
it('test1', async () => {});
|
||||||
|
it('works', async () => {});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. AAA 패턴 (Arrange-Act-Assert)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should ...', async () => {
|
||||||
|
// Arrange: 테스트 데이터 및 모킹 설정
|
||||||
|
const mockData = { ... };
|
||||||
|
service.method.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
// Act: 실제 테스트 대상 실행
|
||||||
|
const result = await functionUnderTest(params);
|
||||||
|
|
||||||
|
// Assert: 결과 검증
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
expect(service.method).toHaveBeenCalledWith(expectedParams);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 독립적인 테스트
|
||||||
|
|
||||||
|
각 테스트는 다른 테스트에 의존하지 않아야 합니다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks(); // 각 테스트 전에 모킹 초기화
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test 1', () => { /* ... */ });
|
||||||
|
it('test 2', () => { /* ... */ }); // test 1의 영향을 받지 않음
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 엣지 케이스 테스트
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('getWorkReportsByDateService', () => {
|
||||||
|
it('should handle empty date', async () => { /* ... */ });
|
||||||
|
it('should handle invalid date format', async () => { /* ... */ });
|
||||||
|
it('should handle null date', async () => { /* ... */ });
|
||||||
|
it('should handle future date', async () => { /* ... */ });
|
||||||
|
it('should return empty array when no reports found', async () => { /* ... */ });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 에러 케이스 테스트
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should throw ValidationError when required field is missing', async () => {
|
||||||
|
await expect(service.method(invalidData))
|
||||||
|
.rejects.toThrow(ValidationError);
|
||||||
|
|
||||||
|
await expect(service.method(invalidData))
|
||||||
|
.rejects.toThrow('보고서 데이터가 필요합니다');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 비동기 테스트
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Good: async/await 사용
|
||||||
|
it('should ...', async () => {
|
||||||
|
const result = await asyncFunction();
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Good: return Promise
|
||||||
|
it('should ...', () => {
|
||||||
|
return asyncFunction().then(result => {
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bad: Promise를 반환하지 않음
|
||||||
|
it('should ...', () => {
|
||||||
|
asyncFunction().then(result => {
|
||||||
|
expect(result).toBe(expected); // 실행되지 않을 수 있음!
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 모킹 복원
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('Service', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks(); // 모든 모킹 복원
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
### Phase 1: 서비스 레이어 테스트 (우선)
|
||||||
|
```bash
|
||||||
|
tests/unit/services/
|
||||||
|
├── workReportService.test.js ⭐ 시작하기 좋음
|
||||||
|
├── attendanceService.test.js
|
||||||
|
├── dailyWorkReportService.test.js
|
||||||
|
├── workerService.test.js
|
||||||
|
├── projectService.test.js
|
||||||
|
├── issueTypeService.test.js
|
||||||
|
├── toolsService.test.js
|
||||||
|
├── dailyIssueReportService.test.js
|
||||||
|
├── uploadService.test.js
|
||||||
|
└── analysisService.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 컨트롤러 테스트
|
||||||
|
```bash
|
||||||
|
tests/unit/controllers/
|
||||||
|
├── workReportController.test.js
|
||||||
|
├── attendanceController.test.js
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: 통합 테스트
|
||||||
|
```bash
|
||||||
|
tests/integration/
|
||||||
|
├── workReport.test.js
|
||||||
|
├── attendance.test.js
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 유용한 Jest 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 특정 파일만 watch
|
||||||
|
npm test -- --watch tests/unit/services/workReportService.test.js
|
||||||
|
|
||||||
|
# 실패한 테스트만 재실행
|
||||||
|
npm test -- --onlyFailures
|
||||||
|
|
||||||
|
# 병렬 실행 비활성화 (디버깅 시)
|
||||||
|
npm test -- --runInBand
|
||||||
|
|
||||||
|
# 상세 출력
|
||||||
|
npm test -- --verbose
|
||||||
|
|
||||||
|
# 특정 describe만 실행
|
||||||
|
npm test -- --testNamePattern="createWorkReportService"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 문제 1: "Cannot find module"
|
||||||
|
```bash
|
||||||
|
# node_modules 재설치
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 2: 타임아웃 에러
|
||||||
|
```javascript
|
||||||
|
// jest.config.js에서 타임아웃 증가
|
||||||
|
module.exports = {
|
||||||
|
testTimeout: 10000 // 10초
|
||||||
|
};
|
||||||
|
|
||||||
|
// 또는 개별 테스트에서
|
||||||
|
it('should ...', async () => {
|
||||||
|
// ...
|
||||||
|
}, 15000); // 15초
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 3: DB 연결 오류 (통합 테스트)
|
||||||
|
```javascript
|
||||||
|
// tests/setup.js에서 테스트 DB 설정
|
||||||
|
process.env.DB_HOST = 'localhost';
|
||||||
|
process.env.DB_NAME = 'test_database';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- [Jest 공식 문서](https://jestjs.io/docs/getting-started)
|
||||||
|
- [Supertest GitHub](https://github.com/visionmedia/supertest)
|
||||||
|
- [Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-12-11
|
||||||
|
**작성자**: TK-FB-Project Team
|
||||||
|
**버전**: 1.0
|
||||||
199
api.hyungi.net/DEPLOY.md
Normal file
199
api.hyungi.net/DEPLOY.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# API 서버 배포 가이드
|
||||||
|
|
||||||
|
## 자동 배포 (권장)
|
||||||
|
|
||||||
|
### 1. 배포 스크립트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api.hyungi.net
|
||||||
|
|
||||||
|
# 처음 한 번만: 실행 권한 부여
|
||||||
|
chmod +x deploy.sh
|
||||||
|
|
||||||
|
# 배포 실행
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
배포 스크립트는 다음을 자동으로 처리합니다:
|
||||||
|
1. ✅ Git Pull
|
||||||
|
2. ✅ NPM Install (package.json 변경 시)
|
||||||
|
3. ✅ 데이터베이스 마이그레이션 (확인 후 실행)
|
||||||
|
4. ✅ PM2 서버 재시작
|
||||||
|
5. ✅ 상태 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수동 배포
|
||||||
|
|
||||||
|
### 1. Git Pull
|
||||||
|
```bash
|
||||||
|
cd api.hyungi.net
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 의존성 설치 (package.json 변경 시)
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 데이터베이스 마이그레이션
|
||||||
|
|
||||||
|
⚠️ **중요**: 마이그레이션 전 데이터베이스 백업을 권장합니다!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 마이그레이션 실행
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# 마이그레이션 롤백 (문제 발생 시)
|
||||||
|
npm run db:rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. PM2 서버 재시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 무중단 재시작 (권장)
|
||||||
|
pm2 reload ecosystem.config.js --env production
|
||||||
|
|
||||||
|
# 또는 일반 재시작
|
||||||
|
pm2 restart hyungi-api
|
||||||
|
|
||||||
|
# 서버 중지 후 시작
|
||||||
|
pm2 stop hyungi-api
|
||||||
|
pm2 start ecosystem.config.js --env production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포 후 확인사항
|
||||||
|
|
||||||
|
### 1. 서버 상태 확인
|
||||||
|
```bash
|
||||||
|
# PM2 프로세스 목록
|
||||||
|
pm2 list
|
||||||
|
|
||||||
|
# 실시간 로그 확인
|
||||||
|
pm2 logs hyungi-api
|
||||||
|
|
||||||
|
# 에러 로그만 확인
|
||||||
|
pm2 logs hyungi-api --err
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API 응답 확인
|
||||||
|
```bash
|
||||||
|
# Health Check
|
||||||
|
curl http://localhost:20005/health
|
||||||
|
|
||||||
|
# 또는
|
||||||
|
curl http://api.hyungi.net/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 마이그레이션 상태 확인
|
||||||
|
```bash
|
||||||
|
# 현재 마이그레이션 버전 확인
|
||||||
|
npx knex migrate:currentVersion --knexfile knexfile.js
|
||||||
|
|
||||||
|
# 적용된 마이그레이션 목록
|
||||||
|
npx knex migrate:list --knexfile knexfile.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 해결
|
||||||
|
|
||||||
|
### 마이그레이션 실패 시
|
||||||
|
|
||||||
|
1. **에러 로그 확인**
|
||||||
|
```bash
|
||||||
|
pm2 logs hyungi-api --err
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **마이그레이션 롤백**
|
||||||
|
```bash
|
||||||
|
npm run db:rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **특정 마이그레이션만 실행**
|
||||||
|
```bash
|
||||||
|
npx knex migrate:up 20260119095549_add_worker_display_fields.js --knexfile knexfile.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서버 시작 실패 시
|
||||||
|
|
||||||
|
1. **포트 충돌 확인**
|
||||||
|
```bash
|
||||||
|
lsof -i :20005
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **PM2 프로세스 완전 삭제 후 재시작**
|
||||||
|
```bash
|
||||||
|
pm2 delete hyungi-api
|
||||||
|
pm2 start ecosystem.config.js --env production
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **환경변수 확인**
|
||||||
|
```bash
|
||||||
|
cat .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경별 배포
|
||||||
|
|
||||||
|
### Development (개발)
|
||||||
|
```bash
|
||||||
|
NODE_ENV=development npm run db:migrate
|
||||||
|
pm2 reload ecosystem.config.js --env development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (운영)
|
||||||
|
```bash
|
||||||
|
NODE_ENV=production npm run db:migrate
|
||||||
|
pm2 reload ecosystem.config.js --env production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터베이스 백업
|
||||||
|
|
||||||
|
### 백업 생성
|
||||||
|
```bash
|
||||||
|
# MySQL 백업
|
||||||
|
mysqldump -h DB_HOST -u DB_USER -p DB_NAME > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백업 복구
|
||||||
|
```bash
|
||||||
|
mysql -h DB_HOST -u DB_USER -p DB_NAME < backup_20260119_120000.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD 자동화 (향후 개선안)
|
||||||
|
|
||||||
|
GitHub Actions 또는 GitLab CI/CD를 사용한 자동 배포:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy.yml 예시
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: SSH and Deploy
|
||||||
|
run: |
|
||||||
|
ssh user@server 'cd /path/to/api.hyungi.net && ./deploy.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고사항
|
||||||
|
|
||||||
|
- **마이그레이션은 한 방향으로만 진행** (forward-only)
|
||||||
|
- **rollback은 개발 환경에서만 사용 권장**
|
||||||
|
- **운영 환경에서는 반드시 백업 후 마이그레이션**
|
||||||
|
- **PM2 reload는 무중단 재시작** (downtime 없음)
|
||||||
89
api.hyungi.net/config/cors.js
Normal file
89
api.hyungi.net/config/cors.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* CORS 설정
|
||||||
|
*
|
||||||
|
* Cross-Origin Resource Sharing 설정
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용된 Origin 목록
|
||||||
|
*/
|
||||||
|
const allowedOrigins = [
|
||||||
|
'http://localhost:20000', // 웹 UI
|
||||||
|
'http://localhost:3005', // API 서버
|
||||||
|
'http://localhost:3000', // 개발 포트
|
||||||
|
'http://127.0.0.1:20000', // 로컬호스트 대체
|
||||||
|
'http://127.0.0.1:3005',
|
||||||
|
'http://127.0.0.1:3000'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 설정 옵션
|
||||||
|
*/
|
||||||
|
const corsOptions = {
|
||||||
|
/**
|
||||||
|
* Origin 검증 함수
|
||||||
|
*/
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
// Origin이 없는 경우 (직접 접근, Postman 등)
|
||||||
|
if (!origin) {
|
||||||
|
logger.debug('CORS: Origin 없음 - 허용');
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 허용된 Origin 확인
|
||||||
|
if (allowedOrigins.includes(origin)) {
|
||||||
|
logger.debug('CORS: 허용된 Origin', { origin });
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개발 환경에서는 모든 localhost 허용
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||||
|
logger.debug('CORS: 로컬호스트 허용 (개발 모드)', { origin });
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 네트워크 IP 자동 허용 (192.168.x.x)
|
||||||
|
if (origin.match(/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/)) {
|
||||||
|
logger.debug('CORS: 로컬 네트워크 IP 허용', { origin });
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차단
|
||||||
|
logger.warn('CORS: 차단된 Origin', { origin });
|
||||||
|
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 정보 포함 허용
|
||||||
|
*/
|
||||||
|
credentials: true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용된 HTTP 메소드
|
||||||
|
*/
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용된 헤더
|
||||||
|
*/
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노출할 헤더
|
||||||
|
*/
|
||||||
|
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preflight 요청 캐시 시간 (초)
|
||||||
|
*/
|
||||||
|
maxAge: 86400 // 24시간
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = corsOptions;
|
||||||
79
api.hyungi.net/config/database.js
Normal file
79
api.hyungi.net/config/database.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 데이터베이스 연결 설정
|
||||||
|
*
|
||||||
|
* MySQL/MariaDB 커넥션 풀 관리
|
||||||
|
* - 환경 변수 기반 설정
|
||||||
|
* - 자동 재연결 (최대 5회 재시도)
|
||||||
|
* - UTF-8MB4 문자셋 지원
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const retry = require('async-retry');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
let pool = null;
|
||||||
|
|
||||||
|
async function initPool() {
|
||||||
|
if (pool) return pool;
|
||||||
|
|
||||||
|
const {
|
||||||
|
DB_HOST, DB_PORT, DB_USER,
|
||||||
|
DB_PASSWORD, DB_NAME,
|
||||||
|
DB_SOCKET, DB_CONN_LIMIT = '10'
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
if (!DB_USER || !DB_PASSWORD || !DB_NAME) {
|
||||||
|
throw new Error('필수 환경변수(DB_USER, DB_PASSWORD, DB_NAME)가 없습니다.');
|
||||||
|
}
|
||||||
|
if (!DB_SOCKET && !DB_HOST) {
|
||||||
|
throw new Error('DB_SOCKET이 없으면 DB_HOST가 반드시 필요합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await retry(async () => {
|
||||||
|
const config = {
|
||||||
|
user: DB_USER,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
database: DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: parseInt(DB_CONN_LIMIT, 10),
|
||||||
|
queueLimit: 0,
|
||||||
|
charset: 'utf8mb4'
|
||||||
|
};
|
||||||
|
if (DB_SOCKET) {
|
||||||
|
config.socketPath = DB_SOCKET;
|
||||||
|
} else {
|
||||||
|
config.host = DB_HOST;
|
||||||
|
config.port = parseInt(DB_PORT, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = mysql.createPool(config);
|
||||||
|
|
||||||
|
// 첫 연결 검증
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
await conn.query('SET NAMES utf8mb4');
|
||||||
|
conn.release();
|
||||||
|
|
||||||
|
const connectionInfo = DB_SOCKET ? `socket=${DB_SOCKET}` : `${DB_HOST}:${DB_PORT}`;
|
||||||
|
logger.info('MariaDB 연결 성공', {
|
||||||
|
connection: connectionInfo,
|
||||||
|
database: DB_NAME,
|
||||||
|
connectionLimit: parseInt(DB_CONN_LIMIT, 10)
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
retries: 5,
|
||||||
|
factor: 2,
|
||||||
|
minTimeout: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDb() {
|
||||||
|
return initPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getDb };
|
||||||
115
api.hyungi.net/config/middleware.js
Normal file
115
api.hyungi.net/config/middleware.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 미들웨어 설정
|
||||||
|
*
|
||||||
|
* Express 애플리케이션의 모든 미들웨어를 등록하는 중앙화된 설정 파일
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const compression = require('compression');
|
||||||
|
const path = require('path');
|
||||||
|
const helmetOptions = require('./security');
|
||||||
|
const corsOptions = require('./cors');
|
||||||
|
const { responseMiddleware } = require('../utils/responseFormatter');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 미들웨어를 Express 앱에 등록
|
||||||
|
* @param {Express.Application} app - Express 애플리케이션 인스턴스
|
||||||
|
*/
|
||||||
|
function setupMiddlewares(app) {
|
||||||
|
// 보안 헤더 설정 (Helmet)
|
||||||
|
app.use(helmet(helmetOptions));
|
||||||
|
|
||||||
|
// 성능 최적화 - Compression
|
||||||
|
app.use(compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers['x-no-compression']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
level: 6, // 압축 레벨 (1-9, 6이 기본값)
|
||||||
|
threshold: 1024 // 1KB 이상만 압축
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 요청 바디 파싱 - 용량 제한 확장
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
|
// 응답 포맷터 미들웨어
|
||||||
|
app.use(responseMiddleware);
|
||||||
|
|
||||||
|
// CORS 설정
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// 정적 파일 서빙
|
||||||
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||||
|
|
||||||
|
// Rate Limiting - API 요청 제한
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
// 일반 API 요청 제한
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
|
max: 1000, // IP당 최대 1000 요청 (일괄 처리 지원)
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
// 인증된 사용자는 더 많은 요청 허용
|
||||||
|
skip: (req) => {
|
||||||
|
// Authorization 헤더가 있으면 Rate Limit 완화
|
||||||
|
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그인 시도 제한 (브루트포스 방지)
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
|
max: 10, // IP당 최대 10회 로그인 시도
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: '로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.',
|
||||||
|
code: 'LOGIN_RATE_LIMIT_EXCEEDED'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiter 적용
|
||||||
|
app.use('/api/', apiLimiter);
|
||||||
|
app.use('/api/auth/login', loginLimiter);
|
||||||
|
|
||||||
|
logger.info('Rate Limiting 설정 완료');
|
||||||
|
|
||||||
|
// CSRF Protection (선택적 - 필요 시 주석 해제)
|
||||||
|
// const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
|
||||||
|
//
|
||||||
|
// CSRF 토큰 발급 엔드포인트
|
||||||
|
// app.get('/api/csrf-token', getCsrfToken);
|
||||||
|
//
|
||||||
|
// CSRF 검증 미들웨어 (로그인 등 일부 경로 제외)
|
||||||
|
// app.use('/api/', verifyCsrfToken({
|
||||||
|
// ignorePaths: [
|
||||||
|
// '/api/auth/login',
|
||||||
|
// '/api/auth/register',
|
||||||
|
// '/api/health',
|
||||||
|
// '/api/csrf-token'
|
||||||
|
// ]
|
||||||
|
// }));
|
||||||
|
//
|
||||||
|
// logger.info('CSRF Protection 설정 완료');
|
||||||
|
|
||||||
|
logger.info('미들웨어 설정 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = setupMiddlewares;
|
||||||
192
api.hyungi.net/config/routes.js
Normal file
192
api.hyungi.net/config/routes.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* 라우트 설정
|
||||||
|
*
|
||||||
|
* 애플리케이션의 모든 라우트를 등록하는 중앙화된 설정 파일
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const swaggerUi = require('swagger-ui-express');
|
||||||
|
const swaggerSpec = require('./swagger');
|
||||||
|
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||||
|
const { activityLogger } = require('../middlewares/activityLogger');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 라우트를 Express 앱에 등록
|
||||||
|
* @param {Express.Application} app - Express 애플리케이션 인스턴스
|
||||||
|
*/
|
||||||
|
function setupRoutes(app) {
|
||||||
|
// 라우터 가져오기
|
||||||
|
const authRoutes = require('../routes/authRoutes');
|
||||||
|
const projectRoutes = require('../routes/projectRoutes');
|
||||||
|
const workerRoutes = require('../routes/workerRoutes');
|
||||||
|
const workReportRoutes = require('../routes/workReportRoutes');
|
||||||
|
const toolsRoute = require('../routes/toolsRoute');
|
||||||
|
const uploadRoutes = require('../routes/uploadRoutes');
|
||||||
|
const uploadBgRoutes = require('../routes/uploadBgRoutes');
|
||||||
|
const dailyIssueReportRoutes = require('../routes/dailyIssueReportRoutes');
|
||||||
|
const issueTypeRoutes = require('../routes/issueTypeRoutes');
|
||||||
|
const healthRoutes = require('../routes/healthRoutes');
|
||||||
|
const dailyWorkReportRoutes = require('../routes/dailyWorkReportRoutes');
|
||||||
|
const workAnalysisRoutes = require('../routes/workAnalysisRoutes');
|
||||||
|
const analysisRoutes = require('../routes/analysisRoutes');
|
||||||
|
const systemRoutes = require('../routes/systemRoutes');
|
||||||
|
const performanceRoutes = require('../routes/performanceRoutes');
|
||||||
|
const userRoutes = require('../routes/userRoutes');
|
||||||
|
const setupRoutes = require('../routes/setupRoutes');
|
||||||
|
const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
|
||||||
|
const attendanceRoutes = require('../routes/attendanceRoutes');
|
||||||
|
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
|
||||||
|
const pageAccessRoutes = require('../routes/pageAccessRoutes');
|
||||||
|
const workplaceRoutes = require('../routes/workplaceRoutes');
|
||||||
|
const equipmentRoutes = require('../routes/equipmentRoutes');
|
||||||
|
const taskRoutes = require('../routes/taskRoutes');
|
||||||
|
const tbmRoutes = require('../routes/tbmRoutes');
|
||||||
|
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
|
||||||
|
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
||||||
|
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
||||||
|
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
||||||
|
const workIssueRoutes = require('../routes/workIssueRoutes');
|
||||||
|
const departmentRoutes = require('../routes/departmentRoutes');
|
||||||
|
const patrolRoutes = require('../routes/patrolRoutes');
|
||||||
|
const notificationRoutes = require('../routes/notificationRoutes');
|
||||||
|
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
|
||||||
|
|
||||||
|
// Rate Limiters 설정
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
|
max: 5, // 최대 5회
|
||||||
|
message: '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000, // 1분
|
||||||
|
max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
|
||||||
|
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
// 관리자 및 시스템 계정은 rate limit 제외
|
||||||
|
skip: (req) => {
|
||||||
|
// 인증된 사용자 정보 확인
|
||||||
|
if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
|
||||||
|
return true; // rate limit 건너뛰기
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 API 요청에 활동 로거 적용
|
||||||
|
app.use('/api/*', activityLogger);
|
||||||
|
|
||||||
|
// 인증 불필요 경로 - 로그인
|
||||||
|
app.use('/api/auth', loginLimiter, authRoutes);
|
||||||
|
|
||||||
|
// DB 설정 라우트 (개발용)
|
||||||
|
app.use('/api/setup', setupRoutes);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.use('/api/health', healthRoutes);
|
||||||
|
|
||||||
|
// 인증이 필요 없는 공개 경로 목록
|
||||||
|
const publicPaths = [
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/refresh-token',
|
||||||
|
'/api/auth/check-password-strength',
|
||||||
|
'/api/health',
|
||||||
|
'/api/ping',
|
||||||
|
'/api/status',
|
||||||
|
'/api/setup/setup-attendance-db',
|
||||||
|
'/api/setup/setup-monthly-status',
|
||||||
|
'/api/setup/add-overtime-warning',
|
||||||
|
'/api/setup/migrate-existing-data',
|
||||||
|
'/api/setup/check-data-status',
|
||||||
|
'/api/monthly-status/calendar',
|
||||||
|
'/api/monthly-status/daily-details',
|
||||||
|
'/api/migrate-work-type-id', // 임시 마이그레이션 - 실행 후 삭제!
|
||||||
|
'/api/diagnose-work-type-id', // 임시 진단 - 실행 후 삭제!
|
||||||
|
'/api/test-analysis' // 임시 분석 테스트 - 실행 후 삭제!
|
||||||
|
];
|
||||||
|
|
||||||
|
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
|
||||||
|
app.use('/api/*', (req, res, next) => {
|
||||||
|
const isPublicPath = publicPaths.some(path => {
|
||||||
|
return req.originalUrl === path ||
|
||||||
|
req.originalUrl.startsWith(path + '?') ||
|
||||||
|
req.originalUrl.startsWith(path + '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPublicPath) {
|
||||||
|
logger.debug('공개 경로 허용', { url: req.originalUrl });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('인증 필요 경로', { url: req.originalUrl });
|
||||||
|
verifyToken(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
|
||||||
|
app.use('/api/', apiLimiter);
|
||||||
|
|
||||||
|
// 인증된 사용자만 접근 가능한 라우트들
|
||||||
|
app.use('/api/issue-reports', dailyIssueReportRoutes);
|
||||||
|
app.use('/api/issue-types', issueTypeRoutes);
|
||||||
|
app.use('/api/workers', workerRoutes);
|
||||||
|
app.use('/api/daily-work-reports', dailyWorkReportRoutes);
|
||||||
|
app.use('/api/work-analysis', workAnalysisRoutes);
|
||||||
|
app.use('/api/analysis', analysisRoutes);
|
||||||
|
app.use('/api/daily-work-reports-analysis', workReportAnalysisRoutes);
|
||||||
|
app.use('/api/attendance', attendanceRoutes);
|
||||||
|
app.use('/api/monthly-status', monthlyStatusRoutes);
|
||||||
|
app.use('/api/workreports', workReportRoutes);
|
||||||
|
app.use('/api/system', systemRoutes);
|
||||||
|
app.use('/api/uploads', uploadRoutes);
|
||||||
|
app.use('/api/performance', performanceRoutes);
|
||||||
|
app.use('/api/projects', projectRoutes);
|
||||||
|
app.use('/api/tools', toolsRoute);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/workplaces', workplaceRoutes);
|
||||||
|
app.use('/api/equipments', equipmentRoutes);
|
||||||
|
app.use('/api/tasks', taskRoutes);
|
||||||
|
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
|
||||||
|
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||||
|
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||||
|
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||||
|
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
||||||
|
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||||
|
app.use('/api/work-issues', workIssueRoutes); // 문제 신고 시스템
|
||||||
|
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||||
|
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||||
|
app.use('/api/notifications', notificationRoutes); // 알림 시스템
|
||||||
|
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
|
||||||
|
app.use('/api', uploadBgRoutes);
|
||||||
|
|
||||||
|
// Swagger API 문서
|
||||||
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||||
|
explorer: true,
|
||||||
|
customCss: '.swagger-ui .topbar { display: none }',
|
||||||
|
customSiteTitle: 'TK Work Management API',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
docExpansion: 'none',
|
||||||
|
filter: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api-docs.json', (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.send(swaggerSpec);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('라우트 설정 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = setupRoutes;
|
||||||
101
api.hyungi.net/config/security.js
Normal file
101
api.hyungi.net/config/security.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 보안 설정 (Helmet)
|
||||||
|
*
|
||||||
|
* HTTP 헤더 보안 설정
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helmet 보안 설정 옵션
|
||||||
|
*/
|
||||||
|
const helmetOptions = {
|
||||||
|
/**
|
||||||
|
* Content Security Policy
|
||||||
|
* XSS 공격 방지
|
||||||
|
*/
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // 개발 중 unsafe-eval 허용
|
||||||
|
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
||||||
|
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||||
|
connectSrc: ["'self'", "https://api.technicalkorea.com"],
|
||||||
|
frameSrc: ["'none'"],
|
||||||
|
objectSrc: ["'none'"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Strict Transport Security (HSTS)
|
||||||
|
* HTTPS 강제 사용
|
||||||
|
*/
|
||||||
|
hsts: {
|
||||||
|
maxAge: 31536000, // 1년
|
||||||
|
includeSubDomains: true,
|
||||||
|
preload: true
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-Frame-Options
|
||||||
|
* 클릭재킹 공격 방지
|
||||||
|
*/
|
||||||
|
frameguard: {
|
||||||
|
action: 'deny'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-Content-Type-Options
|
||||||
|
* MIME 타입 스니핑 방지
|
||||||
|
*/
|
||||||
|
noSniff: true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-XSS-Protection
|
||||||
|
* XSS 필터 활성화
|
||||||
|
*/
|
||||||
|
xssFilter: true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referrer-Policy
|
||||||
|
* 리퍼러 정보 제어
|
||||||
|
*/
|
||||||
|
referrerPolicy: {
|
||||||
|
policy: 'strict-origin-when-cross-origin'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-DNS-Prefetch-Control
|
||||||
|
* DNS prefetching 제어
|
||||||
|
*/
|
||||||
|
dnsPrefetchControl: {
|
||||||
|
allow: false
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-Download-Options
|
||||||
|
* IE8+ 다운로드 옵션
|
||||||
|
*/
|
||||||
|
ieNoOpen: true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-Permitted-Cross-Domain-Policies
|
||||||
|
* Adobe 제품의 크로스 도메인 정책
|
||||||
|
*/
|
||||||
|
permittedCrossDomainPolicies: {
|
||||||
|
permittedPolicies: 'none'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-Origin-Resource-Policy
|
||||||
|
* 크로스 오리진 리소스 공유 설정
|
||||||
|
* 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용
|
||||||
|
*/
|
||||||
|
crossOriginResourcePolicy: {
|
||||||
|
policy: 'cross-origin'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = helmetOptions;
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
// /controllers/analysisController.js
|
/**
|
||||||
|
* 프로젝트 분석 컨트롤러
|
||||||
|
*
|
||||||
|
* 기간별 프로젝트 분석 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const analysisService = require('../services/analysisService');
|
const analysisService = require('../services/analysisService');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로젝트 분석 데이터를 조회하는 API 요청을 처리합니다.
|
* 프로젝트 분석 데이터 조회
|
||||||
*/
|
*/
|
||||||
const getAnalysisData = async (req, res) => {
|
const getAnalysisData = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { startDate, endDate } = req.query;
|
||||||
const { startDate, endDate } = req.query;
|
const data = await analysisService.getAnalysisService(startDate, endDate);
|
||||||
|
|
||||||
const data = await analysisService.getAnalysisService(startDate, endDate);
|
res.json({
|
||||||
|
success: true,
|
||||||
res.json(data);
|
data,
|
||||||
} catch (err) {
|
message: '분석 데이터 조회 성공'
|
||||||
console.error('💥 분석 데이터 컨트롤러 오류:', err);
|
});
|
||||||
res.status(400).json({ success: false, error: err.message });
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAnalysisData,
|
getAnalysisData
|
||||||
};
|
};
|
||||||
|
|||||||
212
api.hyungi.net/controllers/attendanceController.js
Normal file
212
api.hyungi.net/controllers/attendanceController.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 근태 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 근태 기록 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const attendanceService = require('../services/attendanceService');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 근태 현황 조회 (대시보드용)
|
||||||
|
*/
|
||||||
|
const getDailyAttendanceStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { date } = req.query;
|
||||||
|
const data = await attendanceService.getDailyAttendanceStatusService(date);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '근태 현황을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 근태 기록 조회
|
||||||
|
*/
|
||||||
|
const getDailyAttendanceRecords = asyncHandler(async (req, res) => {
|
||||||
|
const { date, worker_id } = req.query;
|
||||||
|
const data = await attendanceService.getDailyAttendanceRecordsService(date, worker_id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '근태 기록을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간별 근태 기록 조회 (월별 조회용)
|
||||||
|
*/
|
||||||
|
const getAttendanceRecordsByRange = asyncHandler(async (req, res) => {
|
||||||
|
const { start_date, end_date, worker_id } = req.query;
|
||||||
|
const data = await attendanceService.getAttendanceRecordsByRangeService(start_date, end_date, worker_id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '근태 기록을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근태 기록 생성/업데이트
|
||||||
|
*/
|
||||||
|
const upsertAttendanceRecord = asyncHandler(async (req, res) => {
|
||||||
|
const recordData = {
|
||||||
|
...req.body,
|
||||||
|
created_by: req.user?.user_id || req.user?.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await attendanceService.upsertAttendanceRecordService(recordData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '근태 기록이 성공적으로 저장되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 처리
|
||||||
|
*/
|
||||||
|
const processVacation = asyncHandler(async (req, res) => {
|
||||||
|
const vacationData = {
|
||||||
|
record_date: req.body.date,
|
||||||
|
worker_id: req.body.worker_id,
|
||||||
|
vacation_type_id: req.body.vacation_type,
|
||||||
|
created_by: req.user?.user_id || req.user?.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await attendanceService.processVacationService(vacationData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '휴가 처리가 성공적으로 완료되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초과근무 승인
|
||||||
|
*/
|
||||||
|
const approveOvertime = asyncHandler(async (req, res) => {
|
||||||
|
const overtimeData = {
|
||||||
|
record_date: req.body.date,
|
||||||
|
worker_id: req.body.worker_id,
|
||||||
|
overtime_approved: true,
|
||||||
|
approved_by: req.user?.user_id || req.user?.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await attendanceService.approveOvertimeService(overtimeData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '초과근무가 성공적으로 승인되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근로 유형 목록 조회
|
||||||
|
*/
|
||||||
|
const getAttendanceTypes = asyncHandler(async (req, res) => {
|
||||||
|
const data = await attendanceService.getAttendanceTypesService();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '근로 유형 목록을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 유형 목록 조회
|
||||||
|
*/
|
||||||
|
const getVacationTypes = asyncHandler(async (req, res) => {
|
||||||
|
const data = await attendanceService.getVacationTypesService();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '휴가 유형 목록을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 휴가 잔여 조회
|
||||||
|
*/
|
||||||
|
const getWorkerVacationBalance = asyncHandler(async (req, res) => {
|
||||||
|
const { worker_id } = req.params;
|
||||||
|
const data = await attendanceService.getWorkerVacationBalanceService(parseInt(worker_id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '휴가 잔여 정보를 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 근태 통계
|
||||||
|
*/
|
||||||
|
const getMonthlyAttendanceStats = asyncHandler(async (req, res) => {
|
||||||
|
const { year, month, worker_id } = req.query;
|
||||||
|
const data = await attendanceService.getMonthlyAttendanceStatsService(
|
||||||
|
parseInt(year),
|
||||||
|
parseInt(month),
|
||||||
|
worker_id ? parseInt(worker_id) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '월별 근태 통계를 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
|
||||||
|
*/
|
||||||
|
const getCheckinList = asyncHandler(async (req, res) => {
|
||||||
|
const { date } = req.query;
|
||||||
|
const data = await attendanceService.getCheckinListService(date);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '출근 체크 목록을 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출근 체크 저장 (일괄 처리)
|
||||||
|
*/
|
||||||
|
const saveCheckins = asyncHandler(async (req, res) => {
|
||||||
|
const { date, checkins } = req.body; // checkins: [{worker_id, is_present}, ...]
|
||||||
|
const result = await attendanceService.saveCheckinsService(date, checkins);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '출근 체크가 성공적으로 저장되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDailyAttendanceStatus,
|
||||||
|
getDailyAttendanceRecords,
|
||||||
|
getAttendanceRecordsByRange,
|
||||||
|
upsertAttendanceRecord,
|
||||||
|
processVacation,
|
||||||
|
approveOvertime,
|
||||||
|
getAttendanceTypes,
|
||||||
|
getVacationTypes,
|
||||||
|
getWorkerVacationBalance,
|
||||||
|
getMonthlyAttendanceStats,
|
||||||
|
getCheckinList,
|
||||||
|
saveCheckins
|
||||||
|
};
|
||||||
@@ -2,7 +2,8 @@ const { getDb } = require('../dbPool');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const authService = require('../services/auth.service');
|
const authService = require('../services/auth.service');
|
||||||
const { ApiError, asyncHandler } = require('../utils/errorHandler');
|
const { asyncHandler } = require('../utils/errorHandler');
|
||||||
|
const { AuthenticationError, ValidationError } = require('../utils/errors');
|
||||||
const { validateSchema, schemas } = require('../utils/validator');
|
const { validateSchema, schemas } = require('../utils/validator');
|
||||||
|
|
||||||
const login = asyncHandler(async (req, res) => {
|
const login = asyncHandler(async (req, res) => {
|
||||||
@@ -12,47 +13,47 @@ const login = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
// 유효성 검사
|
// 유효성 검사
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
throw new ApiError('사용자명과 비밀번호를 입력해주세요.', 400);
|
throw new ValidationError('사용자명과 비밀번호를 입력해주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.loginService(username, password, ipAddress, userAgent);
|
const result = await authService.loginService(username, password, ipAddress, userAgent);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new ApiError(result.error, result.status || 400);
|
throw new AuthenticationError(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일
|
// 로그인 성공 후, 메인 대시보드로 리다이렉트
|
||||||
const user = result.data.user;
|
const user = result.data.user;
|
||||||
const redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트
|
const redirectUrl = '/pages/dashboard.html'; // 메인 대시보드로 리다이렉트
|
||||||
|
|
||||||
// 새로운 응답 포맷터 사용
|
// 새로운 응답 포맷터 사용
|
||||||
res.auth(user, result.data.token, redirectUrl, '로그인 성공');
|
res.auth(user, result.data.token, redirectUrl, '로그인 성공');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✅ 사용자 등록 기능 추가
|
// ✅ 사용자 등록 기능 추가
|
||||||
exports.register = async (req, res) => {
|
const register = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password, name, access_level, worker_id } = req.body;
|
const { username, password, name, access_level, worker_id } = req.body;
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!username || !password || !name || !access_level) {
|
if (!username || !password || !name || !access_level) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '필수 정보가 누락되었습니다.'
|
error: '필수 정보가 누락되었습니다.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 아이디 확인
|
// 중복 아이디 확인
|
||||||
const [existing] = await db.query(
|
const [existing] = await db.query(
|
||||||
'SELECT user_id FROM Users WHERE username = ?',
|
'SELECT user_id FROM users WHERE username = ?',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '이미 존재하는 아이디입니다.'
|
error: '이미 존재하는 아이디입니다.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,17 +72,17 @@ exports.register = async (req, res) => {
|
|||||||
|
|
||||||
// 사용자 등록
|
// 사용자 등록
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO Users (username, password, name, role, access_level, worker_id)
|
`INSERT INTO users (username, password, name, role, access_level, worker_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
[username, hashedPassword, name, role, access_level, worker_id]
|
[username, hashedPassword, name, role, access_level, worker_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[사용자 등록 성공]', username);
|
console.log('[사용자 등록 성공]', username);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '사용자 등록이 완료되었습니다.',
|
message: '사용자 등록이 완료되었습니다.',
|
||||||
user_id: result.insertId
|
user_id: result.insertId
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -95,32 +96,32 @@ exports.register = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ✅ 사용자 삭제 기능 추가
|
// ✅ 사용자 삭제 기능 추가
|
||||||
exports.deleteUser = async (req, res) => {
|
const deleteUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 사용자 존재 확인
|
// 사용자 존재 확인
|
||||||
const [user] = await db.query(
|
const [user] = await db.query(
|
||||||
'SELECT user_id FROM Users WHERE user_id = ?',
|
'SELECT user_id FROM users WHERE user_id = ?',
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user.length === 0) {
|
if (user.length === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '해당 사용자를 찾을 수 없습니다.'
|
error: '해당 사용자를 찾을 수 없습니다.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 삭제
|
// 사용자 삭제
|
||||||
await db.query('DELETE FROM Users WHERE user_id = ?', [id]);
|
await db.query('DELETE FROM users WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
console.log('[사용자 삭제 성공] ID:', id);
|
console.log('[사용자 삭제 성공] ID:', id);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '사용자가 삭제되었습니다.'
|
message: '사용자가 삭제되었습니다.'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -134,17 +135,17 @@ exports.deleteUser = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 모든 사용자 목록 조회
|
// 모든 사용자 목록 조회
|
||||||
exports.getAllUsers = async (req, res) => {
|
const getAllUsers = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 비밀번호 제외하고 조회
|
// 비밀번호 제외하고 조회
|
||||||
const [rows] = await db.query(
|
const [rows] = await db.query(
|
||||||
`SELECT user_id, username, name, role, access_level, worker_id, created_at
|
`SELECT user_id, username, name, role, access_level, worker_id, created_at
|
||||||
FROM Users
|
FROM users
|
||||||
ORDER BY created_at DESC`
|
ORDER BY created_at DESC`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).json(rows);
|
res.status(200).json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[사용자 목록 조회 실패]', err);
|
console.error('[사용자 목록 조회 실패]', err);
|
||||||
@@ -153,5 +154,8 @@ exports.getAllUsers = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login
|
login,
|
||||||
|
register,
|
||||||
|
deleteUser,
|
||||||
|
getAllUsers
|
||||||
};
|
};
|
||||||
@@ -1,58 +1,64 @@
|
|||||||
// /controllers/dailyIssueReportController.js
|
/**
|
||||||
|
* 일일 이슈 보고서 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 일일 이슈 보고서 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const dailyIssueReportService = require('../services/dailyIssueReportService');
|
const dailyIssueReportService = require('../services/dailyIssueReportService');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CREATE: 일일 이슈 보고서 생성 (Service Layer 사용)
|
* 일일 이슈 보고서 생성
|
||||||
*/
|
*/
|
||||||
const createDailyIssueReport = async (req, res) => {
|
const createDailyIssueReport = asyncHandler(async (req, res) => {
|
||||||
try {
|
// 프론트엔드에서 worker_ids 또는 worker_id로 보낼 수 있음
|
||||||
// 프론트엔드에서 worker_ids로 보내주기로 약속함
|
const issueData = {
|
||||||
const issueData = { ...req.body, worker_ids: req.body.worker_ids || req.body.worker_id };
|
...req.body,
|
||||||
|
worker_ids: req.body.worker_ids || req.body.worker_id
|
||||||
const result = await dailyIssueReportService.createDailyIssueReportService(issueData);
|
};
|
||||||
|
|
||||||
res.status(201).json({ success: true, ...result });
|
const result = await dailyIssueReportService.createDailyIssueReportService(issueData);
|
||||||
} catch (err) {
|
|
||||||
console.error('💥 이슈 보고서 생성 컨트롤러 오류:', err);
|
res.status(201).json({
|
||||||
res.status(400).json({ success: false, error: err.message });
|
success: true,
|
||||||
}
|
data: result,
|
||||||
};
|
message: result.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. READ BY DATE: 날짜별 이슈 조회 (Service Layer 사용)
|
* 날짜별 이슈 조회
|
||||||
*/
|
*/
|
||||||
const getDailyIssuesByDate = async (req, res) => {
|
const getDailyIssuesByDate = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { date } = req.query;
|
||||||
const { date } = req.query;
|
const issues = await dailyIssueReportService.getDailyIssuesByDateService(date);
|
||||||
const issues = await dailyIssueReportService.getDailyIssuesByDateService(date);
|
|
||||||
res.json(issues);
|
res.json({
|
||||||
} catch (err) {
|
success: true,
|
||||||
console.error('💥 이슈 보고서 조회 컨트롤러 오류:', err);
|
data: issues,
|
||||||
res.status(500).json({ success: false, error: err.message });
|
message: '이슈 보고서 조회 성공'
|
||||||
}
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. DELETE: 이슈 보고서 삭제 (Service Layer 사용)
|
* 이슈 보고서 삭제
|
||||||
*/
|
*/
|
||||||
const removeDailyIssue = async (req, res) => {
|
const removeDailyIssue = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { id } = req.params;
|
||||||
const { id } = req.params;
|
const result = await dailyIssueReportService.removeDailyIssueService(id);
|
||||||
const result = await dailyIssueReportService.removeDailyIssueService(id);
|
|
||||||
res.json({ success: true, ...result });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('💥 이슈 보고서 삭제 컨트롤러 오류:', err);
|
|
||||||
const statusCode = err.statusCode || 500;
|
|
||||||
res.status(statusCode).json({ success: false, error: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 레거시 함수들은 더 이상 라우팅되지 않으므로 제거하거나 주석 처리 가능
|
res.json({
|
||||||
// exports.getDailyIssueById = ...
|
success: true,
|
||||||
// exports.updateDailyIssue = ...
|
data: result,
|
||||||
|
message: result.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createDailyIssueReport,
|
createDailyIssueReport,
|
||||||
getDailyIssuesByDate,
|
getDailyIssuesByDate,
|
||||||
removeDailyIssue,
|
removeDailyIssue
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,750 +0,0 @@
|
|||||||
// controllers/dailyWorkReportController.js - 누적입력 방식 + 모든 기존 기능 포함
|
|
||||||
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📝 작업보고서 생성 (누적 방식 - 덮어쓰기 없음!)
|
|
||||||
*/
|
|
||||||
const createDailyWorkReport = (req, res) => {
|
|
||||||
const { report_date, worker_id, work_entries } = req.body;
|
|
||||||
const created_by = req.user?.user_id || req.user?.id;
|
|
||||||
const created_by_name = req.user?.name || req.user?.username || '알 수 없는 사용자';
|
|
||||||
|
|
||||||
// 1. 기본 유효성 검사
|
|
||||||
if (!report_date || !worker_id || !work_entries) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: '필수 필드가 누락되었습니다.',
|
|
||||||
required: ['report_date', 'worker_id', 'work_entries'],
|
|
||||||
received: {
|
|
||||||
report_date: !!report_date,
|
|
||||||
worker_id: !!worker_id,
|
|
||||||
work_entries: !!work_entries
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(work_entries) || work_entries.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: '최소 하나의 작업 항목이 필요합니다.',
|
|
||||||
received_entries: work_entries?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!created_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 작업 항목 유효성 검사
|
|
||||||
for (let i = 0; i < work_entries.length; i++) {
|
|
||||||
const entry = work_entries[i];
|
|
||||||
const requiredFields = ['project_id', 'work_type_id', 'work_status_id', 'work_hours'];
|
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `작업 항목 ${i + 1}의 ${field}가 누락되었습니다.`,
|
|
||||||
entry_index: i,
|
|
||||||
missing_field: field
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 에러 상태인 경우 에러 타입 필수
|
|
||||||
if (entry.work_status_id === 2 && (!entry.error_type_id)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `작업 항목 ${i + 1}이 에러 상태인 경우 error_type_id가 필요합니다.`,
|
|
||||||
entry_index: i
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시간 유효성 검사
|
|
||||||
const hours = parseFloat(entry.work_hours);
|
|
||||||
if (isNaN(hours) || hours < 0 || hours > 24) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `작업 항목 ${i + 1}의 작업시간이 유효하지 않습니다. (0-24시간)`,
|
|
||||||
entry_index: i,
|
|
||||||
received_hours: entry.work_hours
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 총 시간 계산
|
|
||||||
const total_hours = work_entries.reduce((sum, entry) => sum + (parseFloat(entry.work_hours) || 0), 0);
|
|
||||||
|
|
||||||
// 4. 요청 데이터 구성
|
|
||||||
const reportData = {
|
|
||||||
report_date,
|
|
||||||
worker_id: parseInt(worker_id),
|
|
||||||
work_entries,
|
|
||||||
created_by,
|
|
||||||
created_by_name,
|
|
||||||
total_hours,
|
|
||||||
is_update: false
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📝 작업보고서 누적 추가 요청:', {
|
|
||||||
date: report_date,
|
|
||||||
worker: worker_id,
|
|
||||||
creator: created_by_name,
|
|
||||||
creator_id: created_by,
|
|
||||||
entries: work_entries.length,
|
|
||||||
total_hours
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. 누적 추가 실행 (덮어쓰기 없음!)
|
|
||||||
dailyWorkReportModel.createDailyReport(reportData, (err, result) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 생성 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 생성 중 오류가 발생했습니다.',
|
|
||||||
details: err.message,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ 작업보고서 누적 추가 성공:', result);
|
|
||||||
res.status(201).json({
|
|
||||||
message: '작업보고서가 성공적으로 누적 추가되었습니다.',
|
|
||||||
report_date,
|
|
||||||
worker_id,
|
|
||||||
created_by: created_by_name,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...result
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 누적 현황 조회 (새로운 기능)
|
|
||||||
*/
|
|
||||||
const getAccumulatedReports = (req, res) => {
|
|
||||||
const { date, worker_id } = req.query;
|
|
||||||
|
|
||||||
if (!date || !worker_id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'date와 worker_id가 필요합니다.',
|
|
||||||
example: 'date=2024-06-16&worker_id=1'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 누적 현황 조회: date=${date}, worker_id=${worker_id}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getAccumulatedReportsByDate(date, worker_id, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('누적 현황 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '누적 현황 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 누적 현황 조회 결과: ${data.length}개`);
|
|
||||||
res.json({
|
|
||||||
date,
|
|
||||||
worker_id,
|
|
||||||
total_entries: data.length,
|
|
||||||
accumulated_data: data,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 기여자별 요약 조회 (새로운 기능)
|
|
||||||
*/
|
|
||||||
const getContributorsSummary = (req, res) => {
|
|
||||||
const { date, worker_id } = req.query;
|
|
||||||
|
|
||||||
if (!date || !worker_id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'date와 worker_id가 필요합니다.',
|
|
||||||
example: 'date=2024-06-16&worker_id=1'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 기여자별 요약 조회: date=${date}, worker_id=${worker_id}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getContributorsByDate(date, worker_id, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('기여자별 요약 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '기여자별 요약 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalHours = data.reduce((sum, contributor) => sum + parseFloat(contributor.total_hours || 0), 0);
|
|
||||||
|
|
||||||
console.log(`📊 기여자별 요약: ${data.length}명, 총 ${totalHours}시간`);
|
|
||||||
res.json({
|
|
||||||
date,
|
|
||||||
worker_id,
|
|
||||||
contributors: data,
|
|
||||||
total_contributors: data.length,
|
|
||||||
grand_total_hours: totalHours,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 개인 누적 현황 조회 (새로운 기능)
|
|
||||||
*/
|
|
||||||
const getMyAccumulatedData = (req, res) => {
|
|
||||||
const { date, worker_id } = req.query;
|
|
||||||
const created_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!date || !worker_id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'date와 worker_id가 필요합니다.',
|
|
||||||
example: 'date=2024-06-16&worker_id=1'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!created_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 개인 누적 현황 조회: date=${date}, worker_id=${worker_id}, created_by=${created_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getMyAccumulatedHours(date, worker_id, created_by, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('개인 누적 현황 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '개인 누적 현황 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 개인 누적: ${data.my_entry_count}개 항목, ${data.my_total_hours}시간`);
|
|
||||||
res.json({
|
|
||||||
date,
|
|
||||||
worker_id,
|
|
||||||
created_by,
|
|
||||||
my_data: data,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🗑️ 개별 항목 삭제 (본인 작성분만 - 새로운 기능)
|
|
||||||
*/
|
|
||||||
const removeMyEntry = (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const deleted_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!deleted_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ 개별 항목 삭제 요청: id=${id}, 삭제자=${deleted_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.removeSpecificEntry(id, deleted_by, (err, result) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('개별 항목 삭제 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '항목 삭제 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 개별 항목 삭제 완료: id=${id}`);
|
|
||||||
res.json({
|
|
||||||
message: '항목이 성공적으로 삭제되었습니다.',
|
|
||||||
id: id,
|
|
||||||
deleted_by,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...result
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 작업보고서 조회 (쿼리 파라미터 기반 - 작성자별 필터링 강화)
|
|
||||||
*/
|
|
||||||
const getDailyWorkReports = (req, res) => {
|
|
||||||
const { date, worker_id, created_by: requested_created_by } = req.query;
|
|
||||||
const current_user_id = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!current_user_id) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 사용자는 자신이 작성한 것만 볼 수 있음
|
|
||||||
const created_by = requested_created_by || current_user_id;
|
|
||||||
|
|
||||||
console.log('📊 작업보고서 조회 요청:', {
|
|
||||||
date,
|
|
||||||
worker_id,
|
|
||||||
requested_created_by,
|
|
||||||
current_user_id,
|
|
||||||
final_created_by: created_by
|
|
||||||
});
|
|
||||||
|
|
||||||
if (date && created_by) {
|
|
||||||
// 날짜 + 작성자별 조회
|
|
||||||
dailyWorkReportModel.getByDateAndCreator(date, created_by, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 날짜+작성자별 조회 결과: ${data.length}개`);
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
} else if (date && worker_id) {
|
|
||||||
// 기존 방식: 날짜 + 작업자별 (하지만 작성자 필터링 추가)
|
|
||||||
dailyWorkReportModel.getByDateAndWorker(date, worker_id, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 본인이 작성한 것만 필터링
|
|
||||||
const filteredData = data.filter(report => report.created_by === current_user_id);
|
|
||||||
console.log(`📊 날짜+작업자별 조회 결과: 전체 ${data.length}개 → 필터링 후 ${filteredData.length}개`);
|
|
||||||
res.json(filteredData);
|
|
||||||
});
|
|
||||||
} else if (date) {
|
|
||||||
// 날짜별 조회 (작성자 필터링)
|
|
||||||
dailyWorkReportModel.getByDate(date, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 본인이 작성한 것만 필터링
|
|
||||||
const filteredData = data.filter(report => report.created_by === current_user_id);
|
|
||||||
console.log(`📊 날짜별 조회 결과: 전체 ${data.length}개 → 필터링 후 ${filteredData.length}개`);
|
|
||||||
res.json(filteredData);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.status(400).json({
|
|
||||||
error: '날짜(date) 파라미터가 필요합니다.',
|
|
||||||
example: 'date=2024-06-16',
|
|
||||||
optional: ['worker_id', 'created_by']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 날짜별 작업보고서 조회 (경로 파라미터)
|
|
||||||
*/
|
|
||||||
const getDailyWorkReportsByDate = (req, res) => {
|
|
||||||
const { date } = req.params;
|
|
||||||
const created_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!created_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 날짜별 조회 (경로): date=${date}, created_by=${created_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getByDate(date, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('날짜별 작업보고서 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 본인이 작성한 것만 필터링
|
|
||||||
const filteredData = data.filter(report => report.created_by === created_by);
|
|
||||||
console.log(`📊 날짜별 조회 결과: 전체 ${data.length}개 → 필터링 후 ${filteredData.length}개`);
|
|
||||||
res.json(filteredData);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔍 작업보고서 검색 (페이지네이션 포함)
|
|
||||||
*/
|
|
||||||
const searchWorkReports = (req, res) => {
|
|
||||||
const { start_date, end_date, worker_id, project_id, work_status_id, page = 1, limit = 20 } = req.query;
|
|
||||||
const created_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!start_date || !end_date) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'start_date와 end_date가 필요합니다.',
|
|
||||||
example: 'start_date=2024-01-01&end_date=2024-01-31',
|
|
||||||
optional: ['worker_id', 'project_id', 'work_status_id', 'page', 'limit']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!created_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = {
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
worker_id: worker_id ? parseInt(worker_id) : null,
|
|
||||||
project_id: project_id ? parseInt(project_id) : null,
|
|
||||||
work_status_id: work_status_id ? parseInt(work_status_id) : null,
|
|
||||||
created_by, // 작성자 필터링 추가
|
|
||||||
page: parseInt(page),
|
|
||||||
limit: parseInt(limit)
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('🔍 작업보고서 검색 요청:', searchParams);
|
|
||||||
|
|
||||||
dailyWorkReportModel.searchWithDetails(searchParams, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 검색 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 검색 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔍 검색 결과: ${data.reports?.length || 0}개 (전체: ${data.total || 0}개)`);
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📈 통계 조회 (작성자별 필터링)
|
|
||||||
*/
|
|
||||||
const getWorkReportStats = (req, res) => {
|
|
||||||
const { start_date, end_date } = req.query;
|
|
||||||
const created_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!start_date || !end_date) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'start_date와 end_date가 필요합니다.',
|
|
||||||
example: 'start_date=2024-01-01&end_date=2024-01-31'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!created_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📈 통계 조회: ${start_date} ~ ${end_date}, 요청자: ${created_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getStatistics(start_date, end_date, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('통계 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '통계 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
...data,
|
|
||||||
metadata: {
|
|
||||||
note: '현재는 전체 통계입니다. 개인별 통계는 추후 구현 예정',
|
|
||||||
requested_by: created_by,
|
|
||||||
period: `${start_date} ~ ${end_date}`,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📊 일일 근무 요약 조회
|
|
||||||
*/
|
|
||||||
const getDailySummary = (req, res) => {
|
|
||||||
const { date, worker_id } = req.query;
|
|
||||||
|
|
||||||
if (date) {
|
|
||||||
console.log(`📊 일일 요약 조회: date=${date}`);
|
|
||||||
dailyWorkReportModel.getSummaryByDate(date, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('일일 요약 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '일일 요약 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
} else if (worker_id) {
|
|
||||||
console.log(`📊 작업자별 요약 조회: worker_id=${worker_id}`);
|
|
||||||
dailyWorkReportModel.getSummaryByWorker(worker_id, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업자별 요약 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업자별 요약 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.status(400).json({
|
|
||||||
error: 'date 또는 worker_id 파라미터가 필요합니다.',
|
|
||||||
examples: [
|
|
||||||
'date=2024-06-16',
|
|
||||||
'worker_id=1'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📅 월간 요약 조회
|
|
||||||
*/
|
|
||||||
const getMonthlySummary = (req, res) => {
|
|
||||||
const { year, month } = req.query;
|
|
||||||
|
|
||||||
if (!year || !month) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'year와 month가 필요합니다.',
|
|
||||||
example: 'year=2024&month=01',
|
|
||||||
note: 'month는 01, 02, ..., 12 형식으로 입력하세요.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📅 월간 요약 조회: ${year}-${month}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.getMonthlySummary(year, month, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('월간 요약 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '월간 요약 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
year: parseInt(year),
|
|
||||||
month: parseInt(month),
|
|
||||||
summary: data,
|
|
||||||
total_entries: data.length,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ✏️ 작업보고서 수정
|
|
||||||
*/
|
|
||||||
const updateWorkReport = (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const updateData = req.body;
|
|
||||||
const updated_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!updated_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData.updated_by = updated_by;
|
|
||||||
|
|
||||||
console.log(`✏️ 작업보고서 수정 요청: id=${id}, 수정자=${updated_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.updateById(id, updateData, (err, affectedRows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 수정 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 수정 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (affectedRows === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: '수정할 작업보고서를 찾을 수 없습니다.',
|
|
||||||
id: id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 작업보고서 수정 완료: id=${id}`);
|
|
||||||
res.json({
|
|
||||||
message: '작업보고서가 성공적으로 수정되었습니다.',
|
|
||||||
id: id,
|
|
||||||
affected_rows: affectedRows,
|
|
||||||
updated_by,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🗑️ 특정 작업보고서 삭제
|
|
||||||
*/
|
|
||||||
const removeDailyWorkReport = (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const deleted_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!deleted_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ 작업보고서 삭제 요청: id=${id}, 삭제자=${deleted_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.removeById(id, deleted_by, (err, affectedRows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 삭제 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 삭제 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (affectedRows === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: '삭제할 작업보고서를 찾을 수 없습니다.',
|
|
||||||
id: id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 작업보고서 삭제 완료: id=${id}`);
|
|
||||||
res.json({
|
|
||||||
message: '작업보고서가 성공적으로 삭제되었습니다.',
|
|
||||||
id: id,
|
|
||||||
affected_rows: affectedRows,
|
|
||||||
deleted_by,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🗑️ 작업자의 특정 날짜 전체 삭제
|
|
||||||
*/
|
|
||||||
const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
|
||||||
const { date, worker_id } = req.params;
|
|
||||||
const deleted_by = req.user?.user_id || req.user?.id;
|
|
||||||
|
|
||||||
if (!deleted_by) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: '사용자 인증 정보가 없습니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
|
|
||||||
|
|
||||||
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업보고서 전체 삭제 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업보고서 삭제 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (affectedRows === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: '삭제할 작업보고서를 찾을 수 없습니다.',
|
|
||||||
date: date,
|
|
||||||
worker_id: worker_id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 날짜+작업자별 전체 삭제 완료: ${affectedRows}개`);
|
|
||||||
res.json({
|
|
||||||
message: `${date} 날짜의 작업자 ${worker_id} 작업보고서 ${affectedRows}개가 삭제되었습니다.`,
|
|
||||||
date,
|
|
||||||
worker_id,
|
|
||||||
affected_rows: affectedRows,
|
|
||||||
deleted_by,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 📋 마스터 데이터 조회 함수들
|
|
||||||
*/
|
|
||||||
const getWorkTypes = (req, res) => {
|
|
||||||
console.log('📋 작업 유형 조회 요청');
|
|
||||||
dailyWorkReportModel.getAllWorkTypes((err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('작업 유형 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '작업 유형 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`📋 작업 유형 조회 결과: ${data.length}개`);
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getWorkStatusTypes = (req, res) => {
|
|
||||||
console.log('📋 업무 상태 유형 조회 요청');
|
|
||||||
dailyWorkReportModel.getAllWorkStatusTypes((err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('업무 상태 유형 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '업무 상태 유형 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`📋 업무 상태 유형 조회 결과: ${data.length}개`);
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorTypes = (req, res) => {
|
|
||||||
console.log('📋 에러 유형 조회 요청');
|
|
||||||
dailyWorkReportModel.getAllErrorTypes((err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('에러 유형 조회 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: '에러 유형 조회 중 오류가 발생했습니다.',
|
|
||||||
details: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`📋 에러 유형 조회 결과: ${data.length}개`);
|
|
||||||
res.json(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모든 컨트롤러 함수 내보내기 (기존 기능 + 누적 기능)
|
|
||||||
module.exports = {
|
|
||||||
// 📝 핵심 CRUD 함수들
|
|
||||||
createDailyWorkReport, // 누적 추가 (덮어쓰기 없음)
|
|
||||||
getDailyWorkReports, // 조회 (작성자별 필터링)
|
|
||||||
getDailyWorkReportsByDate, // 날짜별 조회
|
|
||||||
searchWorkReports, // 검색 (페이지네이션)
|
|
||||||
updateWorkReport, // 수정
|
|
||||||
removeDailyWorkReport, // 개별 삭제
|
|
||||||
removeDailyWorkReportByDateAndWorker, // 전체 삭제
|
|
||||||
|
|
||||||
// 🔄 누적 관련 새로운 함수들
|
|
||||||
getAccumulatedReports, // 누적 현황 조회
|
|
||||||
getContributorsSummary, // 기여자별 요약
|
|
||||||
getMyAccumulatedData, // 개인 누적 현황
|
|
||||||
removeMyEntry, // 개별 항목 삭제 (본인 것만)
|
|
||||||
|
|
||||||
// 📊 요약 및 통계 함수들
|
|
||||||
getDailySummary, // 일일 요약
|
|
||||||
getMonthlySummary, // 월간 요약
|
|
||||||
getWorkReportStats, // 통계
|
|
||||||
|
|
||||||
// 📋 마스터 데이터 함수들
|
|
||||||
getWorkTypes, // 작업 유형 목록
|
|
||||||
getWorkStatusTypes, // 업무 상태 유형 목록
|
|
||||||
getErrorTypes // 에러 유형 목록
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
// controllers/dailyWorkReportController.js - 권한별 전체 조회 지원 버전
|
/**
|
||||||
|
* 일일 작업 보고서 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업 보고서 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
|
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
|
||||||
const dailyWorkReportService = require('../services/dailyWorkReportService');
|
const dailyWorkReportService = require('../services/dailyWorkReportService');
|
||||||
const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler');
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
const { validateSchema, schemas } = require('../utils/validator');
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📝 작업보고서 생성 (V2 - Service Layer 사용)
|
* 📝 작업보고서 생성 (V2 - Service Layer 사용)
|
||||||
@@ -14,17 +23,13 @@ const createDailyWorkReport = asyncHandler(async (req, res) => {
|
|||||||
created_by_name: req.user?.name || req.user?.username || '알 수 없는 사용자'
|
created_by_name: req.user?.name || req.user?.username || '알 수 없는 사용자'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 스키마 기반 유효성 검사
|
const result = await dailyWorkReportService.createDailyWorkReportService(reportData);
|
||||||
validateSchema(reportData, schemas.createDailyWorkReport);
|
|
||||||
|
|
||||||
try {
|
res.status(201).json({
|
||||||
const result = await dailyWorkReportService.createDailyWorkReportService(reportData);
|
success: true,
|
||||||
|
data: result,
|
||||||
res.created(result, '작업보고서가 성공적으로 생성되었습니다.');
|
message: '작업보고서가 성공적으로 생성되었습니다'
|
||||||
} catch (error) {
|
});
|
||||||
console.error('💥 작업보고서 생성 컨트롤러 오류:', error.message);
|
|
||||||
throw new ApiError('작업보고서 생성에 실패했습니다.', 400);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,6 +182,7 @@ const getDailyWorkReportsByDate = (req, res) => {
|
|||||||
const { date } = req.params;
|
const { date } = req.params;
|
||||||
const current_user_id = req.user?.user_id || req.user?.id;
|
const current_user_id = req.user?.user_id || req.user?.id;
|
||||||
const user_access_level = req.user?.access_level;
|
const user_access_level = req.user?.access_level;
|
||||||
|
const user_job_type = req.user?.job_type;
|
||||||
|
|
||||||
if (!current_user_id) {
|
if (!current_user_id) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -184,9 +190,10 @@ const getDailyWorkReportsByDate = (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = user_access_level === 'system' || user_access_level === 'admin';
|
const isAdmin = user_access_level === 'system' || user_access_level === 'admin' || user_access_level === 'leader' || user_job_type === 'leader';
|
||||||
|
|
||||||
console.log(`📊 날짜별 조회 (경로): date=${date}, user=${current_user_id}, 권한=${user_access_level}, 관리자=${isAdmin}`);
|
console.log(`📊 날짜별 조회 (경로): date=${date}, user=${current_user_id}, 권한=${user_access_level}, 직책=${user_job_type}, 관리자=${isAdmin}`);
|
||||||
|
console.log(`🔍 사용자 정보 상세:`, req.user);
|
||||||
|
|
||||||
dailyWorkReportModel.getByDate(date, (err, data) => {
|
dailyWorkReportModel.getByDate(date, (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -197,14 +204,17 @@ const getDailyWorkReportsByDate = (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 권한별 필터링
|
// 🎯 권한별 필터링 (임시로 비활성화)
|
||||||
let finalData = data;
|
let finalData = data;
|
||||||
if (!isAdmin) {
|
console.log(`📊 임시로 모든 사용자에게 전체 조회 허용: ${data.length}개`);
|
||||||
finalData = data.filter(report => report.created_by === current_user_id);
|
console.log(`📊 권한 정보: access_level=${user_access_level}, job_type=${user_job_type}, isAdmin=${isAdmin}`);
|
||||||
console.log(`📊 권한 필터링: 전체 ${data.length}개 → ${finalData.length}개`);
|
|
||||||
} else {
|
// if (!isAdmin) {
|
||||||
console.log(`📊 관리자 권한으로 전체 조회: ${data.length}개`);
|
// finalData = data.filter(report => report.created_by === current_user_id);
|
||||||
}
|
// console.log(`📊 권한 필터링: 전체 ${data.length}개 → ${finalData.length}개`);
|
||||||
|
// } else {
|
||||||
|
// console.log(`📊 관리자 권한으로 전체 조회: ${data.length}개`);
|
||||||
|
// }
|
||||||
|
|
||||||
res.json(finalData);
|
res.json(finalData);
|
||||||
});
|
});
|
||||||
@@ -364,17 +374,28 @@ const updateWorkReport = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용)
|
* 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용)
|
||||||
|
* 권한: 그룹장(group_leader), 시스템(system), 관리자(admin)만 가능
|
||||||
*/
|
*/
|
||||||
const removeDailyWorkReport = async (req, res) => {
|
const removeDailyWorkReport = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id: reportId } = req.params;
|
const { id: reportId } = req.params;
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
user_id: req.user?.user_id || req.user?.id,
|
user_id: req.user?.user_id || req.user?.id,
|
||||||
|
access_level: req.user?.access_level || req.user?.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!userInfo.user_id) {
|
if (!userInfo.user_id) {
|
||||||
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
|
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
|
||||||
|
const allowedRoles = ['admin', 'system', 'group_leader'];
|
||||||
|
if (!allowedRoles.includes(userInfo.access_level)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: '작업보고서 삭제 권한이 없습니다.',
|
||||||
|
details: '그룹장 이상의 권한이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo);
|
const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo);
|
||||||
|
|
||||||
@@ -401,6 +422,7 @@ const removeDailyWorkReport = async (req, res) => {
|
|||||||
const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
||||||
const { date, worker_id } = req.params;
|
const { date, worker_id } = req.params;
|
||||||
const deleted_by = req.user?.user_id || req.user?.id;
|
const deleted_by = req.user?.user_id || req.user?.id;
|
||||||
|
const access_level = req.user?.access_level || req.user?.role;
|
||||||
|
|
||||||
if (!deleted_by) {
|
if (!deleted_by) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -408,6 +430,15 @@ const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
|
||||||
|
const allowedRoles = ['admin', 'system', 'group_leader'];
|
||||||
|
if (!allowedRoles.includes(access_level)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: '작업보고서 삭제 권한이 없습니다.',
|
||||||
|
details: '그룹장 이상의 권한이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
|
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
|
||||||
|
|
||||||
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
|
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
|
||||||
@@ -447,13 +478,20 @@ const getWorkTypes = (req, res) => {
|
|||||||
dailyWorkReportModel.getAllWorkTypes((err, data) => {
|
dailyWorkReportModel.getAllWorkTypes((err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('작업 유형 조회 오류:', err);
|
console.error('작업 유형 조회 오류:', err);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: '작업 유형 조회 중 오류가 발생했습니다.',
|
success: false,
|
||||||
details: err.message
|
error: {
|
||||||
|
message: '작업 유형 조회 중 오류가 발생했습니다.',
|
||||||
|
code: 'DATABASE_ERROR'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`📋 작업 유형 조회 결과: ${data.length}개`);
|
console.log(`📋 작업 유형 조회 결과: ${data.length}개`);
|
||||||
res.json(data);
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: data,
|
||||||
|
message: '작업 유형 조회 성공'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -487,6 +525,273 @@ const getErrorTypes = (req, res) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== 작업 유형 CRUD ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📝 작업 유형 생성
|
||||||
|
*/
|
||||||
|
const createWorkType = asyncHandler(async (req, res) => {
|
||||||
|
const { name, description, category } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new ApiError('작업 유형 이름이 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 작업 유형 생성:', { name, description, category });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.createWorkType({ name, description, category }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.created(result, '작업 유형이 성공적으로 생성되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 유형 생성');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✏️ 작업 유형 수정
|
||||||
|
*/
|
||||||
|
const updateWorkType = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, category } = req.body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('작업 유형 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✏️ 작업 유형 수정:', { id, name, description, category });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.updateWorkType(id, { name, description, category }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('수정할 작업 유형을 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '작업 유형이 성공적으로 수정되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 유형 수정');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🗑️ 작업 유형 삭제
|
||||||
|
*/
|
||||||
|
const deleteWorkType = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('작업 유형 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗑️ 작업 유형 삭제:', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.deleteWorkType(id, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('삭제할 작업 유형을 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '작업 유형이 성공적으로 삭제되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 유형 삭제');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 작업 상태 CRUD ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📝 작업 상태 생성
|
||||||
|
*/
|
||||||
|
const createWorkStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { name, description, is_error } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new ApiError('작업 상태 이름이 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 작업 상태 생성:', { name, description, is_error });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.createWorkStatus({ name, description, is_error }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.created(result, '작업 상태가 성공적으로 생성되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 상태 생성');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✏️ 작업 상태 수정
|
||||||
|
*/
|
||||||
|
const updateWorkStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, is_error } = req.body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('작업 상태 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✏️ 작업 상태 수정:', { id, name, description, is_error });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.updateWorkStatus(id, { name, description, is_error }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('수정할 작업 상태를 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '작업 상태가 성공적으로 수정되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 상태 수정');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🗑️ 작업 상태 삭제
|
||||||
|
*/
|
||||||
|
const deleteWorkStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('작업 상태 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗑️ 작업 상태 삭제:', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.deleteWorkStatus(id, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('삭제할 작업 상태를 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '작업 상태가 성공적으로 삭제되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '작업 상태 삭제');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 오류 유형 CRUD ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📝 오류 유형 생성
|
||||||
|
*/
|
||||||
|
const createErrorType = asyncHandler(async (req, res) => {
|
||||||
|
const { name, description, severity } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new ApiError('오류 유형 이름이 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 오류 유형 생성:', { name, description, severity });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.createErrorType({ name, description, severity }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.created(result, '오류 유형이 성공적으로 생성되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '오류 유형 생성');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✏️ 오류 유형 수정
|
||||||
|
*/
|
||||||
|
const updateErrorType = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, severity } = req.body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('오류 유형 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✏️ 오류 유형 수정:', { id, name, description, severity });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.updateErrorType(id, { name, description, severity }, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('수정할 오류 유형을 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '오류 유형이 성공적으로 수정되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '오류 유형 수정');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🗑️ 오류 유형 삭제
|
||||||
|
*/
|
||||||
|
const deleteErrorType = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new ApiError('오류 유형 ID가 필요합니다.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗑️ 오류 유형 삭제:', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
dailyWorkReportModel.deleteErrorType(id, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
throw new ApiError('삭제할 오류 유형을 찾을 수 없습니다.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.success(result, '오류 유형이 성공적으로 삭제되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
handleDatabaseError(err, '오류 유형 삭제');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📊 누적 현황 조회
|
* 📊 누적 현황 조회
|
||||||
*/
|
*/
|
||||||
@@ -522,6 +827,74 @@ const getAccumulatedReports = (req, res) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 배정 기반 작업보고서 생성
|
||||||
|
*/
|
||||||
|
const createFromTbm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
tbm_assignment_id,
|
||||||
|
tbm_session_id,
|
||||||
|
worker_id,
|
||||||
|
project_id,
|
||||||
|
work_type_id,
|
||||||
|
report_date,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
total_hours,
|
||||||
|
error_hours,
|
||||||
|
error_type_id,
|
||||||
|
work_status_id
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!tbm_assignment_id || !tbm_session_id || !worker_id || !report_date || !total_hours) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다. (assignment_id, session_id, worker_id, report_date, total_hours)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// regular_hours 계산
|
||||||
|
const regular_hours = total_hours - (error_hours || 0);
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
tbm_assignment_id,
|
||||||
|
tbm_session_id,
|
||||||
|
worker_id,
|
||||||
|
project_id,
|
||||||
|
work_type_id,
|
||||||
|
report_date,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
total_hours,
|
||||||
|
error_hours: error_hours || 0,
|
||||||
|
regular_hours,
|
||||||
|
work_status_id: work_status_id || (error_hours > 0 ? 2 : 1), // error_hours가 있으면 상태 2 (부적합)
|
||||||
|
error_type_id,
|
||||||
|
created_by: req.user.user_id
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dailyWorkReportModel.createFromTbmAssignment(reportData);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '작업보고서가 생성되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('TBM 작업보고서 생성 오류:', err);
|
||||||
|
console.error('Error stack:', err.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 작업보고서 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 모든 컨트롤러 함수 내보내기 (리팩토링된 함수 위주로 재구성)
|
// 모든 컨트롤러 함수 내보내기 (리팩토링된 함수 위주로 재구성)
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// 📝 V2 핵심 CRUD 함수
|
// 📝 V2 핵심 CRUD 함수
|
||||||
@@ -529,7 +902,8 @@ module.exports = {
|
|||||||
getDailyWorkReports,
|
getDailyWorkReports,
|
||||||
updateWorkReport,
|
updateWorkReport,
|
||||||
removeDailyWorkReport,
|
removeDailyWorkReport,
|
||||||
|
createFromTbm,
|
||||||
|
|
||||||
// 📊 V2 통계 및 요약 함수
|
// 📊 V2 통계 및 요약 함수
|
||||||
getWorkReportStats,
|
getWorkReportStats,
|
||||||
getDailySummary,
|
getDailySummary,
|
||||||
@@ -545,5 +919,16 @@ module.exports = {
|
|||||||
removeDailyWorkReportByDateAndWorker,
|
removeDailyWorkReportByDateAndWorker,
|
||||||
getWorkTypes,
|
getWorkTypes,
|
||||||
getWorkStatusTypes,
|
getWorkStatusTypes,
|
||||||
getErrorTypes
|
getErrorTypes,
|
||||||
|
|
||||||
|
// 🔽 마스터 데이터 CRUD
|
||||||
|
createWorkType,
|
||||||
|
updateWorkType,
|
||||||
|
deleteWorkType,
|
||||||
|
createWorkStatus,
|
||||||
|
updateWorkStatus,
|
||||||
|
deleteWorkStatus,
|
||||||
|
createErrorType,
|
||||||
|
updateErrorType,
|
||||||
|
deleteErrorType
|
||||||
};
|
};
|
||||||
241
api.hyungi.net/controllers/departmentController.js
Normal file
241
api.hyungi.net/controllers/departmentController.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// controllers/departmentController.js
|
||||||
|
const departmentModel = require('../models/departmentModel');
|
||||||
|
|
||||||
|
const departmentController = {
|
||||||
|
// 모든 부서 조회
|
||||||
|
async getAll(req, res) {
|
||||||
|
try {
|
||||||
|
const { active_only } = req.query;
|
||||||
|
const departments = active_only === 'true'
|
||||||
|
? await departmentModel.getActive()
|
||||||
|
: await departmentModel.getAll();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: departments
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서 목록을 불러오는데 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 부서 상세 조회
|
||||||
|
async getById(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const department = await departmentModel.getById(id);
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: department
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서 정보를 불러오는데 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 부서 생성
|
||||||
|
async create(req, res) {
|
||||||
|
try {
|
||||||
|
const { department_name, parent_id, description, is_active, display_order } = req.body;
|
||||||
|
|
||||||
|
if (!department_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서명은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentId = await departmentModel.create({
|
||||||
|
department_name,
|
||||||
|
parent_id,
|
||||||
|
description,
|
||||||
|
is_active,
|
||||||
|
display_order
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDepartment = await departmentModel.getById(departmentId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '부서가 생성되었습니다.',
|
||||||
|
data: newDepartment
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 생성 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서 생성에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 부서 수정
|
||||||
|
async update(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { department_name, parent_id, description, is_active, display_order } = req.body;
|
||||||
|
|
||||||
|
if (!department_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서명은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자기 자신을 상위 부서로 지정하는 것 방지
|
||||||
|
if (parent_id && parseInt(parent_id) === parseInt(id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '자기 자신을 상위 부서로 지정할 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await departmentModel.update(id, {
|
||||||
|
department_name,
|
||||||
|
parent_id,
|
||||||
|
description,
|
||||||
|
is_active,
|
||||||
|
display_order
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDepartment = await departmentModel.getById(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '부서 정보가 수정되었습니다.',
|
||||||
|
data: updatedDepartment
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 수정 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '부서 수정에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 부서 삭제
|
||||||
|
async delete(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await departmentModel.delete(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '부서가 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 삭제 오류:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || '부서 삭제에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 부서별 작업자 조회
|
||||||
|
async getWorkers(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const workers = await departmentModel.getWorkersByDepartment(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: workers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부서 작업자 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '작업자 목록을 불러오는데 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업자 부서 이동
|
||||||
|
async moveWorker(req, res) {
|
||||||
|
try {
|
||||||
|
const { workerId, departmentId } = req.body;
|
||||||
|
|
||||||
|
if (!workerId || !departmentId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '작업자 ID와 부서 ID가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await departmentModel.moveWorker(workerId, departmentId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업자 부서가 변경되었습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업자 부서 이동 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '작업자 부서 변경에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 여러 작업자 부서 일괄 이동
|
||||||
|
async moveWorkers(req, res) {
|
||||||
|
try {
|
||||||
|
const { workerIds, departmentId } = req.body;
|
||||||
|
|
||||||
|
if (!workerIds || !Array.isArray(workerIds) || workerIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '이동할 작업자를 선택하세요.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!departmentId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '대상 부서를 선택하세요.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await departmentModel.moveWorkers(workerIds, departmentId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${count}명의 작업자 부서가 변경되었습니다.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업자 일괄 이동 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '작업자 부서 변경에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = departmentController;
|
||||||
945
api.hyungi.net/controllers/equipmentController.js
Normal file
945
api.hyungi.net/controllers/equipmentController.js
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
// controllers/equipmentController.js
|
||||||
|
const EquipmentModel = require('../models/equipmentModel');
|
||||||
|
const imageUploadService = require('../services/imageUploadService');
|
||||||
|
|
||||||
|
const EquipmentController = {
|
||||||
|
// CREATE - 설비 생성
|
||||||
|
createEquipment: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentData = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 코드와 설비명은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설비 코드 중복 확인
|
||||||
|
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, null, (error, isDuplicate) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 코드 중복 확인 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 사용 중인 설비 코드입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설비 생성
|
||||||
|
EquipmentModel.create(equipmentData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 생성 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 생성 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 성공적으로 생성되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 생성 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// READ ALL - 모든 설비 조회 (필터링 가능)
|
||||||
|
getAllEquipments: (req, res) => {
|
||||||
|
try {
|
||||||
|
const filters = {
|
||||||
|
workplace_id: req.query.workplace_id,
|
||||||
|
equipment_type: req.query.equipment_type,
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
};
|
||||||
|
|
||||||
|
EquipmentModel.getAll(filters, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// READ ONE - 특정 설비 조회
|
||||||
|
getEquipmentById: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.getById(equipmentId, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// READ BY WORKPLACE - 특정 작업장의 설비 조회
|
||||||
|
getEquipmentsByWorkplace: (req, res) => {
|
||||||
|
try {
|
||||||
|
const workplaceId = req.params.workplaceId;
|
||||||
|
|
||||||
|
EquipmentModel.getByWorkplace(workplaceId, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('작업장 설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업장 설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// READ ACTIVE - 활성 설비만 조회
|
||||||
|
getActiveEquipments: (req, res) => {
|
||||||
|
try {
|
||||||
|
EquipmentModel.getActive((error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('활성 설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '활성 설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('활성 설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UPDATE - 설비 수정
|
||||||
|
updateEquipment: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const equipmentData = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 코드와 설비명은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설비 존재 확인
|
||||||
|
EquipmentModel.getById(equipmentId, (error, existingEquipment) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingEquipment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설비 코드 중복 확인 (자신 제외)
|
||||||
|
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, equipmentId, (error, isDuplicate) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 코드 중복 확인 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 사용 중인 설비 코드입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설비 수정
|
||||||
|
EquipmentModel.update(equipmentId, equipmentData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 수정 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 수정 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 성공적으로 수정되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 수정 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UPDATE MAP POSITION - 지도상 위치 업데이트
|
||||||
|
updateMapPosition: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const positionData = {
|
||||||
|
map_x_percent: req.body.map_x_percent,
|
||||||
|
map_y_percent: req.body.map_y_percent,
|
||||||
|
map_width_percent: req.body.map_width_percent,
|
||||||
|
map_height_percent: req.body.map_height_percent
|
||||||
|
};
|
||||||
|
|
||||||
|
// workplace_id가 있으면 포함 (설비를 다른 작업장으로 이동 가능)
|
||||||
|
if (req.body.workplace_id !== undefined) {
|
||||||
|
positionData.workplace_id = req.body.workplace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentModel.updateMapPosition(equipmentId, positionData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 위치 업데이트 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 위치 업데이트 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비 위치가 성공적으로 업데이트되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 위치 업데이트 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// DELETE - 설비 삭제
|
||||||
|
deleteEquipment: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.delete(equipmentId, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 삭제 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 삭제 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 성공적으로 삭제되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
|
||||||
|
getEquipmentTypes: (req, res) => {
|
||||||
|
try {
|
||||||
|
EquipmentModel.getEquipmentTypes((error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 유형 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 유형 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 유형 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET NEXT EQUIPMENT CODE - 다음 관리번호 자동 생성
|
||||||
|
getNextEquipmentCode: (req, res) => {
|
||||||
|
try {
|
||||||
|
const prefix = req.query.prefix || 'TKP';
|
||||||
|
|
||||||
|
EquipmentModel.getNextEquipmentCode(prefix, (error, nextCode) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('다음 관리번호 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '다음 관리번호 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
next_code: nextCode,
|
||||||
|
prefix: prefix
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('다음 관리번호 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 설비 사진 관리
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// ADD PHOTO - 설비 사진 추가
|
||||||
|
addPhoto: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const { photo_base64, description, display_order } = req.body;
|
||||||
|
|
||||||
|
if (!photo_base64) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진 데이터가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64 이미지를 파일로 저장
|
||||||
|
const photoPath = await imageUploadService.saveBase64Image(
|
||||||
|
photo_base64,
|
||||||
|
'equipment',
|
||||||
|
'equipments'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!photoPath) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진 저장에 실패했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에 사진 정보 저장
|
||||||
|
const photoData = {
|
||||||
|
photo_path: photoPath,
|
||||||
|
description: description || null,
|
||||||
|
display_order: display_order || 0,
|
||||||
|
uploaded_by: req.user?.user_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
EquipmentModel.addPhoto(equipmentId, photoData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('사진 정보 저장 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진 정보 저장 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '사진이 성공적으로 추가되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사진 추가 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET PHOTOS - 설비 사진 조회
|
||||||
|
getPhotos: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.getPhotos(equipmentId, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('사진 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사진 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// DELETE PHOTO - 설비 사진 삭제
|
||||||
|
deletePhoto: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const photoId = req.params.photoId;
|
||||||
|
|
||||||
|
EquipmentModel.deletePhoto(photoId, async (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
if (error.message === 'Photo not found') {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('사진 삭제 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사진 삭제 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 시스템에서 사진 삭제
|
||||||
|
if (result.photo_path) {
|
||||||
|
await imageUploadService.deleteFile(result.photo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '사진이 성공적으로 삭제되었습니다.',
|
||||||
|
data: { photo_id: photoId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사진 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 설비 임시 이동
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// MOVE TEMPORARILY - 설비 임시 이동
|
||||||
|
moveTemporarily: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const moveData = {
|
||||||
|
target_workplace_id: req.body.target_workplace_id,
|
||||||
|
target_x_percent: req.body.target_x_percent,
|
||||||
|
target_y_percent: req.body.target_y_percent,
|
||||||
|
target_width_percent: req.body.target_width_percent,
|
||||||
|
target_height_percent: req.body.target_height_percent,
|
||||||
|
from_workplace_id: req.body.from_workplace_id,
|
||||||
|
from_x_percent: req.body.from_x_percent,
|
||||||
|
from_y_percent: req.body.from_y_percent,
|
||||||
|
reason: req.body.reason,
|
||||||
|
moved_by: req.user?.user_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!moveData.target_workplace_id || moveData.target_x_percent === undefined || moveData.target_y_percent === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이동할 작업장과 위치가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentModel.moveTemporarily(equipmentId, moveData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 이동 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 이동 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 임시 이동되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 이동 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// RETURN TO ORIGINAL - 설비 원위치 복귀
|
||||||
|
returnToOriginal: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const userId = req.user?.user_id || null;
|
||||||
|
|
||||||
|
EquipmentModel.returnToOriginal(equipmentId, userId, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
if (error.message === 'Equipment not found') {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('설비 복귀 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 복귀 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 원위치로 복귀되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 복귀 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
|
||||||
|
getTemporarilyMoved: (req, res) => {
|
||||||
|
try {
|
||||||
|
EquipmentModel.getTemporarilyMoved((error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('임시 이동 설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '임시 이동 설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('임시 이동 설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET MOVE LOGS - 설비 이동 이력 조회
|
||||||
|
getMoveLogs: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.getMoveLogs(equipmentId, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('이동 이력 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '이동 이력 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이동 이력 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 설비 외부 반출/반입
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// EXPORT EQUIPMENT - 설비 외부 반출
|
||||||
|
exportEquipment: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const exportData = {
|
||||||
|
equipment_id: equipmentId,
|
||||||
|
export_date: req.body.export_date,
|
||||||
|
expected_return_date: req.body.expected_return_date,
|
||||||
|
destination: req.body.destination,
|
||||||
|
reason: req.body.reason,
|
||||||
|
notes: req.body.notes,
|
||||||
|
is_repair: req.body.is_repair || false,
|
||||||
|
exported_by: req.user?.user_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
EquipmentModel.exportEquipment(exportData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('설비 반출 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 반출 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 외부로 반출되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 반출 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
|
||||||
|
returnEquipment: (req, res) => {
|
||||||
|
try {
|
||||||
|
const logId = req.params.logId;
|
||||||
|
const returnData = {
|
||||||
|
return_date: req.body.return_date,
|
||||||
|
new_status: req.body.new_status || 'active',
|
||||||
|
notes: req.body.notes,
|
||||||
|
returned_by: req.user?.user_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
EquipmentModel.returnEquipment(logId, returnData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
if (error.message === 'Export log not found') {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '반출 기록을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('설비 반입 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '설비 반입 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '설비가 반입되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 반입 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
|
||||||
|
getExternalLogs: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.getExternalLogs(equipmentId, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('반출 이력 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '반출 이력 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('반출 이력 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
|
||||||
|
getExportedEquipments: (req, res) => {
|
||||||
|
try {
|
||||||
|
EquipmentModel.getExportedEquipments((error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('반출 중 설비 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '반출 중 설비 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('반출 중 설비 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 설비 수리 신청
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// CREATE REPAIR REQUEST - 수리 신청
|
||||||
|
createRepairRequest: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
const { photo_base64_list, description, item_id, workplace_id } = req.body;
|
||||||
|
|
||||||
|
// 사진 저장 (있는 경우)
|
||||||
|
let photoPaths = [];
|
||||||
|
if (photo_base64_list && photo_base64_list.length > 0) {
|
||||||
|
for (const base64 of photo_base64_list) {
|
||||||
|
const path = await imageUploadService.saveBase64Image(base64, 'repair', 'issues');
|
||||||
|
if (path) photoPaths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
equipment_id: equipmentId,
|
||||||
|
item_id: item_id || null,
|
||||||
|
workplace_id: workplace_id || null,
|
||||||
|
description: description || null,
|
||||||
|
photo_paths: photoPaths.length > 0 ? photoPaths : null,
|
||||||
|
reported_by: req.user?.user_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
EquipmentModel.createRepairRequest(requestData, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
if (error.message === '설비 수리 카테고리가 없습니다') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('수리 신청 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '수리 신청 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '수리 신청이 접수되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수리 신청 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET REPAIR HISTORY - 설비 수리 이력 조회
|
||||||
|
getRepairHistory: (req, res) => {
|
||||||
|
try {
|
||||||
|
const equipmentId = req.params.id;
|
||||||
|
|
||||||
|
EquipmentModel.getRepairHistory(equipmentId, (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('수리 이력 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '수리 이력 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수리 이력 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
|
||||||
|
getRepairCategories: (req, res) => {
|
||||||
|
try {
|
||||||
|
EquipmentModel.getRepairCategories((error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('수리 항목 조회 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '수리 항목 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수리 항목 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ADD REPAIR CATEGORY - 새 수리 항목 추가
|
||||||
|
addRepairCategory: (req, res) => {
|
||||||
|
try {
|
||||||
|
const { item_name } = req.body;
|
||||||
|
|
||||||
|
if (!item_name || !item_name.trim()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '수리 유형 이름을 입력하세요.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentModel.addRepairCategory(item_name.trim(), (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('수리 항목 추가 오류:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '수리 항목 추가 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: result.isNew ? '새 수리 유형이 추가되었습니다.' : '기존 수리 유형을 사용합니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수리 항목 추가 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = EquipmentController;
|
||||||
@@ -1,55 +1,65 @@
|
|||||||
const issueTypeModel = require('../models/issueTypeModel');
|
/**
|
||||||
|
* 이슈 유형 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 이슈 유형(카테고리/서브카테고리) CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
exports.createIssueType = async (req, res) => {
|
const issueTypeService = require('../services/issueTypeService');
|
||||||
try {
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
const id = await new Promise((resolve, reject) => {
|
|
||||||
issueTypeModel.create(req.body, (err, insertId) =>
|
|
||||||
err ? reject(err) : resolve(insertId)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
res.json({ success: true, issue_type_id: id });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.getAllIssueTypes = async (_req, res) => {
|
/**
|
||||||
try {
|
* 이슈 유형 생성
|
||||||
const rows = await new Promise((resolve, reject) => {
|
*/
|
||||||
issueTypeModel.getAll((err, data) => err ? reject(err) : resolve(data));
|
exports.createIssueType = asyncHandler(async (req, res) => {
|
||||||
});
|
const result = await issueTypeService.createIssueTypeService(req.body);
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateIssueType = async (req, res) => {
|
res.status(201).json({
|
||||||
try {
|
success: true,
|
||||||
const id = parseInt(req.params.id, 10);
|
data: result,
|
||||||
const changes = await new Promise((resolve, reject) => {
|
message: '이슈 유형이 성공적으로 생성되었습니다'
|
||||||
issueTypeModel.update(id, req.body, (err, affectedRows) =>
|
});
|
||||||
err ? reject(err) : resolve(affectedRows)
|
});
|
||||||
);
|
|
||||||
});
|
|
||||||
if (changes === 0) return res.status(404).json({ error: 'Not found or no changes' });
|
|
||||||
res.json({ success: true, changes });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.removeIssueType = async (req, res) => {
|
/**
|
||||||
try {
|
* 전체 이슈 유형 조회
|
||||||
const id = parseInt(req.params.id, 10);
|
*/
|
||||||
const changes = await new Promise((resolve, reject) => {
|
exports.getAllIssueTypes = asyncHandler(async (req, res) => {
|
||||||
issueTypeModel.remove(id, (err, affectedRows) =>
|
const rows = await issueTypeService.getAllIssueTypesService();
|
||||||
err ? reject(err) : resolve(affectedRows)
|
|
||||||
);
|
res.json({
|
||||||
});
|
success: true,
|
||||||
if (changes === 0) return res.status(404).json({ error: 'Not found' });
|
data: rows,
|
||||||
res.json({ success: true, changes });
|
message: '이슈 유형 목록 조회 성공'
|
||||||
} catch (err) {
|
});
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
});
|
||||||
}
|
|
||||||
};
|
/**
|
||||||
|
* 이슈 유형 수정
|
||||||
|
*/
|
||||||
|
exports.updateIssueType = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await issueTypeService.updateIssueTypeService(id, req.body);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '이슈 유형이 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈 유형 삭제
|
||||||
|
*/
|
||||||
|
exports.removeIssueType = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await issueTypeService.removeIssueTypeService(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '이슈 유형이 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
231
api.hyungi.net/controllers/monthlyStatusController.js
Normal file
231
api.hyungi.net/controllers/monthlyStatusController.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 월별 작업자 상태 집계 컨트롤러
|
||||||
|
*
|
||||||
|
* 월별 캘린더 및 작업자 상태 집계 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MonthlyStatusModel = require('../models/monthlyStatusModel');
|
||||||
|
const { ValidationError, ForbiddenError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 캘린더 데이터 조회
|
||||||
|
*/
|
||||||
|
const getMonthlyCalendarData = asyncHandler(async (req, res) => {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
|
||||||
|
if (!year || !month) {
|
||||||
|
throw new ValidationError('연도(year)와 월(month)이 필요합니다', {
|
||||||
|
required: ['year', 'month'],
|
||||||
|
received: { year, month }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearNum = parseInt(year);
|
||||||
|
const monthNum = parseInt(month);
|
||||||
|
|
||||||
|
if (yearNum < 2020 || yearNum > 2030 || monthNum < 1 || monthNum > 12) {
|
||||||
|
throw new ValidationError('유효하지 않은 연도 또는 월입니다', {
|
||||||
|
received: { year: yearNum, month: monthNum }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('월별 캘린더 데이터 조회 요청', { year: yearNum, month: monthNum });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summaryData = await MonthlyStatusModel.getMonthlySummary(yearNum, monthNum);
|
||||||
|
|
||||||
|
// 날짜별 객체로 변환
|
||||||
|
const calendarData = {};
|
||||||
|
summaryData.forEach(day => {
|
||||||
|
const dateKey = day.date.toISOString().split('T')[0];
|
||||||
|
calendarData[dateKey] = {
|
||||||
|
totalWorkers: day.total_workers,
|
||||||
|
workingWorkers: day.working_workers,
|
||||||
|
hasIssues: day.has_issues,
|
||||||
|
hasErrors: day.has_errors,
|
||||||
|
hasOvertimeWarning: day.has_overtime_warning,
|
||||||
|
incompleteWorkers: day.incomplete_workers,
|
||||||
|
partialWorkers: day.partial_workers,
|
||||||
|
errorWorkers: day.error_workers,
|
||||||
|
overtimeWarningWorkers: day.overtime_warning_workers,
|
||||||
|
totalHours: parseFloat(day.total_work_hours || 0),
|
||||||
|
totalTasks: day.total_work_count,
|
||||||
|
errorCount: day.total_error_count,
|
||||||
|
lastUpdated: day.last_updated
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('월별 캘린더 데이터 조회 성공', {
|
||||||
|
year: yearNum,
|
||||||
|
month: monthNum,
|
||||||
|
dayCount: Object.keys(calendarData).length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: calendarData,
|
||||||
|
message: `${year}년 ${month}월 캘린더 데이터를 성공적으로 조회했습니다`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('월별 캘린더 데이터 조회 실패', {
|
||||||
|
year: yearNum,
|
||||||
|
month: monthNum,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw new DatabaseError('월별 캘린더 데이터 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 날짜의 작업자별 상세 상태 조회
|
||||||
|
*/
|
||||||
|
const getDailyWorkerDetails = asyncHandler(async (req, res) => {
|
||||||
|
const { date } = req.query;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
throw new ValidationError('날짜(date)가 필요합니다', {
|
||||||
|
required: ['date'],
|
||||||
|
received: { date }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('일별 작업자 상세 조회 요청', { date });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workerDetails = await MonthlyStatusModel.getDailyWorkerStatus(date);
|
||||||
|
|
||||||
|
// 데이터 변환
|
||||||
|
const formattedData = workerDetails.map(worker => ({
|
||||||
|
workerId: worker.worker_id,
|
||||||
|
workerName: worker.worker_name,
|
||||||
|
jobType: worker.job_type,
|
||||||
|
totalHours: parseFloat(worker.total_work_hours || 0),
|
||||||
|
actualWorkHours: parseFloat(worker.actual_work_hours || 0),
|
||||||
|
vacationHours: parseFloat(worker.vacation_hours || 0),
|
||||||
|
totalWorkCount: worker.total_work_count,
|
||||||
|
regularWorkCount: worker.regular_work_count,
|
||||||
|
errorWorkCount: worker.error_work_count,
|
||||||
|
status: worker.work_status,
|
||||||
|
hasVacation: worker.has_vacation,
|
||||||
|
hasError: worker.has_error,
|
||||||
|
hasIssues: worker.has_issues,
|
||||||
|
lastUpdated: worker.last_updated
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 요약 정보 계산
|
||||||
|
const summary = {
|
||||||
|
totalWorkers: formattedData.length,
|
||||||
|
totalHours: formattedData.reduce((sum, w) => sum + w.totalHours, 0),
|
||||||
|
totalTasks: formattedData.reduce((sum, w) => sum + w.totalWorkCount, 0),
|
||||||
|
errorCount: formattedData.reduce((sum, w) => sum + w.errorWorkCount, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('일별 작업자 상세 조회 성공', {
|
||||||
|
date,
|
||||||
|
workerCount: formattedData.length,
|
||||||
|
totalHours: summary.totalHours
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
workers: formattedData,
|
||||||
|
summary
|
||||||
|
},
|
||||||
|
message: `${date} 작업자 상세 정보를 성공적으로 조회했습니다`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('일별 작업자 상세 조회 실패', {
|
||||||
|
date,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw new DatabaseError('일별 작업자 상세 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 집계 재계산 (관리자용)
|
||||||
|
*/
|
||||||
|
const recalculateMonth = asyncHandler(async (req, res) => {
|
||||||
|
const { year, month } = req.body;
|
||||||
|
|
||||||
|
if (!year || !month) {
|
||||||
|
throw new ValidationError('연도(year)와 월(month)이 필요합니다', {
|
||||||
|
required: ['year', 'month'],
|
||||||
|
received: { year, month }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 권한 확인
|
||||||
|
if (req.user.role !== 'admin' && req.user.role !== 'system') {
|
||||||
|
throw new ForbiddenError('관리자 권한이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('월별 집계 재계산 시작', {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
requestedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await MonthlyStatusModel.recalculateMonth(parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
logger.info('월별 집계 재계산 성공', { year, month, result });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `${year}년 ${month}월 집계 재계산이 완료되었습니다`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('월별 집계 재계산 실패', {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw new DatabaseError('월별 집계 재계산 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 집계 테이블 상태 확인 (관리자용)
|
||||||
|
*/
|
||||||
|
const getStatusInfo = asyncHandler(async (req, res) => {
|
||||||
|
// 관리자 권한 확인
|
||||||
|
if (req.user.role !== 'admin' && req.user.role !== 'system') {
|
||||||
|
throw new ForbiddenError('관리자 권한이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('집계 테이블 상태 확인 요청', {
|
||||||
|
requestedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusInfo = await MonthlyStatusModel.getStatusInfo();
|
||||||
|
|
||||||
|
logger.info('집계 테이블 상태 확인 성공');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: statusInfo,
|
||||||
|
message: '집계 테이블 상태 정보를 성공적으로 조회했습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('집계 테이블 상태 확인 실패', {
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw new DatabaseError('집계 테이블 상태 확인 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMonthlyCalendarData,
|
||||||
|
getDailyWorkerDetails,
|
||||||
|
recalculateMonth,
|
||||||
|
getStatusInfo
|
||||||
|
};
|
||||||
165
api.hyungi.net/controllers/notificationController.js
Normal file
165
api.hyungi.net/controllers/notificationController.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// controllers/notificationController.js
|
||||||
|
const notificationModel = require('../models/notificationModel');
|
||||||
|
|
||||||
|
const notificationController = {
|
||||||
|
// 읽지 않은 알림 조회
|
||||||
|
async getUnread(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const notifications = await notificationModel.getUnread(userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: notifications
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('읽지 않은 알림 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 전체 알림 조회
|
||||||
|
async getAll(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
|
||||||
|
const result = await notificationModel.getAll(userId, page, limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.notifications,
|
||||||
|
pagination: {
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
limit: result.limit,
|
||||||
|
totalPages: Math.ceil(result.total / result.limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 읽지 않은 알림 개수
|
||||||
|
async getUnreadCount(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const count = await notificationModel.getUnreadCount(userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { count }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 개수 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 개수 조회 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 알림 읽음 처리
|
||||||
|
async markAsRead(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const success = await notificationModel.markAsRead(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success,
|
||||||
|
message: success ? '알림을 읽음 처리했습니다.' : '알림을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 읽음 처리 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 처리 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 모든 알림 읽음 처리
|
||||||
|
async markAllAsRead(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const count = await notificationModel.markAllAsRead(userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${count}개의 알림을 읽음 처리했습니다.`,
|
||||||
|
data: { count }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('전체 읽음 처리 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 처리 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 알림 삭제
|
||||||
|
async delete(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const success = await notificationModel.delete(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success,
|
||||||
|
message: success ? '알림을 삭제했습니다.' : '알림을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 삭제 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 알림 생성 (시스템용)
|
||||||
|
async create(req, res) {
|
||||||
|
try {
|
||||||
|
const { type, title, message, link_url, user_id } = req.body;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 제목은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationId = await notificationModel.create({
|
||||||
|
user_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
link_url,
|
||||||
|
created_by: req.user?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '알림이 생성되었습니다.',
|
||||||
|
data: { notification_id: notificationId }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 생성 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '알림 생성 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = notificationController;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// controllers/notificationRecipientController.js
|
||||||
|
const notificationRecipientModel = require('../models/notificationRecipientModel');
|
||||||
|
|
||||||
|
const notificationRecipientController = {
|
||||||
|
// 알림 유형 목록
|
||||||
|
getTypes: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const types = notificationRecipientModel.getTypes();
|
||||||
|
res.json({ success: true, data: types });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 유형 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '알림 유형 조회 실패' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 전체 수신자 목록 (유형별 그룹화)
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('🔔 알림 수신자 목록 조회 시작');
|
||||||
|
const recipients = await notificationRecipientModel.getAll();
|
||||||
|
console.log('✅ 알림 수신자 목록 조회 완료:', recipients);
|
||||||
|
res.json({ success: true, data: recipients });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 수신자 목록 조회 오류:', error.message);
|
||||||
|
console.error('❌ 스택:', error.stack);
|
||||||
|
res.status(500).json({ success: false, error: '수신자 목록 조회 실패', detail: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 유형별 수신자 조회
|
||||||
|
getByType: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const recipients = await notificationRecipientModel.getByType(type);
|
||||||
|
res.json({ success: true, data: recipients });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신자 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '수신자 조회 실패' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 수신자 추가
|
||||||
|
add: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { notification_type, user_id } = req.body;
|
||||||
|
|
||||||
|
if (!notification_type || !user_id) {
|
||||||
|
return res.status(400).json({ success: false, error: '알림 유형과 사용자 ID가 필요합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await notificationRecipientModel.add(notification_type, user_id, req.user?.user_id);
|
||||||
|
res.json({ success: true, message: '수신자가 추가되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신자 추가 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '수신자 추가 실패' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 수신자 제거
|
||||||
|
remove: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, userId } = req.params;
|
||||||
|
|
||||||
|
await notificationRecipientModel.remove(type, userId);
|
||||||
|
res.json({ success: true, message: '수신자가 제거되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신자 제거 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '수신자 제거 실패' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 유형별 수신자 일괄 설정
|
||||||
|
setRecipients: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(user_ids)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'user_ids 배열이 필요합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await notificationRecipientModel.setRecipients(type, user_ids, req.user?.user_id);
|
||||||
|
res.json({ success: true, message: '수신자가 설정되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신자 설정 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '수신자 설정 실패' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = notificationRecipientController;
|
||||||
200
api.hyungi.net/controllers/pageAccessController.js
Normal file
200
api.hyungi.net/controllers/pageAccessController.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// controllers/pageAccessController.js
|
||||||
|
const PageAccessModel = require('../models/pageAccessModel');
|
||||||
|
|
||||||
|
const PageAccessController = {
|
||||||
|
// 사용자의 페이지 권한 조회
|
||||||
|
getUserPageAccess: (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 사용자 ID입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageAccessModel.getUserPageAccess(userId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 권한 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 권한 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 모든 페이지 목록 조회
|
||||||
|
getAllPages: (req, res) => {
|
||||||
|
PageAccessModel.getAllPages((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 페이지 권한 부여
|
||||||
|
grantPageAccess: (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const { pageId } = req.body;
|
||||||
|
const grantedBy = req.user.user_id;
|
||||||
|
|
||||||
|
if (isNaN(userId) || !pageId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 파라미터가 누락되었습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageAccessModel.grantPageAccess(userId, pageId, grantedBy, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 권한 부여 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 권한 부여 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '페이지 권한이 부여되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 페이지 권한 회수
|
||||||
|
revokePageAccess: (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const pageId = parseInt(req.params.pageId);
|
||||||
|
|
||||||
|
if (isNaN(userId) || isNaN(pageId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 파라미터입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageAccessModel.revokePageAccess(userId, pageId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 권한 회수 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 권한 회수 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '페이지 권한이 회수되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 사용자 페이지 권한 일괄 설정
|
||||||
|
setUserPageAccess: (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const { pageIds } = req.body;
|
||||||
|
const grantedBy = req.user.user_id;
|
||||||
|
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 사용자 ID입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pageIds)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'pageIds는 배열이어야 합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageAccessModel.setUserPageAccess(userId, pageIds, grantedBy, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 권한 설정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 권한 설정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '페이지 권한이 설정되었습니다.',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 특정 페이지 접근 권한 확인
|
||||||
|
checkPageAccess: (req, res) => {
|
||||||
|
const userId = req.user.user_id;
|
||||||
|
const { pageKey } = req.params;
|
||||||
|
|
||||||
|
if (!pageKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 키가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageAccessModel.checkPageAccess(userId, pageKey, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('페이지 접근 권한 확인 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 접근 권한 확인 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 계정이 있는 사용자 목록 조회 (권한 관리용)
|
||||||
|
getUsersWithAccounts: (req, res) => {
|
||||||
|
PageAccessModel.getUsersWithAccounts((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('사용자 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사용자 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PageAccessController;
|
||||||
796
api.hyungi.net/controllers/patrolController.js
Normal file
796
api.hyungi.net/controllers/patrolController.js
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
// patrolController.js
|
||||||
|
// 일일순회점검 시스템 컨트롤러
|
||||||
|
|
||||||
|
const PatrolModel = require('../models/patrolModel');
|
||||||
|
|
||||||
|
const PatrolController = {
|
||||||
|
// ==================== 순회점검 세션 ====================
|
||||||
|
|
||||||
|
// 세션 시작/조회
|
||||||
|
getOrCreateSession: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { patrol_date, patrol_time, category_id } = req.body;
|
||||||
|
const inspectorId = req.user.user_id;
|
||||||
|
|
||||||
|
if (!patrol_date || !patrol_time || !category_id) {
|
||||||
|
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await PatrolModel.getOrCreateSession(patrol_date, patrol_time, category_id, inspectorId);
|
||||||
|
res.json({ success: true, data: session });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 생성/조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 세션 상세 조회
|
||||||
|
getSession: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = await PatrolModel.getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ success: false, message: '세션을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: session });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 세션 목록 조회
|
||||||
|
getSessions: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { patrol_date, patrol_time, category_id, status, limit } = req.query;
|
||||||
|
const sessions = await PatrolModel.getSessions({
|
||||||
|
patrol_date,
|
||||||
|
patrol_time,
|
||||||
|
category_id,
|
||||||
|
status,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: sessions });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 세션 완료
|
||||||
|
completeSession: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
await PatrolModel.completeSession(sessionId);
|
||||||
|
res.json({ success: true, message: '순회점검이 완료되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 완료 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 세션 메모 업데이트
|
||||||
|
updateSessionNotes: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { notes } = req.body;
|
||||||
|
await PatrolModel.updateSessionNotes(sessionId, notes);
|
||||||
|
res.json({ success: true, message: '메모가 저장되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('메모 저장 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 체크리스트 항목 ====================
|
||||||
|
|
||||||
|
// 체크리스트 항목 조회
|
||||||
|
getChecklistItems: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_id, workplace_id } = req.query;
|
||||||
|
const items = await PatrolModel.getChecklistItems(category_id, workplace_id);
|
||||||
|
|
||||||
|
// 카테고리별로 그룹화
|
||||||
|
const grouped = {};
|
||||||
|
items.forEach(item => {
|
||||||
|
if (!grouped[item.check_category]) {
|
||||||
|
grouped[item.check_category] = [];
|
||||||
|
}
|
||||||
|
grouped[item.check_category].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: { items, grouped } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('체크리스트 항목 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크리스트 항목 추가
|
||||||
|
createChecklistItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const itemId = await PatrolModel.createChecklistItem(req.body);
|
||||||
|
res.json({ success: true, data: { item_id: itemId }, message: '항목이 추가되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('항목 추가 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크리스트 항목 수정
|
||||||
|
updateChecklistItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
await PatrolModel.updateChecklistItem(itemId, req.body);
|
||||||
|
res.json({ success: true, message: '항목이 수정되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('항목 수정 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크리스트 항목 삭제
|
||||||
|
deleteChecklistItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
await PatrolModel.deleteChecklistItem(itemId);
|
||||||
|
res.json({ success: true, message: '항목이 삭제되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('항목 삭제 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 체크 기록 ====================
|
||||||
|
|
||||||
|
// 작업장별 체크 기록 조회
|
||||||
|
getCheckRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { workplace_id } = req.query;
|
||||||
|
const records = await PatrolModel.getCheckRecords(sessionId, workplace_id);
|
||||||
|
res.json({ success: true, data: records });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('체크 기록 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크 기록 저장
|
||||||
|
saveCheckRecord: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { workplace_id, check_item_id, is_checked, check_result, note } = req.body;
|
||||||
|
|
||||||
|
if (!workplace_id || !check_item_id) {
|
||||||
|
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await PatrolModel.saveCheckRecord(sessionId, workplace_id, check_item_id, is_checked, check_result, note);
|
||||||
|
res.json({ success: true, message: '저장되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('체크 기록 저장 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크 기록 일괄 저장
|
||||||
|
saveCheckRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { workplace_id, records } = req.body;
|
||||||
|
|
||||||
|
if (!workplace_id || !records || !Array.isArray(records)) {
|
||||||
|
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await PatrolModel.saveCheckRecords(sessionId, workplace_id, records);
|
||||||
|
res.json({ success: true, message: '저장되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('체크 기록 일괄 저장 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 작업장 물품 현황 ====================
|
||||||
|
|
||||||
|
// 작업장 물품 조회
|
||||||
|
getWorkplaceItems: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workplaceId } = req.params;
|
||||||
|
const { include_inactive } = req.query;
|
||||||
|
const items = await PatrolModel.getWorkplaceItems(workplaceId, include_inactive !== 'true');
|
||||||
|
res.json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('물품 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 물품 추가
|
||||||
|
createWorkplaceItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workplaceId } = req.params;
|
||||||
|
const data = { ...req.body, workplace_id: workplaceId, created_by: req.user.user_id };
|
||||||
|
const itemId = await PatrolModel.createWorkplaceItem(data);
|
||||||
|
res.json({ success: true, data: { item_id: itemId }, message: '물품이 추가되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('물품 추가 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 물품 수정
|
||||||
|
updateWorkplaceItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
await PatrolModel.updateWorkplaceItem(itemId, req.body, req.user.user_id);
|
||||||
|
res.json({ success: true, message: '물품이 수정되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('물품 수정 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 물품 삭제
|
||||||
|
deleteWorkplaceItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const { permanent } = req.query;
|
||||||
|
|
||||||
|
if (permanent === 'true') {
|
||||||
|
await PatrolModel.hardDeleteWorkplaceItem(itemId);
|
||||||
|
} else {
|
||||||
|
await PatrolModel.deleteWorkplaceItem(itemId, req.user.user_id);
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '물품이 삭제되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('물품 삭제 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 물품 유형 ====================
|
||||||
|
|
||||||
|
// 물품 유형 목록
|
||||||
|
getItemTypes: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const types = await PatrolModel.getItemTypes();
|
||||||
|
res.json({ success: true, data: types });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('물품 유형 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 대시보드/통계 ====================
|
||||||
|
|
||||||
|
// 오늘 순회점검 현황
|
||||||
|
getTodayStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_id } = req.query;
|
||||||
|
const status = await PatrolModel.getTodayPatrolStatus(category_id);
|
||||||
|
res.json({ success: true, data: status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('오늘 현황 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업장별 점검 현황
|
||||||
|
getWorkplaceCheckStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const status = await PatrolModel.getWorkplaceCheckStatus(sessionId);
|
||||||
|
res.json({ success: true, data: status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장별 점검 현황 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 작업장 상세 정보 (통합) ====================
|
||||||
|
|
||||||
|
// 작업장 상세 정보 조회 (시설물, 안전신고, 부적합, 출입, TBM)
|
||||||
|
getWorkplaceDetail: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workplaceId } = req.params;
|
||||||
|
const { date } = req.query; // 기본: 오늘
|
||||||
|
const targetDate = date || new Date().toISOString().slice(0, 10);
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 1. 작업장 기본 정보 (카테고리 지도 이미지 포함)
|
||||||
|
const [workplaceInfo] = await db.query(`
|
||||||
|
SELECT w.*, wc.category_name, wc.layout_image as category_layout_image
|
||||||
|
FROM workplaces w
|
||||||
|
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
|
||||||
|
WHERE w.workplace_id = ?
|
||||||
|
`, [workplaceId]);
|
||||||
|
|
||||||
|
if (!workplaceInfo.length) {
|
||||||
|
return res.status(404).json({ success: false, message: '작업장을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 설비 현황 (해당 작업장 - 원래 위치 또는 현재 위치)
|
||||||
|
let equipments = [];
|
||||||
|
try {
|
||||||
|
const [eqResult] = await db.query(`
|
||||||
|
SELECT e.equipment_id, e.equipment_name, e.equipment_code, e.equipment_type,
|
||||||
|
e.status, e.notes, e.workplace_id,
|
||||||
|
e.map_x_percent, e.map_y_percent, e.map_width_percent, e.map_height_percent,
|
||||||
|
e.is_temporarily_moved, e.current_workplace_id,
|
||||||
|
e.current_map_x_percent, e.current_map_y_percent,
|
||||||
|
e.current_map_width_percent, e.current_map_height_percent,
|
||||||
|
e.moved_at,
|
||||||
|
ow.workplace_name as original_workplace_name,
|
||||||
|
cw.workplace_name as current_workplace_name,
|
||||||
|
CASE
|
||||||
|
WHEN e.status IN ('maintenance', 'repair_needed', 'repair_external') THEN 1
|
||||||
|
WHEN e.is_temporarily_moved = 1 THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as needs_attention
|
||||||
|
FROM equipments e
|
||||||
|
LEFT JOIN workplaces ow ON e.workplace_id = ow.workplace_id
|
||||||
|
LEFT JOIN workplaces cw ON e.current_workplace_id = cw.workplace_id
|
||||||
|
WHERE (e.workplace_id = ? OR e.current_workplace_id = ?)
|
||||||
|
AND e.status != 'inactive'
|
||||||
|
ORDER BY needs_attention DESC, e.equipment_name
|
||||||
|
`, [workplaceId, workplaceId]);
|
||||||
|
equipments = eqResult;
|
||||||
|
} catch (eqError) {
|
||||||
|
console.log('설비 조회 스킵 (테이블 없음 또는 오류):', eqError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 수리 요청 현황 (미완료) - 테이블 존재 여부 확인 후 조회
|
||||||
|
let repairRequests = [];
|
||||||
|
try {
|
||||||
|
const [repairResult] = await db.query(`
|
||||||
|
SELECT er.request_id, er.request_date, er.repair_category, er.description,
|
||||||
|
er.priority, er.status, e.equipment_name, e.equipment_code
|
||||||
|
FROM equipment_repair_requests er
|
||||||
|
JOIN equipments e ON er.equipment_id = e.equipment_id
|
||||||
|
WHERE e.workplace_id = ? AND er.status NOT IN ('completed', 'cancelled')
|
||||||
|
ORDER BY
|
||||||
|
CASE er.priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END,
|
||||||
|
er.request_date DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, [workplaceId]);
|
||||||
|
repairRequests = repairResult;
|
||||||
|
} catch (repairError) {
|
||||||
|
console.log('수리요청 조회 스킵 (테이블 없음 또는 오류):', repairError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 안전 신고 및 부적합 사항 - 테이블 존재 여부 확인 후 조회
|
||||||
|
let workIssues = [];
|
||||||
|
try {
|
||||||
|
const [issueResult] = await db.query(`
|
||||||
|
SELECT wi.report_id, wi.issue_type, wi.title, wi.description,
|
||||||
|
wi.status, wi.severity, wi.created_at, wi.resolved_at,
|
||||||
|
wic.category_name, wic.issue_type as category_type,
|
||||||
|
u.name as reporter_name
|
||||||
|
FROM work_issue_reports wi
|
||||||
|
LEFT JOIN issue_report_categories wic ON wi.category_id = wic.category_id
|
||||||
|
LEFT JOIN users u ON wi.reporter_id = u.user_id
|
||||||
|
WHERE wi.workplace_id = ?
|
||||||
|
AND wi.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
ORDER BY wi.created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, [workplaceId]);
|
||||||
|
workIssues = issueResult;
|
||||||
|
} catch (issueError) {
|
||||||
|
console.log('신고 조회 스킵 (테이블 없음 또는 오류):', issueError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 오늘의 출입 기록 (해당 공장 카테고리)
|
||||||
|
const categoryId = workplaceInfo[0].category_id;
|
||||||
|
let visitRecords = [];
|
||||||
|
try {
|
||||||
|
const [visitResult] = await db.query(`
|
||||||
|
SELECT vr.request_id, vr.visitor_name, vr.visitor_company, vr.visit_purpose,
|
||||||
|
vr.visit_date, vr.visit_time_from, vr.visit_time_to, vr.status,
|
||||||
|
vr.vehicle_number, vr.companion_count,
|
||||||
|
vp.purpose_name, u.name as requester_name
|
||||||
|
FROM workplace_visit_requests vr
|
||||||
|
LEFT JOIN visit_purpose_types vp ON vr.purpose_id = vp.purpose_id
|
||||||
|
LEFT JOIN users u ON vr.requester_id = u.user_id
|
||||||
|
WHERE vr.category_id = ? AND vr.visit_date = ? AND vr.status = 'approved'
|
||||||
|
ORDER BY vr.visit_time_from
|
||||||
|
`, [categoryId, targetDate]);
|
||||||
|
visitRecords = visitResult;
|
||||||
|
} catch (visitError) {
|
||||||
|
console.log('출입기록 조회 스킵 (테이블 없음 또는 오류):', visitError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 오늘의 TBM 세션 (해당 공장 카테고리)
|
||||||
|
let tbmSessions = [];
|
||||||
|
try {
|
||||||
|
const [tbmResult] = await db.query(`
|
||||||
|
SELECT ts.session_id, ts.session_date, ts.work_location, ts.status,
|
||||||
|
ts.work_content, ts.safety_measures, ts.team_size,
|
||||||
|
t.task_name, wt.name as work_type_name,
|
||||||
|
u.name as leader_name, w.worker_name as leader_worker_name
|
||||||
|
FROM tbm_sessions ts
|
||||||
|
LEFT JOIN tasks t ON ts.task_id = t.task_id
|
||||||
|
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||||
|
LEFT JOIN users u ON ts.leader_id = u.user_id
|
||||||
|
LEFT JOIN workers w ON ts.leader_worker_id = w.worker_id
|
||||||
|
WHERE ts.category_id = ? AND ts.session_date = ?
|
||||||
|
ORDER BY ts.created_at DESC
|
||||||
|
`, [categoryId, targetDate]);
|
||||||
|
tbmSessions = tbmResult;
|
||||||
|
} catch (tbmError) {
|
||||||
|
console.log('TBM 조회 스킵 (테이블 없음 또는 오류):', tbmError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. TBM 팀원 정보 (세션별)
|
||||||
|
let tbmWithTeams = [];
|
||||||
|
try {
|
||||||
|
tbmWithTeams = await Promise.all(tbmSessions.map(async (session) => {
|
||||||
|
const [team] = await db.query(`
|
||||||
|
SELECT tta.assignment_id, w.worker_name, w.occupation,
|
||||||
|
tta.attendance_status, tta.signature_image
|
||||||
|
FROM tbm_team_assignments tta
|
||||||
|
JOIN workers w ON tta.worker_id = w.worker_id
|
||||||
|
WHERE tta.session_id = ?
|
||||||
|
ORDER BY w.worker_name
|
||||||
|
`, [session.session_id]);
|
||||||
|
return { ...session, team };
|
||||||
|
}));
|
||||||
|
} catch (teamError) {
|
||||||
|
console.log('TBM 팀원 조회 스킵:', teamError.message);
|
||||||
|
tbmWithTeams = tbmSessions.map(s => ({ ...s, team: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 최근 순회점검 결과 (해당 작업장)
|
||||||
|
let recentPatrol = [];
|
||||||
|
try {
|
||||||
|
const [patrolResult] = await db.query(`
|
||||||
|
SELECT ps.session_id, ps.patrol_date, ps.patrol_time, ps.status,
|
||||||
|
ps.notes, u.name as inspector_name,
|
||||||
|
(SELECT COUNT(*) FROM patrol_check_records pcr
|
||||||
|
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?) as checked_count,
|
||||||
|
(SELECT COUNT(*) FROM patrol_check_records pcr
|
||||||
|
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?
|
||||||
|
AND pcr.check_result IN ('warning', 'bad')) as issue_count
|
||||||
|
FROM daily_patrol_sessions ps
|
||||||
|
LEFT JOIN users u ON ps.inspector_id = u.user_id
|
||||||
|
WHERE ps.category_id = ? AND ps.patrol_date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY ps.patrol_date DESC, ps.patrol_time DESC
|
||||||
|
LIMIT 5
|
||||||
|
`, [workplaceId, workplaceId, categoryId]);
|
||||||
|
recentPatrol = patrolResult;
|
||||||
|
} catch (patrolError) {
|
||||||
|
console.log('순회점검 조회 스킵 (테이블 없음 또는 오류):', patrolError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
workplace: workplaceInfo[0],
|
||||||
|
equipments: equipments,
|
||||||
|
repairRequests: repairRequests,
|
||||||
|
workIssues: {
|
||||||
|
safety: workIssues.filter(i => i.category_type === 'safety'),
|
||||||
|
nonconformity: workIssues.filter(i => i.category_type === 'nonconformity'),
|
||||||
|
all: workIssues
|
||||||
|
},
|
||||||
|
visitRecords: visitRecords,
|
||||||
|
tbmSessions: tbmWithTeams,
|
||||||
|
recentPatrol: recentPatrol,
|
||||||
|
summary: {
|
||||||
|
equipmentCount: equipments.length,
|
||||||
|
needsAttention: equipments.filter(e => e.needs_attention).length,
|
||||||
|
pendingRepairs: repairRequests.length,
|
||||||
|
openIssues: workIssues.filter(i => i.status !== 'closed').length,
|
||||||
|
todayVisitors: visitRecords.reduce((sum, v) => sum + 1 + (v.companion_count || 0), 0),
|
||||||
|
todayTbmSessions: tbmSessions.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 상세 정보 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 구역 내 등록 물품/시설물 ====================
|
||||||
|
|
||||||
|
// 구역 내 물품/시설물 목록 조회
|
||||||
|
getZoneItems: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workplaceId } = req.params;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 테이블이 없으면 생성
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workplace_zone_items (
|
||||||
|
item_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
workplace_id INT NOT NULL,
|
||||||
|
item_name VARCHAR(200) NOT NULL COMMENT '물품/시설물 명칭',
|
||||||
|
item_type VARCHAR(50) DEFAULT 'general' COMMENT '유형 (heavy_equipment, hazardous, storage, general 등)',
|
||||||
|
description TEXT COMMENT '상세 설명',
|
||||||
|
x_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 X 좌표 (%)',
|
||||||
|
y_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 Y 좌표 (%)',
|
||||||
|
width_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 너비 (%)',
|
||||||
|
height_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 높이 (%)',
|
||||||
|
color VARCHAR(20) DEFAULT '#3b82f6' COMMENT '표시 색상',
|
||||||
|
warning_level VARCHAR(20) DEFAULT 'normal' COMMENT '주의 수준 (normal, caution, danger)',
|
||||||
|
quantity INT DEFAULT 1 COMMENT '수량',
|
||||||
|
unit VARCHAR(20) DEFAULT '개' COMMENT '단위',
|
||||||
|
weight_kg DECIMAL(10,2) DEFAULT NULL COMMENT '중량 (kg)',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_workplace (workplace_id),
|
||||||
|
INDEX idx_type (item_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='구역 내 등록 물품/시설물'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 새 컬럼 추가 (없으면)
|
||||||
|
try {
|
||||||
|
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
|
||||||
|
} catch (e) { /* 이미 존재 */ }
|
||||||
|
try {
|
||||||
|
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
|
||||||
|
} catch (e) { /* 이미 존재 */ }
|
||||||
|
|
||||||
|
const [items] = await db.query(`
|
||||||
|
SELECT zi.*, p.project_name
|
||||||
|
FROM workplace_zone_items zi
|
||||||
|
LEFT JOIN projects p ON zi.project_id = p.project_id
|
||||||
|
WHERE zi.workplace_id = ? AND zi.is_active = TRUE
|
||||||
|
ORDER BY zi.warning_level DESC, zi.item_name
|
||||||
|
`, [workplaceId]);
|
||||||
|
|
||||||
|
// 사진 테이블 존재 확인 및 사진 조회
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
const [photos] = await db.query(`
|
||||||
|
SELECT photo_id, photo_url, created_at
|
||||||
|
FROM zone_item_photos
|
||||||
|
WHERE item_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [item.item_id]);
|
||||||
|
item.photos = photos || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 사진 테이블이 없으면 무시
|
||||||
|
items.forEach(item => item.photos = []);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('구역 물품 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구역 현황 등록
|
||||||
|
createZoneItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workplaceId } = req.params;
|
||||||
|
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
|
||||||
|
color, warning_level, project_type, project_id } = req.body;
|
||||||
|
const createdBy = req.user?.user_id;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
if (!item_name || x_percent === undefined || y_percent === undefined) {
|
||||||
|
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블에 새 컬럼 추가 (없으면)
|
||||||
|
try {
|
||||||
|
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
|
||||||
|
} catch (e) { /* 이미 존재 */ }
|
||||||
|
try {
|
||||||
|
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
|
||||||
|
} catch (e) { /* 이미 존재 */ }
|
||||||
|
|
||||||
|
const [result] = await db.query(`
|
||||||
|
INSERT INTO workplace_zone_items
|
||||||
|
(workplace_id, item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
|
||||||
|
color, warning_level, project_type, project_id, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [workplaceId, item_name, item_type || 'working', description, x_percent, y_percent,
|
||||||
|
width_percent || 5, height_percent || 5, color || '#3b82f6', warning_level || 'good',
|
||||||
|
project_type || 'non_project', project_id || null, createdBy]);
|
||||||
|
|
||||||
|
const newItemId = result.insertId;
|
||||||
|
|
||||||
|
// 등록 이력 저장
|
||||||
|
try {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO zone_item_history (item_id, action_type, new_values, changed_by)
|
||||||
|
VALUES (?, 'created', ?, ?)
|
||||||
|
`, [newItemId, JSON.stringify({ item_name, item_type, warning_level, project_type }), createdBy]);
|
||||||
|
} catch (e) { /* 테이블 없으면 무시 */ }
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { item_id: newItemId },
|
||||||
|
message: '현황이 등록되었습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('구역 현황 등록 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구역 현황 수정
|
||||||
|
updateZoneItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
|
||||||
|
color, warning_level, project_type, project_id } = req.body;
|
||||||
|
const userId = req.user?.user_id;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 이력 테이블 생성 (없으면)
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS zone_item_history (
|
||||||
|
history_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
action_type VARCHAR(20) NOT NULL COMMENT 'created, updated, deleted',
|
||||||
|
changed_fields TEXT COMMENT '변경된 필드 JSON',
|
||||||
|
old_values TEXT COMMENT '이전 값 JSON',
|
||||||
|
new_values TEXT COMMENT '새 값 JSON',
|
||||||
|
changed_by INT,
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_item (item_id),
|
||||||
|
INDEX idx_date (changed_at)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 기존 데이터 조회 (이력용)
|
||||||
|
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
|
||||||
|
const oldItem = oldData[0];
|
||||||
|
|
||||||
|
// 업데이트
|
||||||
|
await db.query(`
|
||||||
|
UPDATE workplace_zone_items SET
|
||||||
|
item_name = COALESCE(?, item_name),
|
||||||
|
item_type = COALESCE(?, item_type),
|
||||||
|
description = ?,
|
||||||
|
x_percent = COALESCE(?, x_percent),
|
||||||
|
y_percent = COALESCE(?, y_percent),
|
||||||
|
width_percent = COALESCE(?, width_percent),
|
||||||
|
height_percent = COALESCE(?, height_percent),
|
||||||
|
color = COALESCE(?, color),
|
||||||
|
warning_level = COALESCE(?, warning_level),
|
||||||
|
project_type = COALESCE(?, project_type),
|
||||||
|
project_id = ?
|
||||||
|
WHERE item_id = ?
|
||||||
|
`, [item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
|
||||||
|
color, warning_level, project_type, project_id, itemId]);
|
||||||
|
|
||||||
|
// 변경 이력 저장
|
||||||
|
if (oldItem) {
|
||||||
|
const changedFields = [];
|
||||||
|
const oldValues = {};
|
||||||
|
const newValues = {};
|
||||||
|
|
||||||
|
const fieldMap = { item_name, item_type, description, warning_level, project_type, project_id };
|
||||||
|
for (const [key, newVal] of Object.entries(fieldMap)) {
|
||||||
|
if (newVal !== undefined && oldItem[key] !== newVal) {
|
||||||
|
changedFields.push(key);
|
||||||
|
oldValues[key] = oldItem[key];
|
||||||
|
newValues[key] = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedFields.length > 0) {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO zone_item_history (item_id, action_type, changed_fields, old_values, new_values, changed_by)
|
||||||
|
VALUES (?, 'updated', ?, ?, ?, ?)
|
||||||
|
`, [itemId, JSON.stringify(changedFields), JSON.stringify(oldValues), JSON.stringify(newValues), userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '현황이 수정되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('구역 현황 수정 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구역 현황 사진 업로드
|
||||||
|
uploadZoneItemPhoto: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { item_id } = req.body;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 테이블 생성 (없으면)
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS zone_item_photos (
|
||||||
|
photo_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
photo_url VARCHAR(500) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_item_id (item_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const photoUrl = `/uploads/${req.file.filename}`;
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO zone_item_photos (item_id, photo_url) VALUES (?, ?)`,
|
||||||
|
[item_id, photoUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { photo_id: result.insertId, photo_url: photoUrl },
|
||||||
|
message: '사진이 업로드되었습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사진 업로드 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구역 현황 삭제
|
||||||
|
deleteZoneItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const userId = req.user?.user_id;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 기존 데이터 조회 (이력용)
|
||||||
|
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
|
||||||
|
const oldItem = oldData[0];
|
||||||
|
|
||||||
|
// 소프트 삭제
|
||||||
|
await db.query(`UPDATE workplace_zone_items SET is_active = FALSE WHERE item_id = ?`, [itemId]);
|
||||||
|
|
||||||
|
// 삭제 이력 저장
|
||||||
|
if (oldItem) {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO zone_item_history (item_id, action_type, old_values, changed_by)
|
||||||
|
VALUES (?, 'deleted', ?, ?)
|
||||||
|
`, [itemId, JSON.stringify({ item_name: oldItem.item_name, item_type: oldItem.item_type }), userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '현황이 삭제되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('구역 현황 삭제 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구역 현황 이력 조회
|
||||||
|
getZoneItemHistory: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const [history] = await db.query(`
|
||||||
|
SELECT h.*, u.full_name as changed_by_name
|
||||||
|
FROM zone_item_history h
|
||||||
|
LEFT JOIN users u ON h.changed_by = u.user_id
|
||||||
|
WHERE h.item_id = ?
|
||||||
|
ORDER BY h.changed_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, [itemId]);
|
||||||
|
|
||||||
|
res.json({ success: true, data: history });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('현황 이력 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PatrolController;
|
||||||
@@ -1,105 +1,142 @@
|
|||||||
const projectModel = require('../models/projectModel');
|
/**
|
||||||
const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler');
|
* 프로젝트 관리 컨트롤러
|
||||||
const { validateSchema, schemas } = require('../utils/validator');
|
*
|
||||||
|
* 프로젝트 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. 프로젝트 생성
|
const projectModel = require('../models/projectModel');
|
||||||
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const cache = require('../utils/cache');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 생성
|
||||||
|
*/
|
||||||
exports.createProject = asyncHandler(async (req, res) => {
|
exports.createProject = asyncHandler(async (req, res) => {
|
||||||
const projectData = req.body;
|
const projectData = req.body;
|
||||||
|
|
||||||
// 스키마 기반 유효성 검사
|
logger.info('프로젝트 생성 요청', { name: projectData.name });
|
||||||
validateSchema(projectData, schemas.createProject);
|
|
||||||
|
const id = await projectModel.create(projectData);
|
||||||
try {
|
|
||||||
const id = await new Promise((resolve, reject) => {
|
// 프로젝트 캐시 무효화
|
||||||
projectModel.create(projectData, (err, lastID) => (err ? reject(err) : resolve(lastID)));
|
await cache.invalidateCache.project();
|
||||||
});
|
|
||||||
|
logger.info('프로젝트 생성 성공', { project_id: id });
|
||||||
res.created({ project_id: id }, '프로젝트가 성공적으로 생성되었습니다.');
|
|
||||||
} catch (err) {
|
res.status(201).json({
|
||||||
handleDatabaseError(err, '프로젝트 생성');
|
success: true,
|
||||||
}
|
data: { project_id: id },
|
||||||
|
message: '프로젝트가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 전체 조회
|
/**
|
||||||
|
* 전체 프로젝트 조회
|
||||||
|
*/
|
||||||
exports.getAllProjects = asyncHandler(async (req, res) => {
|
exports.getAllProjects = asyncHandler(async (req, res) => {
|
||||||
try {
|
const rows = await projectModel.getAll();
|
||||||
const rows = await new Promise((resolve, reject) => {
|
|
||||||
projectModel.getAll((err, data) => (err ? reject(err) : resolve(data)));
|
res.json({
|
||||||
});
|
success: true,
|
||||||
|
data: rows,
|
||||||
res.list(rows, '프로젝트 목록 조회 성공');
|
message: '프로젝트 목록 조회 성공'
|
||||||
} catch (err) {
|
});
|
||||||
handleDatabaseError(err, '프로젝트 목록 조회');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 단일 조회
|
/**
|
||||||
|
* 활성 프로젝트만 조회 (작업보고서용)
|
||||||
|
*/
|
||||||
|
exports.getActiveProjects = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await projectModel.getActiveProjects();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '활성 프로젝트 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 프로젝트 조회
|
||||||
|
*/
|
||||||
exports.getProjectById = asyncHandler(async (req, res) => {
|
exports.getProjectById = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.project_id, 10);
|
const id = parseInt(req.params.project_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 프로젝트 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const row = await projectModel.getById(id);
|
||||||
const row = await new Promise((resolve, reject) => {
|
|
||||||
projectModel.getById(id, (err, data) => (err ? reject(err) : resolve(data)));
|
if (!row) {
|
||||||
});
|
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
handleNotFoundError('프로젝트', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.success(row, '프로젝트 조회 성공');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '프로젝트 조회');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: row,
|
||||||
|
message: '프로젝트 조회 성공'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 수정
|
/**
|
||||||
|
* 프로젝트 수정
|
||||||
|
*/
|
||||||
exports.updateProject = asyncHandler(async (req, res) => {
|
exports.updateProject = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.project_id, 10);
|
const id = parseInt(req.params.project_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 프로젝트 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { ...req.body, project_id: id };
|
const data = { ...req.body, project_id: id };
|
||||||
|
|
||||||
try {
|
const changes = await projectModel.update(data);
|
||||||
const changes = await new Promise((resolve, reject) => {
|
|
||||||
projectModel.update(data, (err, ch) => (err ? reject(err) : resolve(ch)));
|
if (changes === 0) {
|
||||||
});
|
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
|
||||||
|
|
||||||
if (changes === 0) {
|
|
||||||
handleNotFoundError('프로젝트', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.updated({ changes }, '프로젝트 정보가 성공적으로 수정되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '프로젝트 수정');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프로젝트 캐시 무효화
|
||||||
|
await cache.invalidateCache.project();
|
||||||
|
|
||||||
|
logger.info('프로젝트 수정 성공', { project_id: id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { changes },
|
||||||
|
message: '프로젝트 정보가 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 삭제
|
/**
|
||||||
|
* 프로젝트 삭제
|
||||||
|
*/
|
||||||
exports.removeProject = asyncHandler(async (req, res) => {
|
exports.removeProject = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.project_id, 10);
|
const id = parseInt(req.params.project_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 프로젝트 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const changes = await projectModel.remove(id);
|
||||||
const changes = await new Promise((resolve, reject) => {
|
|
||||||
projectModel.remove(id, (err, ch) => (err ? reject(err) : resolve(ch)));
|
if (changes === 0) {
|
||||||
});
|
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
|
||||||
|
|
||||||
if (changes === 0) {
|
|
||||||
handleNotFoundError('프로젝트', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.deleted('프로젝트가 성공적으로 삭제되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '프로젝트 삭제');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 프로젝트 캐시 무효화
|
||||||
|
await cache.invalidateCache.project();
|
||||||
|
|
||||||
|
logger.info('프로젝트 삭제 성공', { project_id: id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '프로젝트가 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,102 +1,152 @@
|
|||||||
const taskModel = require('../models/taskModel');
|
/**
|
||||||
const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler');
|
* 작업 관리 컨트롤러
|
||||||
const { validateSchema, schemas } = require('../utils/validator');
|
*
|
||||||
|
* 작업 CRUD API 엔드포인트 핸들러
|
||||||
|
* (공정=work_types에 속하는 세부 작업)
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-01-26
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. 생성
|
const taskModel = require('../models/taskModel');
|
||||||
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// ==================== 작업 CRUD ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 생성
|
||||||
|
*/
|
||||||
exports.createTask = asyncHandler(async (req, res) => {
|
exports.createTask = asyncHandler(async (req, res) => {
|
||||||
const taskData = req.body;
|
const taskData = req.body;
|
||||||
|
|
||||||
try {
|
if (!taskData.task_name) {
|
||||||
const lastID = await new Promise((resolve, reject) => {
|
throw new ValidationError('작업명은 필수 입력 항목입니다');
|
||||||
taskModel.create(taskData, (err, id) => (err ? reject(err) : resolve(id)));
|
|
||||||
});
|
|
||||||
|
|
||||||
res.created({ task_id: lastID }, '작업이 성공적으로 생성되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업 생성');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('작업 생성 요청', { name: taskData.task_name });
|
||||||
|
|
||||||
|
const id = await taskModel.createTask(taskData);
|
||||||
|
|
||||||
|
logger.info('작업 생성 성공', { task_id: id });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { task_id: id },
|
||||||
|
message: '작업이 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 전체 조회
|
/**
|
||||||
|
* 전체 작업 조회 (work_type_id 필터 지원)
|
||||||
|
*/
|
||||||
exports.getAllTasks = asyncHandler(async (req, res) => {
|
exports.getAllTasks = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { work_type_id } = req.query;
|
||||||
const rows = await new Promise((resolve, reject) => {
|
|
||||||
taskModel.getAll((err, data) => (err ? reject(err) : resolve(data)));
|
let rows;
|
||||||
});
|
if (work_type_id) {
|
||||||
|
// 특정 공정의 활성 작업만 조회
|
||||||
res.list(rows, '작업 목록 조회 성공');
|
rows = await taskModel.getTasksByWorkType(work_type_id);
|
||||||
} catch (err) {
|
} else {
|
||||||
handleDatabaseError(err, '작업 목록 조회');
|
rows = await taskModel.getAllTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '작업 목록 조회 성공'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 단일 조회
|
/**
|
||||||
|
* 활성 작업만 조회
|
||||||
|
*/
|
||||||
|
exports.getActiveTasks = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await taskModel.getActiveTasks();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '활성 작업 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공정별 작업 조회
|
||||||
|
*/
|
||||||
|
exports.getTasksByWorkType = asyncHandler(async (req, res) => {
|
||||||
|
const workTypeId = req.params.work_type_id || req.query.work_type_id;
|
||||||
|
|
||||||
|
if (!workTypeId) {
|
||||||
|
throw new ValidationError('공정 ID가 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await taskModel.getTasksByWorkType(workTypeId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '공정별 작업 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 작업 조회
|
||||||
|
*/
|
||||||
exports.getTaskById = asyncHandler(async (req, res) => {
|
exports.getTaskById = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.task_id, 10);
|
const taskId = req.params.id;
|
||||||
|
|
||||||
if (isNaN(id)) {
|
const task = await taskModel.getTaskById(taskId);
|
||||||
throw new ApiError('유효하지 않은 작업 ID입니다.', 400);
|
|
||||||
}
|
if (!task) {
|
||||||
|
throw new NotFoundError('작업을 찾을 수 없습니다');
|
||||||
try {
|
|
||||||
const row = await new Promise((resolve, reject) => {
|
|
||||||
taskModel.getById(id, (err, data) => (err ? reject(err) : resolve(data)));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
handleNotFoundError('작업', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.success(row, '작업 조회 성공');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업 조회');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
message: '작업 조회 성공'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 수정
|
/**
|
||||||
|
* 작업 수정
|
||||||
|
*/
|
||||||
exports.updateTask = asyncHandler(async (req, res) => {
|
exports.updateTask = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.task_id, 10);
|
const taskId = req.params.id;
|
||||||
|
const taskData = req.body;
|
||||||
if (isNaN(id)) {
|
|
||||||
throw new ApiError('유효하지 않은 작업 ID입니다.', 400);
|
if (!taskData.task_name) {
|
||||||
}
|
throw new ValidationError('작업명은 필수 입력 항목입니다');
|
||||||
|
|
||||||
const taskData = { ...req.body, task_id: id };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const changes = await new Promise((resolve, reject) => {
|
|
||||||
taskModel.update(taskData, (err, ch) => (err ? reject(err) : resolve(ch)));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changes === 0) {
|
|
||||||
handleNotFoundError('작업', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.updated({ changes }, '작업 정보가 성공적으로 수정되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업 수정');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('작업 수정 요청', { task_id: taskId });
|
||||||
|
|
||||||
|
await taskModel.updateTask(taskId, taskData);
|
||||||
|
|
||||||
|
logger.info('작업 수정 성공', { task_id: taskId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업이 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 삭제
|
/**
|
||||||
exports.removeTask = asyncHandler(async (req, res) => {
|
* 작업 삭제
|
||||||
const id = parseInt(req.params.task_id, 10);
|
*/
|
||||||
|
exports.deleteTask = asyncHandler(async (req, res) => {
|
||||||
if (isNaN(id)) {
|
const taskId = req.params.id;
|
||||||
throw new ApiError('유효하지 않은 작업 ID입니다.', 400);
|
|
||||||
}
|
logger.info('작업 삭제 요청', { task_id: taskId });
|
||||||
|
|
||||||
try {
|
await taskModel.deleteTask(taskId);
|
||||||
const changes = await new Promise((resolve, reject) => {
|
|
||||||
taskModel.remove(id, (err, ch) => (err ? reject(err) : resolve(ch)));
|
logger.info('작업 삭제 성공', { task_id: taskId });
|
||||||
});
|
|
||||||
|
res.json({
|
||||||
if (changes === 0) {
|
success: true,
|
||||||
handleNotFoundError('작업', id);
|
message: '작업이 성공적으로 삭제되었습니다'
|
||||||
}
|
});
|
||||||
|
});
|
||||||
res.deleted('작업이 성공적으로 삭제되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업 삭제');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
893
api.hyungi.net/controllers/tbmController.js
Normal file
893
api.hyungi.net/controllers/tbmController.js
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
// controllers/tbmController.js - TBM 시스템 컨트롤러
|
||||||
|
const TbmModel = require('../models/tbmModel');
|
||||||
|
|
||||||
|
const TbmController = {
|
||||||
|
// ==================== TBM 세션 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 생성
|
||||||
|
*/
|
||||||
|
createSession: (req, res) => {
|
||||||
|
const sessionData = {
|
||||||
|
session_date: req.body.session_date,
|
||||||
|
leader_id: req.body.leader_id || null,
|
||||||
|
project_id: req.body.project_id || null,
|
||||||
|
work_location: req.body.work_location || null,
|
||||||
|
work_description: req.body.work_description || null,
|
||||||
|
safety_notes: req.body.safety_notes || null,
|
||||||
|
start_time: req.body.start_time || null,
|
||||||
|
created_by: req.user.user_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용)
|
||||||
|
if (!sessionData.session_date) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 날짜는 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.createSession(sessionData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 세션 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'TBM 세션이 생성되었습니다.',
|
||||||
|
data: {
|
||||||
|
session_id: result.insertId,
|
||||||
|
...sessionData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 날짜의 TBM 세션 목록 조회
|
||||||
|
*/
|
||||||
|
getSessionsByDate: (req, res) => {
|
||||||
|
const { date } = req.params;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '날짜 정보가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.getSessionsByDate(date, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 세션 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 상세 조회
|
||||||
|
*/
|
||||||
|
getSessionById: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.getSessionById(sessionId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 세션 상세 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션 상세 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results[0]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 수정
|
||||||
|
*/
|
||||||
|
updateSession: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const sessionData = {
|
||||||
|
project_id: req.body.project_id,
|
||||||
|
work_location: req.body.work_location,
|
||||||
|
work_description: req.body.work_description,
|
||||||
|
safety_notes: req.body.safety_notes,
|
||||||
|
status: req.body.status || 'draft'
|
||||||
|
};
|
||||||
|
|
||||||
|
TbmModel.updateSession(sessionId, sessionData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 세션 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션 수정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'TBM 세션이 수정되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 완료 처리
|
||||||
|
*/
|
||||||
|
completeSession: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8);
|
||||||
|
|
||||||
|
TbmModel.completeSession(sessionId, endTime, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 세션 완료 처리 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 세션을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'TBM 세션이 완료되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 팀 구성 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팀원 추가 (작업자별 상세 정보 포함)
|
||||||
|
*/
|
||||||
|
addTeamMember: (req, res) => {
|
||||||
|
const assignmentData = {
|
||||||
|
session_id: req.params.sessionId,
|
||||||
|
worker_id: req.body.worker_id,
|
||||||
|
assigned_role: req.body.assigned_role || null,
|
||||||
|
work_detail: req.body.work_detail || null,
|
||||||
|
is_present: req.body.is_present,
|
||||||
|
absence_reason: req.body.absence_reason || null,
|
||||||
|
project_id: req.body.project_id || null,
|
||||||
|
work_type_id: req.body.work_type_id || null,
|
||||||
|
task_id: req.body.task_id || null,
|
||||||
|
workplace_category_id: req.body.workplace_category_id || null,
|
||||||
|
workplace_id: req.body.workplace_id || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!assignmentData.worker_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업자 ID가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.addTeamMember(assignmentData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('팀원 추가 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀원 추가 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '팀원이 추가되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팀 구성 일괄 추가
|
||||||
|
*/
|
||||||
|
addTeamMembers: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { members } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(members) || members.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀원 목록이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.addTeamMembers(sessionId, members, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('팀 구성 일괄 추가 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀 구성 추가 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${members.length}명의 팀원이 추가되었습니다.`,
|
||||||
|
data: { count: members.length }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션의 팀 구성 조회
|
||||||
|
*/
|
||||||
|
getTeamMembers: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.getTeamMembers(sessionId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('팀 구성 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀 구성 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팀원 제거
|
||||||
|
*/
|
||||||
|
removeTeamMember: (req, res) => {
|
||||||
|
const { sessionId, workerId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.removeTeamMember(sessionId, workerId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('팀원 제거 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀원 제거 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀원을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '팀원이 제거되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션의 모든 팀원 삭제 (수정 시 사용)
|
||||||
|
*/
|
||||||
|
clearAllTeamMembers: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.clearAllTeamMembers(sessionId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('팀원 전체 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '팀원 전체 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '모든 팀원이 삭제되었습니다.',
|
||||||
|
data: { deletedCount: result.affectedRows }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 안전 체크리스트 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 안전 체크 항목 조회
|
||||||
|
*/
|
||||||
|
getAllSafetyChecks: (req, res) => {
|
||||||
|
TbmModel.getAllSafetyChecks((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 항목 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션의 안전 체크 기록 조회
|
||||||
|
*/
|
||||||
|
getSafetyRecords: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.getSafetyRecords(sessionId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 기록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 기록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 체크 일괄 저장
|
||||||
|
*/
|
||||||
|
saveSafetyRecords: (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { records } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(records) || records.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 기록이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedBy = req.user.user_id;
|
||||||
|
|
||||||
|
TbmModel.saveSafetyRecords(sessionId, records, checkedBy, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 저장 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 저장 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전 체크가 저장되었습니다.',
|
||||||
|
data: { count: records.length }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 필터링된 안전 체크리스트 (확장) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션에 맞는 필터링된 안전 체크 항목 조회
|
||||||
|
* 기본 + 날씨 + 작업별 체크항목 통합
|
||||||
|
*/
|
||||||
|
getFilteredSafetyChecks: async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 날씨 정보 확인 (이미 저장된 경우 사용, 없으면 새로 조회)
|
||||||
|
const weatherService = require('../services/weatherService');
|
||||||
|
let weatherRecord = await weatherService.getWeatherRecord(sessionId);
|
||||||
|
let weatherConditions = [];
|
||||||
|
|
||||||
|
if (weatherRecord && weatherRecord.weather_conditions) {
|
||||||
|
weatherConditions = weatherRecord.weather_conditions;
|
||||||
|
} else {
|
||||||
|
// 날씨 정보가 없으면 현재 날씨 조회
|
||||||
|
const currentWeather = await weatherService.getCurrentWeather();
|
||||||
|
weatherConditions = await weatherService.determineWeatherConditions(currentWeather);
|
||||||
|
// 날씨 기록 저장
|
||||||
|
await weatherService.saveWeatherRecord(sessionId, currentWeather, weatherConditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.getFilteredSafetyChecks(sessionId, weatherConditions, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('필터링된 안전 체크 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('필터링된 안전 체크 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 날씨 조회
|
||||||
|
*/
|
||||||
|
getCurrentWeather: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const weatherService = require('../services/weatherService');
|
||||||
|
const { nx, ny } = req.query;
|
||||||
|
|
||||||
|
const weatherData = await weatherService.getCurrentWeather(nx, ny);
|
||||||
|
const conditions = await weatherService.determineWeatherConditions(weatherData);
|
||||||
|
const conditionList = await weatherService.getWeatherConditionList();
|
||||||
|
|
||||||
|
// 현재 조건의 상세 정보 매핑
|
||||||
|
const activeConditions = conditionList.filter(c => conditions.includes(c.condition_code));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...weatherData,
|
||||||
|
conditions,
|
||||||
|
conditionDetails: activeConditions
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('날씨 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '날씨 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 날씨 정보 저장
|
||||||
|
*/
|
||||||
|
saveSessionWeather: async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { weatherConditions } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const weatherService = require('../services/weatherService');
|
||||||
|
|
||||||
|
// 현재 날씨 조회
|
||||||
|
const weatherData = await weatherService.getCurrentWeather();
|
||||||
|
const conditions = weatherConditions || await weatherService.determineWeatherConditions(weatherData);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
await weatherService.saveWeatherRecord(sessionId, weatherData, conditions);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '날씨 정보가 저장되었습니다.',
|
||||||
|
data: { conditions }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('날씨 저장 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '날씨 저장 중 오류가 발생했습니다.',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 날씨 정보 조회
|
||||||
|
*/
|
||||||
|
getSessionWeather: async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const weatherService = require('../services/weatherService');
|
||||||
|
const weatherRecord = await weatherService.getWeatherRecord(sessionId);
|
||||||
|
|
||||||
|
if (!weatherRecord) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '날씨 기록이 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: weatherRecord
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('날씨 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '날씨 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날씨 조건 목록 조회
|
||||||
|
*/
|
||||||
|
getWeatherConditions: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const weatherService = require('../services/weatherService');
|
||||||
|
const conditions = await weatherService.getWeatherConditionList();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: conditions
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('날씨 조건 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '날씨 조건 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 안전 체크항목 관리 (관리자용) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 체크 항목 생성
|
||||||
|
*/
|
||||||
|
createSafetyCheck: (req, res) => {
|
||||||
|
const checkData = req.body;
|
||||||
|
|
||||||
|
if (!checkData.check_category || !checkData.check_item) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '카테고리와 체크 항목은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.createSafetyCheck(checkData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 항목 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '안전 체크 항목이 생성되었습니다.',
|
||||||
|
data: { check_id: result.insertId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 체크 항목 수정
|
||||||
|
*/
|
||||||
|
updateSafetyCheck: (req, res) => {
|
||||||
|
const { checkId } = req.params;
|
||||||
|
const checkData = req.body;
|
||||||
|
|
||||||
|
TbmModel.updateSafetyCheck(checkId, checkData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 항목 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목 수정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전 체크 항목이 수정되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 체크 항목 삭제 (비활성화)
|
||||||
|
*/
|
||||||
|
deleteSafetyCheck: (req, res) => {
|
||||||
|
const { checkId } = req.params;
|
||||||
|
|
||||||
|
TbmModel.deleteSafetyCheck(checkId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전 체크 항목 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전 체크 항목을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전 체크 항목이 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 작업 인계 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 인계 생성
|
||||||
|
*/
|
||||||
|
createHandover: (req, res) => {
|
||||||
|
const handoverData = {
|
||||||
|
session_id: req.body.session_id,
|
||||||
|
from_leader_id: req.body.from_leader_id,
|
||||||
|
to_leader_id: req.body.to_leader_id,
|
||||||
|
handover_date: req.body.handover_date,
|
||||||
|
handover_time: req.body.handover_time || null,
|
||||||
|
reason: req.body.reason,
|
||||||
|
handover_notes: req.body.handover_notes || null,
|
||||||
|
worker_ids: req.body.worker_ids || []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!handoverData.session_id || !handoverData.from_leader_id ||
|
||||||
|
!handoverData.to_leader_id || !handoverData.handover_date || !handoverData.reason) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 정보가 누락되었습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.createHandover(handoverData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('작업 인계 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업 인계 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '작업 인계가 생성되었습니다.',
|
||||||
|
data: { handover_id: result.insertId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 인계 확인
|
||||||
|
*/
|
||||||
|
confirmHandover: (req, res) => {
|
||||||
|
const { handoverId } = req.params;
|
||||||
|
const confirmedBy = req.user.user_id;
|
||||||
|
|
||||||
|
TbmModel.confirmHandover(handoverId, confirmedBy, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('작업 인계 확인 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업 인계 확인 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업 인계 건을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업 인계가 확인되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 날짜의 작업 인계 목록 조회
|
||||||
|
*/
|
||||||
|
getHandoversByDate: (req, res) => {
|
||||||
|
const { date } = req.params;
|
||||||
|
|
||||||
|
TbmModel.getHandoversByDate(date, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('작업 인계 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업 인계 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 나에게 온 미확인 인계 건 조회
|
||||||
|
*/
|
||||||
|
getMyPendingHandovers: (req, res) => {
|
||||||
|
// worker_id는 req.user에서 가져옴
|
||||||
|
const toLeaderId = req.user.worker_id;
|
||||||
|
|
||||||
|
if (!toLeaderId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '작업자 정보를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.getPendingHandovers(toLeaderId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('미확인 인계 건 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '미확인 인계 건 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 통계 및 리포트 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 통계 조회
|
||||||
|
*/
|
||||||
|
getTbmStatistics: (req, res) => {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '시작일과 종료일이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.getTbmStatistics(startDate, endDate, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('TBM 통계 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'TBM 통계 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리더별 TBM 진행 현황 조회
|
||||||
|
*/
|
||||||
|
getLeaderStatistics: (req, res) => {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '시작일과 종료일이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TbmModel.getLeaderStatistics(startDate, endDate, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('리더 통계 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '리더 통계 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업보고서가 작성되지 않은 TBM 팀 배정 조회
|
||||||
|
*/
|
||||||
|
getIncompleteWorkReports: (req, res) => {
|
||||||
|
const userId = req.user.user_id;
|
||||||
|
const accessLevel = req.user.access_level;
|
||||||
|
|
||||||
|
// 관리자는 모든 TBM 조회, 일반 사용자는 본인이 작성한 것만 조회
|
||||||
|
const filterUserId = (accessLevel === 'system' || accessLevel === 'admin') ? null : userId;
|
||||||
|
|
||||||
|
TbmModel.getIncompleteWorkReports(filterUserId, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('미완료 작업보고서 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '미완료 작업보고서 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = TbmController;
|
||||||
@@ -1,76 +1,75 @@
|
|||||||
const Tools = require('../models/toolsModel');
|
/**
|
||||||
|
* 도구 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 도구(공구) 재고 및 위치 관리 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. 전체 도구 조회
|
const toolsService = require('../services/toolsService');
|
||||||
exports.getAll = async (req, res) => {
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
try {
|
|
||||||
const rows = await new Promise((resolve, reject) => {
|
|
||||||
Tools.getAllTools((err, data) => err ? reject(err) : resolve(data));
|
|
||||||
});
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 단일 도구 조회
|
/**
|
||||||
exports.getById = async (req, res) => {
|
* 전체 도구 조회
|
||||||
try {
|
*/
|
||||||
const id = parseInt(req.params.id, 10);
|
exports.getAll = asyncHandler(async (req, res) => {
|
||||||
const row = await new Promise((resolve, reject) => {
|
const rows = await toolsService.getAllToolsService();
|
||||||
Tools.getToolById(id, (err, data) => err ? reject(err) : resolve(data));
|
|
||||||
});
|
|
||||||
if (!row) return res.status(404).json({ error: 'Tool not found' });
|
|
||||||
res.json(row);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 도구 생성
|
res.json({
|
||||||
exports.create = async (req, res) => {
|
success: true,
|
||||||
try {
|
data: rows,
|
||||||
const insertId = await new Promise((resolve, reject) => {
|
message: '도구 목록 조회 성공'
|
||||||
Tools.createTool(req.body, (err, resultId) => {
|
});
|
||||||
if (err) return reject(err);
|
});
|
||||||
resolve(resultId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
res.status(201).json({ success: true, id: insertId });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. 도구 수정
|
/**
|
||||||
exports.update = async (req, res) => {
|
* 단일 도구 조회
|
||||||
try {
|
*/
|
||||||
const id = parseInt(req.params.id, 10);
|
exports.getById = asyncHandler(async (req, res) => {
|
||||||
const changedRows = await new Promise((resolve, reject) => {
|
const id = parseInt(req.params.id, 10);
|
||||||
Tools.updateTool(id, req.body, (err, affectedRows) => {
|
const row = await toolsService.getToolByIdService(id);
|
||||||
if (err) return reject(err);
|
|
||||||
resolve(affectedRows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (changedRows === 0) return res.status(404).json({ error: 'Tool not found or no change' });
|
|
||||||
res.json({ success: true, changes: changedRows });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. 도구 삭제
|
res.json({
|
||||||
exports.delete = async (req, res) => {
|
success: true,
|
||||||
try {
|
data: row,
|
||||||
const id = parseInt(req.params.id, 10);
|
message: '도구 조회 성공'
|
||||||
const deletedRows = await new Promise((resolve, reject) => {
|
});
|
||||||
Tools.deleteTool(id, (err, affectedRows) => {
|
});
|
||||||
if (err) return reject(err);
|
|
||||||
resolve(affectedRows);
|
/**
|
||||||
});
|
* 도구 생성
|
||||||
});
|
*/
|
||||||
if (deletedRows === 0) return res.status(404).json({ error: 'Tool not found' });
|
exports.create = asyncHandler(async (req, res) => {
|
||||||
res.status(204).send();
|
const result = await toolsService.createToolService(req.body);
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
res.status(201).json({
|
||||||
}
|
success: true,
|
||||||
};
|
data: result,
|
||||||
|
message: '도구가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도구 수정
|
||||||
|
*/
|
||||||
|
exports.update = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await toolsService.updateToolService(id, req.body);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '도구 정보가 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도구 삭제
|
||||||
|
*/
|
||||||
|
exports.delete = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
await toolsService.deleteToolService(id);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
const uploadModel = require('../models/uploadModel');
|
/**
|
||||||
|
* 문서 업로드 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 파일 업로드 및 문서 메타데이터 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. 문서 업로드
|
const uploadService = require('../services/uploadService');
|
||||||
exports.createUpload = async (req, res) => {
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
try {
|
|
||||||
const doc = req.body;
|
|
||||||
const id = await new Promise((resolve, reject) => {
|
|
||||||
uploadModel.create(doc, (err, insertId) => (err ? reject(err) : resolve(insertId)));
|
|
||||||
});
|
|
||||||
res.status(201).json({ success: true, id });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 전체 업로드 문서 조회
|
/**
|
||||||
exports.getUploads = async (req, res) => {
|
* 문서 업로드
|
||||||
try {
|
*/
|
||||||
const rows = await new Promise((resolve, reject) => {
|
exports.createUpload = asyncHandler(async (req, res) => {
|
||||||
uploadModel.getAll((err, data) => (err ? reject(err) : resolve(data)));
|
const doc = req.body;
|
||||||
});
|
const result = await uploadService.createUploadService(doc);
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
res.status(201).json({
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
success: true,
|
||||||
}
|
data: result,
|
||||||
};
|
message: '문서가 성공적으로 업로드되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 업로드 문서 조회
|
||||||
|
*/
|
||||||
|
exports.getUploads = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await uploadService.getAllUploadsService();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '업로드 문서 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
739
api.hyungi.net/controllers/userController.js
Normal file
739
api.hyungi.net/controllers/userController.js
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
/**
|
||||||
|
* 사용자 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 사용자 CRUD 및 상태 관리 기능을 제공하는 컨트롤러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const { ValidationError, ForbiddenError, NotFoundError, ConflictError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한 확인 헬퍼 함수
|
||||||
|
*/
|
||||||
|
const checkAdminPermission = (user) => {
|
||||||
|
if (!user || !['admin', 'system'].includes(user.access_level)) {
|
||||||
|
throw new ForbiddenError('관리자 권한이 필요합니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 사용자 조회
|
||||||
|
*/
|
||||||
|
const getAllUsers = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
logger.info('사용자 목록 조회 요청', { requestedBy: req.user?.username });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.role_id,
|
||||||
|
r.name as role,
|
||||||
|
u._access_level_old as access_level,
|
||||||
|
u.is_active,
|
||||||
|
u.worker_id,
|
||||||
|
w.worker_name,
|
||||||
|
w.department_id,
|
||||||
|
d.department_name,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
u.last_login_at as last_login
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
LEFT JOIN workers w ON u.worker_id = w.worker_id
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [users] = await db.execute(query);
|
||||||
|
|
||||||
|
logger.info('사용자 목록 조회 성공', { count: users.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
message: '사용자 목록 조회 성공'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('사용자 목록 조회 실패', { error: error.message });
|
||||||
|
throw new DatabaseError('사용자 목록을 조회하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 사용자 조회
|
||||||
|
*/
|
||||||
|
const getUserById = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 조회 요청', { userId: id });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
role,
|
||||||
|
access_level,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_login
|
||||||
|
FROM users
|
||||||
|
WHERE user_id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [users] = await db.execute(query, [id]);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 조회 성공', { userId: id, username: users[0].username });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: users[0],
|
||||||
|
message: '사용자 조회 성공'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 조회 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('사용자를 조회하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 사용자 생성
|
||||||
|
*/
|
||||||
|
const createUser = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { username, name, email, phone, role, password } = req.body;
|
||||||
|
|
||||||
|
logger.info('사용자 생성 요청', { username, name, role });
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!username || !name || !role || !password) {
|
||||||
|
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||||
|
required: ['username', 'name', 'role', 'password'],
|
||||||
|
received: { username, name, role, password: '***' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자명 유효성 검증
|
||||||
|
if (username.length < 3 || username.length > 20) {
|
||||||
|
throw new ValidationError('사용자명은 3-20자 사이여야 합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 유효성 검증
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 레벨 검증
|
||||||
|
const validRoles = ['admin', 'group_leader', 'worker'];
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
throw new ValidationError('유효하지 않은 권한입니다', {
|
||||||
|
valid: validRoles,
|
||||||
|
received: role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자명 중복 확인
|
||||||
|
const checkQuery = 'SELECT user_id FROM users WHERE username = ?';
|
||||||
|
const [existing] = await db.execute(checkQuery, [username]);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new ConflictError('이미 존재하는 사용자명입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 해시화
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// 사용자 생성
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO users (username, name, email, phone, role, access_level, password_hash, is_active, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW())
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [result] = await db.execute(insertQuery, [
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
email || null,
|
||||||
|
phone || null,
|
||||||
|
role,
|
||||||
|
role, // access_level을 role과 동일하게 설정
|
||||||
|
hashedPassword
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('사용자 생성 성공', {
|
||||||
|
userId: result.insertId,
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
createdBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: result.insertId },
|
||||||
|
message: '사용자가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConflictError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 생성 실패', { username, error: error.message });
|
||||||
|
throw new DatabaseError('사용자를 생성하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 정보 수정
|
||||||
|
*/
|
||||||
|
const updateUser = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username, name, email, role, role_id, password, worker_id } = req.body;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 수정 요청', { userId: id, body: req.body });
|
||||||
|
|
||||||
|
// 최소 하나의 수정 필드가 필요
|
||||||
|
if (!username && !name && email === undefined && !role && !role_id && !password && worker_id === undefined) {
|
||||||
|
throw new ValidationError('수정할 필드가 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
|
||||||
|
const [existing] = await db.execute(checkQuery, [id]);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing[0].is_active === 0) {
|
||||||
|
throw new ValidationError('비활성화된 사용자는 수정할 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트할 필드들
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
if (username.length < 3 || username.length > 20) {
|
||||||
|
throw new ValidationError('사용자명은 3-20자 사이여야 합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자명 중복 확인 (자신 제외)
|
||||||
|
const dupQuery = 'SELECT user_id FROM users WHERE username = ? AND user_id != ?';
|
||||||
|
const [duplicate] = await db.execute(dupQuery, [username, id]);
|
||||||
|
|
||||||
|
if (duplicate.length > 0) {
|
||||||
|
throw new ConflictError('이미 존재하는 사용자명입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('username = ?');
|
||||||
|
values.push(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
updates.push('name = ?');
|
||||||
|
values.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email !== undefined) {
|
||||||
|
updates.push('email = ?');
|
||||||
|
values.push(email || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// role_id 또는 role 문자열 처리
|
||||||
|
if (role_id) {
|
||||||
|
// role_id가 유효한지 확인
|
||||||
|
const [roleCheck] = await db.execute('SELECT id, name FROM roles WHERE id = ?', [role_id]);
|
||||||
|
if (roleCheck.length === 0) {
|
||||||
|
throw new ValidationError('유효하지 않은 역할 ID입니다');
|
||||||
|
}
|
||||||
|
updates.push('role_id = ?');
|
||||||
|
values.push(role_id);
|
||||||
|
logger.info('role_id로 역할 변경', { userId: id, role_id, role_name: roleCheck[0].name });
|
||||||
|
} else if (role) {
|
||||||
|
// role 문자열을 role_id로 변환 (하위 호환성)
|
||||||
|
const roleNameMap = {
|
||||||
|
'admin': 'Admin',
|
||||||
|
'system': 'System Admin',
|
||||||
|
'user': 'User',
|
||||||
|
'guest': 'Guest',
|
||||||
|
'group_leader': 'User', // 임시 매핑
|
||||||
|
'worker': 'User' // 임시 매핑
|
||||||
|
};
|
||||||
|
const roleName = roleNameMap[role.toLowerCase()] || role;
|
||||||
|
const [roleCheck] = await db.execute('SELECT id FROM roles WHERE name = ?', [roleName]);
|
||||||
|
|
||||||
|
if (roleCheck.length === 0) {
|
||||||
|
throw new ValidationError(`유효하지 않은 권한입니다: ${role}`);
|
||||||
|
}
|
||||||
|
updates.push('role_id = ?');
|
||||||
|
values.push(roleCheck[0].id);
|
||||||
|
logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
|
||||||
|
}
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
updates.push('password = ?');
|
||||||
|
values.push(hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker_id 업데이트 (null도 허용 - 연결 해제)
|
||||||
|
if (worker_id !== undefined) {
|
||||||
|
if (worker_id !== null) {
|
||||||
|
// worker_id가 유효한지 확인
|
||||||
|
const [workerCheck] = await db.execute('SELECT worker_id, worker_name FROM workers WHERE worker_id = ?', [worker_id]);
|
||||||
|
if (workerCheck.length === 0) {
|
||||||
|
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||||
|
}
|
||||||
|
logger.info('작업자 연결', { userId: id, worker_id, worker_name: workerCheck[0].worker_name });
|
||||||
|
} else {
|
||||||
|
logger.info('작업자 연결 해제', { userId: id });
|
||||||
|
}
|
||||||
|
updates.push('worker_id = ?');
|
||||||
|
values.push(worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE users SET ${updates.join(', ')} WHERE user_id = ?`;
|
||||||
|
|
||||||
|
logger.info('실행할 UPDATE 쿼리', { query: updateQuery, values });
|
||||||
|
await db.execute(updateQuery, values);
|
||||||
|
|
||||||
|
logger.info('사용자 수정 성공', {
|
||||||
|
userId: id,
|
||||||
|
username: existing[0].username,
|
||||||
|
updatedFields: Object.keys(req.body),
|
||||||
|
updatedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: id },
|
||||||
|
message: '사용자 정보가 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 수정 실패', { userId: id, error: error.message, stack: error.stack });
|
||||||
|
throw new DatabaseError('사용자 정보를 수정하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 상태 변경 (활성화/비활성화)
|
||||||
|
*/
|
||||||
|
const updateUserStatus = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { is_active } = req.body;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_active === undefined || ![0, 1, true, false].includes(is_active)) {
|
||||||
|
throw new ValidationError('유효하지 않은 활성 상태 값입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeValue = is_active === true || is_active === 1 ? 1 : 0;
|
||||||
|
|
||||||
|
// 자기 자신 비활성화 방지
|
||||||
|
if (parseInt(id) === req.user.user_id && activeValue === 0) {
|
||||||
|
throw new ValidationError('자기 자신을 비활성화할 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 상태 변경 요청', { userId: id, is_active: activeValue });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
|
||||||
|
const [users] = await db.execute(checkQuery, [id]);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 변경이 필요한지 확인
|
||||||
|
if (users[0].is_active === activeValue) {
|
||||||
|
const status = activeValue === 1 ? '활성' : '비활성';
|
||||||
|
throw new ValidationError(`사용자가 이미 ${status} 상태입니다`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = 'UPDATE users SET is_active = ?, updated_at = NOW() WHERE user_id = ?';
|
||||||
|
await db.execute(query, [activeValue, id]);
|
||||||
|
|
||||||
|
const statusText = activeValue === 1 ? '활성화' : '비활성화';
|
||||||
|
|
||||||
|
logger.info(`사용자 ${statusText} 성공`, {
|
||||||
|
userId: id,
|
||||||
|
username: users[0].username,
|
||||||
|
newStatus: activeValue,
|
||||||
|
updatedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: id, is_active: activeValue },
|
||||||
|
message: `사용자가 성공적으로 ${statusText}되었습니다`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 상태 변경 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('사용자 상태를 변경하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 삭제 (Soft Delete)
|
||||||
|
*/
|
||||||
|
const deleteUser = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자기 자신 삭제 방지
|
||||||
|
if (req.user && req.user.user_id == id) {
|
||||||
|
throw new ValidationError('자기 자신은 삭제할 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 삭제 요청', { userId: id });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
|
||||||
|
const [users] = await db.execute(checkQuery, [id]);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users[0].is_active === 0) {
|
||||||
|
throw new ValidationError('이미 비활성화된 사용자입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft Delete (is_active = 0)
|
||||||
|
const query = 'UPDATE users SET is_active = 0, updated_at = NOW() WHERE user_id = ?';
|
||||||
|
await db.execute(query, [id]);
|
||||||
|
|
||||||
|
logger.info('사용자 비활성화 성공', {
|
||||||
|
userId: id,
|
||||||
|
username: users[0].username,
|
||||||
|
deletedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: id },
|
||||||
|
message: '사용자가 성공적으로 비활성화되었습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 비활성화 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('사용자를 비활성화하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 영구 삭제 (Hard Delete)
|
||||||
|
*/
|
||||||
|
const permanentDeleteUser = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자기 자신 삭제 방지
|
||||||
|
if (req.user && req.user.user_id == id) {
|
||||||
|
throw new ValidationError('자기 자신은 삭제할 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 영구 삭제 요청', { userId: id });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const checkQuery = 'SELECT user_id, username FROM users WHERE user_id = ?';
|
||||||
|
const [users] = await db.execute(checkQuery, [id]);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = users[0].username;
|
||||||
|
|
||||||
|
// 관련 데이터 삭제 (외래 키 제약 조건 때문에 순서 중요)
|
||||||
|
// 1. 로그인 로그 삭제
|
||||||
|
await db.execute('DELETE FROM login_logs WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
|
// 2. 페이지 접근 권한 삭제
|
||||||
|
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
|
// 3. 사용자 삭제
|
||||||
|
await db.execute('DELETE FROM users WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
|
logger.info('사용자 영구 삭제 성공', {
|
||||||
|
userId: id,
|
||||||
|
username: username,
|
||||||
|
deletedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: id },
|
||||||
|
message: `사용자 "${username}"이(가) 영구적으로 삭제되었습니다`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('사용자 영구 삭제 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('사용자를 영구 삭제하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 페이지 접근 권한 조회
|
||||||
|
*/
|
||||||
|
const getUserPageAccess = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 페이지 권한 조회 요청', { userId: id });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.id as page_id,
|
||||||
|
p.page_key,
|
||||||
|
p.page_name,
|
||||||
|
p.page_path,
|
||||||
|
p.category,
|
||||||
|
p.is_default_accessible,
|
||||||
|
COALESCE(upa.can_access, p.is_default_accessible) as can_access
|
||||||
|
FROM pages p
|
||||||
|
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
||||||
|
ORDER BY p.category, p.display_order
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [pageAccess] = await db.execute(query, [id]);
|
||||||
|
|
||||||
|
logger.info('사용자 페이지 권한 조회 성공', { userId: id, pageCount: pageAccess.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pageAccess
|
||||||
|
},
|
||||||
|
message: '페이지 권한 조회 성공'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('사용자 페이지 권한 조회 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('페이지 권한을 조회하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 페이지 접근 권한 업데이트
|
||||||
|
*/
|
||||||
|
const updateUserPageAccess = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { pageAccess } = req.body;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pageAccess)) {
|
||||||
|
throw new ValidationError('pageAccess는 배열이어야 합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('사용자 페이지 권한 업데이트 요청', {
|
||||||
|
userId: id,
|
||||||
|
pageCount: pageAccess.length,
|
||||||
|
updatedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 트랜잭션 시작
|
||||||
|
await db.query('START TRANSACTION');
|
||||||
|
|
||||||
|
// 기존 권한 삭제
|
||||||
|
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
|
// 새 권한 삽입
|
||||||
|
if (pageAccess.length > 0) {
|
||||||
|
const values = pageAccess.map(p => [id, p.page_id, p.can_access]);
|
||||||
|
const placeholders = values.map(() => '(?, ?, ?)').join(', ');
|
||||||
|
const flatValues = values.flat();
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO user_page_access (user_id, page_id, can_access) VALUES ${placeholders}`,
|
||||||
|
flatValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커밋
|
||||||
|
await db.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('사용자 페이지 권한 업데이트 성공', {
|
||||||
|
userId: id,
|
||||||
|
pageCount: pageAccess.length,
|
||||||
|
updatedBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user_id: id },
|
||||||
|
message: '페이지 권한이 성공적으로 업데이트되었습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 롤백
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
logger.error('사용자 페이지 권한 업데이트 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('페이지 권한을 업데이트하는데 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 비밀번호 초기화 (000000)
|
||||||
|
*/
|
||||||
|
const resetUserPassword = asyncHandler(async (req, res) => {
|
||||||
|
checkAdminPermission(req.user);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const [existing] = await db.execute('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호를 000000으로 초기화
|
||||||
|
const hashedPassword = await bcrypt.hash('000000', 10);
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
|
||||||
|
[hashedPassword, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('사용자 비밀번호 초기화 성공', {
|
||||||
|
userId: id,
|
||||||
|
username: existing[0].username,
|
||||||
|
resetBy: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '비밀번호가 000000으로 초기화되었습니다'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('비밀번호 초기화 실패', { userId: id, error: error.message });
|
||||||
|
throw new DatabaseError('비밀번호 초기화에 실패했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllUsers,
|
||||||
|
getUserById,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
updateUserStatus,
|
||||||
|
deleteUser,
|
||||||
|
permanentDeleteUser,
|
||||||
|
getUserPageAccess,
|
||||||
|
updateUserPageAccess,
|
||||||
|
resetUserPassword
|
||||||
|
};
|
||||||
421
api.hyungi.net/controllers/vacationBalanceController.js
Normal file
421
api.hyungi.net/controllers/vacationBalanceController.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* vacationBalanceController.js
|
||||||
|
* 휴가 잔액 관련 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
const vacationTypeModel = require('../models/vacationTypeModel');
|
||||||
|
|
||||||
|
const vacationBalanceController = {
|
||||||
|
/**
|
||||||
|
* 특정 작업자의 휴가 잔액 조회 (특정 연도)
|
||||||
|
* GET /api/vacation-balances/worker/:workerId/year/:year
|
||||||
|
*/
|
||||||
|
async getByWorkerAndYear(req, res) {
|
||||||
|
try {
|
||||||
|
const { workerId, year } = req.params;
|
||||||
|
|
||||||
|
vacationBalanceModel.getByWorkerAndYear(workerId, year, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 잔액 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getByWorkerAndYear 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
|
||||||
|
* GET /api/vacation-balances/year/:year
|
||||||
|
*/
|
||||||
|
async getAllByYear(req, res) {
|
||||||
|
try {
|
||||||
|
const { year } = req.params;
|
||||||
|
|
||||||
|
vacationBalanceModel.getAllByYear(year, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('전체 휴가 잔액 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '전체 휴가 잔액을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAllByYear 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 잔액 생성
|
||||||
|
* POST /api/vacation-balances
|
||||||
|
*/
|
||||||
|
async createBalance(req, res) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
worker_id,
|
||||||
|
vacation_type_id,
|
||||||
|
year,
|
||||||
|
total_days,
|
||||||
|
used_days,
|
||||||
|
notes
|
||||||
|
} = req.body;
|
||||||
|
const created_by = req.user.user_id;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다 (worker_id, vacation_type_id, year, total_days)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
vacationBalanceModel.getByWorkerTypeYear(worker_id, vacation_type_id, year, (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('중복 체크 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '중복 체크 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 해당 작업자의 해당 연도 휴가 잔액이 존재합니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceData = {
|
||||||
|
worker_id,
|
||||||
|
vacation_type_id,
|
||||||
|
year,
|
||||||
|
total_days,
|
||||||
|
used_days: used_days || 0,
|
||||||
|
notes: notes || null,
|
||||||
|
created_by
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationBalanceModel.create(balanceData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 잔액 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 잔액이 생성되었습니다',
|
||||||
|
data: { id: result.insertId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('createBalance 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 잔액 수정
|
||||||
|
* PUT /api/vacation-balances/:id
|
||||||
|
*/
|
||||||
|
async updateBalance(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { total_days, used_days, notes } = req.body;
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (total_days !== undefined) updateData.total_days = total_days;
|
||||||
|
if (used_days !== undefined) updateData.used_days = used_days;
|
||||||
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
updateData.updated_at = new Date();
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '수정할 데이터가 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vacationBalanceModel.update(id, updateData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 잔액 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 수정하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 잔액이 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateBalance 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 잔액 삭제
|
||||||
|
* DELETE /api/vacation-balances/:id
|
||||||
|
*/
|
||||||
|
async deleteBalance(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
vacationBalanceModel.delete(id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 잔액 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 삭제하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 잔액이 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteBalance 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근속년수 기반 연차 자동 계산 및 생성
|
||||||
|
* POST /api/vacation-balances/auto-calculate
|
||||||
|
*/
|
||||||
|
async autoCalculateAndCreate(req, res) {
|
||||||
|
try {
|
||||||
|
const { worker_id, hire_date, year } = req.body;
|
||||||
|
const created_by = req.user.user_id;
|
||||||
|
|
||||||
|
if (!worker_id || !hire_date || !year) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다 (worker_id, hire_date, year)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연차 일수 계산
|
||||||
|
const annualDays = vacationBalanceModel.calculateAnnualLeaveDays(hire_date, year);
|
||||||
|
|
||||||
|
// ANNUAL 휴가 유형 ID 조회
|
||||||
|
vacationTypeModel.getByCode('ANNUAL', (err, types) => {
|
||||||
|
if (err || !types || types.length === 0) {
|
||||||
|
console.error('ANNUAL 휴가 유형 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'ANNUAL 휴가 유형을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const annualTypeId = types[0].id;
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
vacationBalanceModel.getByWorkerTypeYear(worker_id, annualTypeId, year, (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('중복 체크 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '중복 체크 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 해당 작업자의 해당 연도 연차가 존재합니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceData = {
|
||||||
|
worker_id,
|
||||||
|
vacation_type_id: annualTypeId,
|
||||||
|
year,
|
||||||
|
total_days: annualDays,
|
||||||
|
used_days: 0,
|
||||||
|
notes: `근속년수 기반 자동 계산 (입사일: ${hire_date})`,
|
||||||
|
created_by
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationBalanceModel.create(balanceData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 잔액 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: `${annualDays}일의 연차가 자동으로 생성되었습니다`,
|
||||||
|
data: {
|
||||||
|
id: result.insertId,
|
||||||
|
calculated_days: annualDays
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('autoCalculateAndCreate 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 잔액 일괄 저장 (upsert)
|
||||||
|
* POST /api/vacation-balances/bulk-upsert
|
||||||
|
*/
|
||||||
|
async bulkUpsert(req, res) {
|
||||||
|
try {
|
||||||
|
const { balances } = req.body;
|
||||||
|
const created_by = req.user.user_id;
|
||||||
|
|
||||||
|
if (!balances || !Array.isArray(balances) || balances.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '저장할 데이터가 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const balance of balances) {
|
||||||
|
const { worker_id, vacation_type_id, year, total_days, notes } = balance;
|
||||||
|
|
||||||
|
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upsert 쿼리
|
||||||
|
const query = `
|
||||||
|
INSERT INTO vacation_balance_details
|
||||||
|
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
total_days = VALUES(total_days),
|
||||||
|
notes = VALUES(notes),
|
||||||
|
updated_at = NOW()
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.query(query, [worker_id, vacation_type_id, year, total_days, notes || null, created_by]);
|
||||||
|
successCount++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('휴가 잔액 저장 오류:', err);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${successCount}건 저장 완료${errorCount > 0 ? `, ${errorCount}건 실패` : ''}`,
|
||||||
|
data: { successCount, errorCount }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('bulkUpsert 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자의 사용 가능한 휴가 일수 조회
|
||||||
|
* GET /api/vacation-balances/worker/:workerId/year/:year/available
|
||||||
|
*/
|
||||||
|
async getAvailableDays(req, res) {
|
||||||
|
try {
|
||||||
|
const { workerId, year } = req.params;
|
||||||
|
|
||||||
|
vacationBalanceModel.getAvailableVacationDays(workerId, year, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('사용 가능 휴가 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '사용 가능 휴가를 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAvailableDays 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = vacationBalanceController;
|
||||||
565
api.hyungi.net/controllers/vacationRequestController.js
Normal file
565
api.hyungi.net/controllers/vacationRequestController.js
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
/**
|
||||||
|
* vacationRequestController.js
|
||||||
|
* 휴가 신청 관련 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
const vacationRequestModel = require('../models/vacationRequestModel');
|
||||||
|
// TODO: workerVacationBalanceModel 구현 필요
|
||||||
|
// const workerVacationBalanceModel = require('../models/workerVacationBalanceModel');
|
||||||
|
|
||||||
|
const vacationRequestController = {
|
||||||
|
/**
|
||||||
|
* 휴가 신청 생성
|
||||||
|
*/
|
||||||
|
async createRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { worker_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body;
|
||||||
|
const requested_by = req.user.user_id;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!worker_id || !vacation_type_id || !start_date || !end_date || !days_used) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 유효성 검증
|
||||||
|
const startDate = new Date(start_date);
|
||||||
|
const endDate = new Date(end_date);
|
||||||
|
|
||||||
|
if (endDate < startDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '종료일은 시작일보다 이후여야 합니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기간 중복 체크
|
||||||
|
vacationRequestModel.checkOverlap(worker_id, start_date, end_date, null, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('기간 중복 체크 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '기간 중복 체크 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results[0].count > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 기간에 이미 신청된 휴가가 있습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 잔여 연차 확인 로직 구현 필요
|
||||||
|
// 현재는 잔여 연차 확인 없이 신청 가능
|
||||||
|
|
||||||
|
// 휴가 신청 생성
|
||||||
|
const requestData = {
|
||||||
|
worker_id,
|
||||||
|
vacation_type_id,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
days_used,
|
||||||
|
reason: reason || null,
|
||||||
|
status: 'pending',
|
||||||
|
requested_by
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationRequestModel.create(requestData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 생성 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 완료되었습니다',
|
||||||
|
data: {
|
||||||
|
request_id: result.insertId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 신청 생성 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 신청 목록 조회
|
||||||
|
*/
|
||||||
|
async getAllRequests(req, res) {
|
||||||
|
try {
|
||||||
|
const filters = {
|
||||||
|
worker_id: req.query.worker_id,
|
||||||
|
status: req.query.status,
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
vacation_type_id: req.query.vacation_type_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일반 사용자는 자신의 신청만 조회 가능
|
||||||
|
if (req.user.access_level !== 'system') {
|
||||||
|
if (req.user.worker_id) {
|
||||||
|
filters.worker_id = req.user.worker_id;
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '권한이 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vacationRequestModel.getAll(filters, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 목록 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 신청 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 휴가 신청 조회
|
||||||
|
*/
|
||||||
|
async getRequestById(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
vacationRequestModel.getById(id, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = results[0];
|
||||||
|
|
||||||
|
// 권한 검증: 관리자 또는 본인만 조회 가능
|
||||||
|
if (req.user.access_level !== 'system' && req.user.worker_id !== request.worker_id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '권한이 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: request
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 신청 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 신청 수정 (대기 중인 신청만)
|
||||||
|
*/
|
||||||
|
async updateRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { start_date, end_date, days_used, reason } = req.body;
|
||||||
|
|
||||||
|
// 기존 신청 조회
|
||||||
|
vacationRequestModel.getById(id, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRequest = results[0];
|
||||||
|
|
||||||
|
// 권한 검증
|
||||||
|
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '권한이 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대기 중인 신청만 수정 가능
|
||||||
|
if (existingRequest.status !== 'pending') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '승인/거부된 신청은 수정할 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (start_date) updateData.start_date = start_date;
|
||||||
|
if (end_date) updateData.end_date = end_date;
|
||||||
|
if (days_used) updateData.days_used = days_used;
|
||||||
|
if (reason !== undefined) updateData.reason = reason;
|
||||||
|
|
||||||
|
// 날짜가 변경된 경우 중복 체크
|
||||||
|
if (start_date || end_date) {
|
||||||
|
const newStartDate = start_date || existingRequest.start_date;
|
||||||
|
const newEndDate = end_date || existingRequest.end_date;
|
||||||
|
|
||||||
|
vacationRequestModel.checkOverlap(
|
||||||
|
existingRequest.worker_id,
|
||||||
|
newStartDate,
|
||||||
|
newEndDate,
|
||||||
|
id,
|
||||||
|
(err, overlapResults) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('기간 중복 체크 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '기간 중복 체크 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlapResults[0].count > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 기간에 이미 신청된 휴가가 있습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 실행
|
||||||
|
vacationRequestModel.update(id, updateData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 수정 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 날짜 변경 없이 바로 수정
|
||||||
|
vacationRequestModel.update(id, updateData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 수정 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 신청 수정 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 신청 삭제 (대기 중인 신청만)
|
||||||
|
*/
|
||||||
|
async deleteRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// 기존 신청 조회
|
||||||
|
vacationRequestModel.getById(id, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRequest = results[0];
|
||||||
|
|
||||||
|
// 권한 검증
|
||||||
|
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '권한이 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대기 중인 신청만 삭제 가능
|
||||||
|
if (existingRequest.status !== 'pending') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '승인/거부된 신청은 삭제할 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vacationRequestModel.delete(id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 삭제 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 신청 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 신청 승인 (관리자만)
|
||||||
|
*/
|
||||||
|
async approveRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { review_note } = req.body;
|
||||||
|
const reviewed_by = req.user.user_id;
|
||||||
|
|
||||||
|
// 관리자 권한 확인
|
||||||
|
if (req.user.access_level !== 'system') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '관리자만 승인할 수 있습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 신청 조회
|
||||||
|
vacationRequestModel.getById(id, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = results[0];
|
||||||
|
|
||||||
|
if (request.status !== 'pending') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 처리된 신청입니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 업데이트
|
||||||
|
const statusData = {
|
||||||
|
status: 'approved',
|
||||||
|
reviewed_by,
|
||||||
|
review_note
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 승인 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 승인 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 잔여 연차에서 차감 로직 구현 필요
|
||||||
|
// 현재는 연차 차감 없이 승인만 처리
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 승인되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 승인 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 신청 거부 (관리자만)
|
||||||
|
*/
|
||||||
|
async rejectRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { review_note } = req.body;
|
||||||
|
const reviewed_by = req.user.user_id;
|
||||||
|
|
||||||
|
// 관리자 권한 확인
|
||||||
|
if (req.user.access_level !== 'system') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '관리자만 거부할 수 있습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 신청 조회
|
||||||
|
vacationRequestModel.getById(id, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = results[0];
|
||||||
|
|
||||||
|
if (request.status !== 'pending') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 처리된 신청입니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 업데이트
|
||||||
|
const statusData = {
|
||||||
|
status: 'rejected',
|
||||||
|
reviewed_by,
|
||||||
|
review_note
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 거부 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 거부 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 신청이 거부되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 거부 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기 중인 휴가 신청 목록 (관리자용)
|
||||||
|
*/
|
||||||
|
async getPendingRequests(req, res) {
|
||||||
|
try {
|
||||||
|
// 관리자 권한 확인
|
||||||
|
if (req.user.access_level !== 'system') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '관리자만 조회할 수 있습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vacationRequestModel.getAllPending((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('대기 중인 휴가 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '대기 중인 휴가 신청 조회 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('대기 중인 휴가 신청 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = vacationRequestController;
|
||||||
333
api.hyungi.net/controllers/vacationTypeController.js
Normal file
333
api.hyungi.net/controllers/vacationTypeController.js
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* vacationTypeController.js
|
||||||
|
* 휴가 유형 관련 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
const vacationTypeModel = require('../models/vacationTypeModel');
|
||||||
|
|
||||||
|
const vacationTypeController = {
|
||||||
|
/**
|
||||||
|
* 모든 활성 휴가 유형 조회
|
||||||
|
* GET /api/vacation-types
|
||||||
|
*/
|
||||||
|
async getAllTypes(req, res) {
|
||||||
|
try {
|
||||||
|
vacationTypeModel.getAll((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 유형 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAllTypes 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 기본 휴가 유형 조회
|
||||||
|
* GET /api/vacation-types/system
|
||||||
|
*/
|
||||||
|
async getSystemTypes(req, res) {
|
||||||
|
try {
|
||||||
|
vacationTypeModel.getSystemTypes((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('시스템 휴가 유형 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '시스템 휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getSystemTypes 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특별 휴가 유형 조회
|
||||||
|
* GET /api/vacation-types/special
|
||||||
|
*/
|
||||||
|
async getSpecialTypes(req, res) {
|
||||||
|
try {
|
||||||
|
vacationTypeModel.getSpecialTypes((err, results) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('특별 휴가 유형 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '특별 휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getSpecialTypes 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특별 휴가 유형 생성 (관리자만)
|
||||||
|
* POST /api/vacation-types
|
||||||
|
*/
|
||||||
|
async createType(req, res) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
type_code,
|
||||||
|
type_name,
|
||||||
|
deduct_days,
|
||||||
|
priority,
|
||||||
|
description
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!type_code || !type_name || !deduct_days) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다 (type_code, type_name, deduct_days)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// type_code 중복 체크
|
||||||
|
vacationTypeModel.getByCode(type_code, (err, existingTypes) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('type_code 중복 체크 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'type_code 중복 체크 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTypes && existingTypes.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 존재하는 type_code입니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특별 휴가 유형으로 생성
|
||||||
|
const typeData = {
|
||||||
|
type_code,
|
||||||
|
type_name,
|
||||||
|
deduct_days,
|
||||||
|
priority: priority || 50,
|
||||||
|
description: description || null,
|
||||||
|
is_special: true,
|
||||||
|
is_system: false,
|
||||||
|
is_active: true
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationTypeModel.create(typeData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 유형 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 생성하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '특별 휴가 유형이 생성되었습니다',
|
||||||
|
data: { id: result.insertId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('createType 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 유형 수정 (관리자만)
|
||||||
|
* PUT /api/vacation-types/:id
|
||||||
|
*/
|
||||||
|
async updateType(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
type_name,
|
||||||
|
deduct_days,
|
||||||
|
priority,
|
||||||
|
description,
|
||||||
|
is_active
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 먼저 해당 유형 조회
|
||||||
|
vacationTypeModel.getById(id, (err, types) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 유형 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!types || types.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 찾을 수 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = types[0];
|
||||||
|
|
||||||
|
// 시스템 기본 휴가의 경우 제한적으로만 수정 가능
|
||||||
|
const updateData = {};
|
||||||
|
if (type.is_system) {
|
||||||
|
// 시스템 휴가는 priority와 description만 수정 가능
|
||||||
|
if (priority !== undefined) updateData.priority = priority;
|
||||||
|
if (description !== undefined) updateData.description = description;
|
||||||
|
} else {
|
||||||
|
// 특별 휴가는 모든 필드 수정 가능
|
||||||
|
if (type_name) updateData.type_name = type_name;
|
||||||
|
if (deduct_days !== undefined) updateData.deduct_days = deduct_days;
|
||||||
|
if (priority !== undefined) updateData.priority = priority;
|
||||||
|
if (description !== undefined) updateData.description = description;
|
||||||
|
if (is_active !== undefined) updateData.is_active = is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '수정할 데이터가 없습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.updated_at = new Date();
|
||||||
|
|
||||||
|
vacationTypeModel.update(id, updateData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 유형 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 수정하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 유형이 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateType 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
|
||||||
|
* DELETE /api/vacation-types/:id
|
||||||
|
*/
|
||||||
|
async deleteType(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
vacationTypeModel.delete(id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('휴가 유형 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '휴가 유형을 삭제하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '삭제할 수 없습니다. 시스템 기본 휴가이거나 존재하지 않는 휴가 유형입니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '휴가 유형이 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteType 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴가 유형 우선순위 일괄 업데이트 (관리자만)
|
||||||
|
* PUT /api/vacation-types/priorities
|
||||||
|
*/
|
||||||
|
async updatePriorities(req, res) {
|
||||||
|
try {
|
||||||
|
const { priorities } = req.body;
|
||||||
|
|
||||||
|
// priorities = [{ id: 1, priority: 10 }, { id: 2, priority: 20 }, ...]
|
||||||
|
if (!priorities || !Array.isArray(priorities)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'priorities 배열이 필요합니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vacationTypeModel.updatePriorities(priorities, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('우선순위 업데이트 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '우선순위를 업데이트하는 중 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '우선순위가 업데이트되었습니다',
|
||||||
|
data: { updated: result.affectedRows }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updatePriorities 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '서버 오류가 발생했습니다'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = vacationTypeController;
|
||||||
555
api.hyungi.net/controllers/visitRequestController.js
Normal file
555
api.hyungi.net/controllers/visitRequestController.js
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
const visitRequestModel = require('../models/visitRequestModel');
|
||||||
|
|
||||||
|
// ==================== 출입 신청 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 생성
|
||||||
|
*/
|
||||||
|
exports.createVisitRequest = (req, res) => {
|
||||||
|
const requester_id = req.user.user_id;
|
||||||
|
const requestData = {
|
||||||
|
requester_id,
|
||||||
|
...req.body
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!requestData[field]) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `${field}는 필수 입력 항목입니다.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visitRequestModel.createVisitRequest(requestData, (err, requestId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '출입 신청이 성공적으로 생성되었습니다.',
|
||||||
|
data: { request_id: requestId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 목록 조회
|
||||||
|
*/
|
||||||
|
exports.getAllVisitRequests = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
status: req.query.status,
|
||||||
|
visit_date: req.query.visit_date,
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
requester_id: req.query.requester_id,
|
||||||
|
category_id: req.query.category_id
|
||||||
|
};
|
||||||
|
|
||||||
|
visitRequestModel.getAllVisitRequests(filters, (err, requests) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: requests
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 상세 조회
|
||||||
|
*/
|
||||||
|
exports.getVisitRequestById = (req, res) => {
|
||||||
|
const requestId = req.params.id;
|
||||||
|
|
||||||
|
visitRequestModel.getVisitRequestById(requestId, (err, request) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: request
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 수정
|
||||||
|
*/
|
||||||
|
exports.updateVisitRequest = (req, res) => {
|
||||||
|
const requestId = req.params.id;
|
||||||
|
const requestData = req.body;
|
||||||
|
|
||||||
|
visitRequestModel.updateVisitRequest(requestId, requestData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 수정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '출입 신청이 수정되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteVisitRequest = (req, res) => {
|
||||||
|
const requestId = req.params.id;
|
||||||
|
|
||||||
|
visitRequestModel.deleteVisitRequest(requestId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '출입 신청이 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 승인
|
||||||
|
*/
|
||||||
|
exports.approveVisitRequest = (req, res) => {
|
||||||
|
const requestId = req.params.id;
|
||||||
|
const approvedBy = req.user.user_id;
|
||||||
|
|
||||||
|
visitRequestModel.approveVisitRequest(requestId, approvedBy, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 승인 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 승인 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '출입 신청이 승인되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출입 신청 반려
|
||||||
|
*/
|
||||||
|
exports.rejectVisitRequest = (req, res) => {
|
||||||
|
const requestId = req.params.id;
|
||||||
|
const approvedBy = req.user.user_id;
|
||||||
|
const rejectionReason = req.body.rejection_reason || '사유 없음';
|
||||||
|
|
||||||
|
const rejectionData = {
|
||||||
|
approved_by: approvedBy,
|
||||||
|
rejection_reason: rejectionReason
|
||||||
|
};
|
||||||
|
|
||||||
|
visitRequestModel.rejectVisitRequest(requestId, rejectionData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 반려 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청 반려 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '출입 신청을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '출입 신청이 반려되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 방문 목적 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 방문 목적 조회
|
||||||
|
*/
|
||||||
|
exports.getAllVisitPurposes = (req, res) => {
|
||||||
|
visitRequestModel.getAllVisitPurposes((err, purposes) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('방문 목적 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: purposes
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 방문 목적만 조회
|
||||||
|
*/
|
||||||
|
exports.getActiveVisitPurposes = (req, res) => {
|
||||||
|
visitRequestModel.getActiveVisitPurposes((err, purposes) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('활성 방문 목적 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '활성 방문 목적 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: purposes
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방문 목적 추가
|
||||||
|
*/
|
||||||
|
exports.createVisitPurpose = (req, res) => {
|
||||||
|
const purposeData = req.body;
|
||||||
|
|
||||||
|
if (!purposeData.purpose_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'purpose_name은 필수 입력 항목입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visitRequestModel.createVisitPurpose(purposeData, (err, purposeId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('방문 목적 추가 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적 추가 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '방문 목적이 추가되었습니다.',
|
||||||
|
data: { purpose_id: purposeId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방문 목적 수정
|
||||||
|
*/
|
||||||
|
exports.updateVisitPurpose = (req, res) => {
|
||||||
|
const purposeId = req.params.id;
|
||||||
|
const purposeData = req.body;
|
||||||
|
|
||||||
|
visitRequestModel.updateVisitPurpose(purposeId, purposeData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('방문 목적 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적 수정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '방문 목적이 수정되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방문 목적 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteVisitPurpose = (req, res) => {
|
||||||
|
const purposeId = req.params.id;
|
||||||
|
|
||||||
|
visitRequestModel.deleteVisitPurpose(purposeId, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('방문 목적 삭제 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '방문 목적을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '방문 목적이 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 안전교육 기록 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전교육 기록 생성
|
||||||
|
*/
|
||||||
|
exports.createTrainingRecord = (req, res) => {
|
||||||
|
const trainerId = req.user.user_id;
|
||||||
|
const trainingData = {
|
||||||
|
trainer_id: trainerId,
|
||||||
|
...req.body
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
const requiredFields = ['request_id', 'training_date', 'training_start_time'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!trainingData[field]) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `${field}는 필수 입력 항목입니다.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visitRequestModel.createTrainingRecord(trainingData, (err, trainingId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전교육 기록 생성 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록 생성 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 안전교육 기록이 생성되면 출입 신청 상태를 training_completed로 변경
|
||||||
|
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태를 training_completed로 변경 중...`);
|
||||||
|
visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed', (statusErr) => {
|
||||||
|
if (statusErr) {
|
||||||
|
console.error('출입 신청 상태 업데이트 오류:', statusErr);
|
||||||
|
// 에러가 발생해도 교육 기록은 생성되었으므로 성공 응답
|
||||||
|
} else {
|
||||||
|
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태 변경 성공`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '안전교육 기록이 생성되었습니다.',
|
||||||
|
data: { training_id: trainingId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 출입 신청의 안전교육 기록 조회
|
||||||
|
*/
|
||||||
|
exports.getTrainingRecordByRequestId = (req, res) => {
|
||||||
|
const requestId = req.params.requestId;
|
||||||
|
|
||||||
|
visitRequestModel.getTrainingRecordByRequestId(requestId, (err, record) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전교육 기록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: record || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전교육 기록 수정
|
||||||
|
*/
|
||||||
|
exports.updateTrainingRecord = (req, res) => {
|
||||||
|
const trainingId = req.params.id;
|
||||||
|
const trainingData = req.body;
|
||||||
|
|
||||||
|
visitRequestModel.updateTrainingRecord(trainingId, trainingData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전교육 기록 수정 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록 수정 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전교육 기록이 수정되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전교육 완료 (서명 포함)
|
||||||
|
*/
|
||||||
|
exports.completeTraining = (req, res) => {
|
||||||
|
const trainingId = req.params.id;
|
||||||
|
const signatureData = req.body.signature_data;
|
||||||
|
|
||||||
|
if (!signatureData) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '서명 데이터가 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visitRequestModel.completeTraining(trainingId, signatureData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전교육 완료 처리 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 완료 처리 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 교육 완료 후 출입 신청 상태를 'training_completed'로 변경
|
||||||
|
visitRequestModel.getTrainingRecordByRequestId(trainingId, (err, record) => {
|
||||||
|
if (err || !record) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전교육이 완료되었습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('출입 신청 상태 업데이트 오류:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '안전교육이 완료되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전교육 기록 목록 조회
|
||||||
|
*/
|
||||||
|
exports.getTrainingRecords = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
training_date: req.query.training_date,
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
trainer_id: req.query.trainer_id
|
||||||
|
};
|
||||||
|
|
||||||
|
visitRequestModel.getTrainingRecords(filters, (err, records) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('안전교육 기록 목록 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: records
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,509 +1,490 @@
|
|||||||
// controllers/workAnalysisController.js
|
/**
|
||||||
|
* 작업 분석 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업 보고서 다차원 분석 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const WorkAnalysis = require('../models/WorkAnalysis');
|
const WorkAnalysis = require('../models/WorkAnalysis');
|
||||||
const { getDb } = require('../dbPool'); // 기존 프로젝트의 DB 연결 방식 사용
|
const { getDb } = require('../dbPool');
|
||||||
|
const { ValidationError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
class WorkAnalysisController {
|
/**
|
||||||
constructor() {
|
* 날짜 유효성 검사 헬퍼 함수
|
||||||
// 메서드 바인딩
|
*/
|
||||||
this.getStats = this.getStats.bind(this);
|
const validateDateRange = (startDate, endDate) => {
|
||||||
this.getDailyTrend = this.getDailyTrend.bind(this);
|
if (!startDate || !endDate) {
|
||||||
this.getWorkerStats = this.getWorkerStats.bind(this);
|
throw new ValidationError('시작일과 종료일을 입력해주세요', {
|
||||||
this.getProjectStats = this.getProjectStats.bind(this);
|
required: ['start', 'end'],
|
||||||
this.getWorkTypeStats = this.getWorkTypeStats.bind(this);
|
received: { start: startDate, end: endDate }
|
||||||
this.getRecentWork = this.getRecentWork.bind(this);
|
});
|
||||||
this.getWeekdayPattern = this.getWeekdayPattern.bind(this);
|
}
|
||||||
this.getErrorAnalysis = this.getErrorAnalysis.bind(this);
|
|
||||||
this.getMonthlyComparison = this.getMonthlyComparison.bind(this);
|
const start = new Date(startDate);
|
||||||
this.getWorkerSpecialization = this.getWorkerSpecialization.bind(this);
|
const end = new Date(endDate);
|
||||||
this.getProjectWorkTypeAnalysis = this.getProjectWorkTypeAnalysis.bind(this);
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
throw new ValidationError('올바른 날짜 형식을 입력해주세요', {
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
received: { start: startDate, end: endDate }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
throw new ValidationError('시작일이 종료일보다 늦을 수 없습니다', {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 너무 긴 기간 방지 (1년 제한)
|
||||||
|
const diffTime = Math.abs(end - start);
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays > 365) {
|
||||||
|
throw new ValidationError('조회 기간은 1년을 초과할 수 없습니다', {
|
||||||
|
days: diffDays,
|
||||||
|
max: 365
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 통계 조회
|
||||||
|
*/
|
||||||
|
const getStats = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('기본 통계 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const stats = await workAnalysis.getBasicStats(start, end);
|
||||||
|
|
||||||
|
logger.info('기본 통계 조회 성공', { start, end });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
message: '기본 통계 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기본 통계 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('기본 통계 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 작업시간 추이 조회
|
||||||
|
*/
|
||||||
|
const getDailyTrend = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('일별 추이 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const trendData = await workAnalysis.getDailyTrend(start, end);
|
||||||
|
|
||||||
|
logger.info('일별 추이 조회 성공', { start, end, dataPoints: trendData.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: trendData,
|
||||||
|
message: '일별 추이 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('일별 추이 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('일별 추이 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자별 통계 조회
|
||||||
|
*/
|
||||||
|
const getWorkerStats = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('작업자별 통계 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const workerStats = await workAnalysis.getWorkerStats(start, end);
|
||||||
|
|
||||||
|
logger.info('작업자별 통계 조회 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
workerCount: workerStats.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: workerStats,
|
||||||
|
message: '작업자별 통계 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('작업자별 통계 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('작업자별 통계 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트별 통계 조회
|
||||||
|
*/
|
||||||
|
const getProjectStats = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('프로젝트별 통계 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const projectStats = await workAnalysis.getProjectStats(start, end);
|
||||||
|
|
||||||
|
logger.info('프로젝트별 통계 조회 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
projectCount: projectStats.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: projectStats,
|
||||||
|
message: '프로젝트별 통계 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('프로젝트별 통계 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('프로젝트별 통계 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업유형별 통계 조회
|
||||||
|
*/
|
||||||
|
const getWorkTypeStats = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('작업유형별 통계 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const workTypeStats = await workAnalysis.getWorkTypeStats(start, end);
|
||||||
|
|
||||||
|
logger.info('작업유형별 통계 조회 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
workTypeCount: workTypeStats.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: workTypeStats,
|
||||||
|
message: '작업유형별 통계 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('작업유형별 통계 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('작업유형별 통계 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 작업 현황 조회
|
||||||
|
*/
|
||||||
|
const getRecentWork = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end, limit = 10 } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
// limit 유효성 검사 (최대 5000까지 허용)
|
||||||
|
const limitNum = parseInt(limit);
|
||||||
|
if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) {
|
||||||
|
throw new ValidationError('limit은 1~5000 사이의 숫자여야 합니다', {
|
||||||
|
received: limit,
|
||||||
|
min: 1,
|
||||||
|
max: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('최근 작업 현황 조회 요청', { start, end, limit: limitNum });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const recentWork = await workAnalysis.getRecentWork(start, end, limitNum);
|
||||||
|
|
||||||
|
logger.info('최근 작업 현황 조회 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
limit: limitNum,
|
||||||
|
resultCount: recentWork.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: recentWork,
|
||||||
|
message: '최근 작업 현황 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('최근 작업 현황 조회 실패', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
limit: limitNum,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw new DatabaseError('최근 작업 현황 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요일별 패턴 분석 조회
|
||||||
|
*/
|
||||||
|
const getWeekdayPattern = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('요일별 패턴 분석 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end);
|
||||||
|
|
||||||
|
logger.info('요일별 패턴 분석 성공', { start, end });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: weekdayPattern,
|
||||||
|
message: '요일별 패턴 분석 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('요일별 패턴 분석 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('요일별 패턴 분석 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 분석 조회
|
||||||
|
*/
|
||||||
|
const getErrorAnalysis = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('에러 분석 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end);
|
||||||
|
|
||||||
|
logger.info('에러 분석 성공', { start, end });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: errorAnalysis,
|
||||||
|
message: '에러 분석 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('에러 분석 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('에러 분석 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 비교 분석 조회
|
||||||
|
*/
|
||||||
|
const getMonthlyComparison = asyncHandler(async (req, res) => {
|
||||||
|
const { year = new Date().getFullYear() } = req.query;
|
||||||
|
|
||||||
|
const yearNum = parseInt(year);
|
||||||
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) {
|
||||||
|
throw new ValidationError('올바른 연도를 입력해주세요', {
|
||||||
|
received: year,
|
||||||
|
min: 2000,
|
||||||
|
max: 2050
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('월별 비교 분석 요청', { year: yearNum });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const monthlyData = await workAnalysis.getMonthlyComparison(yearNum);
|
||||||
|
|
||||||
|
logger.info('월별 비교 분석 성공', { year: yearNum });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: monthlyData,
|
||||||
|
message: '월별 비교 분석 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('월별 비교 분석 실패', { year: yearNum, error: error.message });
|
||||||
|
throw new DatabaseError('월별 비교 분석 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자별 전문분야 분석 조회
|
||||||
|
*/
|
||||||
|
const getWorkerSpecialization = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('작업자별 전문분야 분석 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
const specializationData = await workAnalysis.getWorkerSpecialization(start, end);
|
||||||
|
|
||||||
|
// 작업자별로 그룹화하여 정리
|
||||||
|
const groupedData = specializationData.reduce((acc, item) => {
|
||||||
|
if (!acc[item.worker_id]) {
|
||||||
|
acc[item.worker_id] = [];
|
||||||
|
}
|
||||||
|
acc[item.worker_id].push({
|
||||||
|
work_type_id: item.work_type_id,
|
||||||
|
project_id: item.project_id,
|
||||||
|
totalHours: item.totalHours,
|
||||||
|
totalReports: item.totalReports,
|
||||||
|
percentage: item.percentage
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
logger.info('작업자별 전문분야 분석 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
workerCount: Object.keys(groupedData).length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: groupedData,
|
||||||
|
message: '작업자별 전문분야 분석 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('작업자별 전문분야 분석 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('작업자별 전문분야 분석 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드용 종합 데이터 조회
|
||||||
|
*/
|
||||||
|
const getDashboardData = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('대시보드 데이터 조회 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const workAnalysis = new WorkAnalysis(db);
|
||||||
|
|
||||||
|
// 병렬로 여러 데이터 조회
|
||||||
|
const [
|
||||||
|
stats,
|
||||||
|
dailyTrend,
|
||||||
|
workerStats,
|
||||||
|
projectStats,
|
||||||
|
workTypeStats,
|
||||||
|
recentWork
|
||||||
|
] = await Promise.all([
|
||||||
|
workAnalysis.getBasicStats(start, end),
|
||||||
|
workAnalysis.getDailyTrend(start, end),
|
||||||
|
workAnalysis.getWorkerStats(start, end),
|
||||||
|
workAnalysis.getProjectStats(start, end),
|
||||||
|
workAnalysis.getWorkTypeStats(start, end),
|
||||||
|
workAnalysis.getRecentWork(start, end, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('대시보드 데이터 조회 성공', { start, end });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
stats,
|
||||||
|
dailyTrend,
|
||||||
|
workerStats,
|
||||||
|
projectStats,
|
||||||
|
workTypeStats,
|
||||||
|
recentWork
|
||||||
|
},
|
||||||
|
message: '대시보드 데이터 조회 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('대시보드 데이터 조회 실패', { start, end, error: error.message });
|
||||||
|
throw new DatabaseError('대시보드 데이터 조회 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const workAnalysisService = require('../services/workAnalysisService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
|
||||||
|
*/
|
||||||
|
const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
validateDateRange(start, end);
|
||||||
|
|
||||||
|
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await workAnalysisService.getProjectWorkTypeAnalysis(start, end);
|
||||||
|
|
||||||
|
logger.info('프로젝트별-작업별 시간 분석 성공', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
projectCount: result.summary.total_projects,
|
||||||
|
workTypeCount: result.summary.total_work_types,
|
||||||
|
totalHours: result.summary.grand_total_hours
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '프로젝트별-작업별 시간 분석 완료'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('프로젝트별-작업별 시간 분석 실패', {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
// Service throws DatabaseError wrapper or Error
|
||||||
|
if (error.name === 'DatabaseError') {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 날짜 유효성 검사
|
module.exports = {
|
||||||
validateDateRange(startDate, endDate) {
|
getStats,
|
||||||
if (!startDate || !endDate) {
|
getDailyTrend,
|
||||||
throw new Error('시작일과 종료일을 입력해주세요.');
|
getWorkerStats,
|
||||||
}
|
getProjectStats,
|
||||||
|
getWorkTypeStats,
|
||||||
const start = new Date(startDate);
|
getRecentWork,
|
||||||
const end = new Date(endDate);
|
getWeekdayPattern,
|
||||||
|
getErrorAnalysis,
|
||||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
getMonthlyComparison,
|
||||||
throw new Error('올바른 날짜 형식을 입력해주세요. (YYYY-MM-DD)');
|
getWorkerSpecialization,
|
||||||
}
|
getDashboardData,
|
||||||
|
getProjectWorkTypeAnalysis
|
||||||
if (start > end) {
|
};
|
||||||
throw new Error('시작일이 종료일보다 늦을 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 너무 긴 기간 방지 (1년 제한)
|
|
||||||
const diffTime = Math.abs(end - start);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
if (diffDays > 365) {
|
|
||||||
throw new Error('조회 기간은 1년을 초과할 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 통계 조회
|
|
||||||
async getStats(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const stats = await workAnalysis.getBasicStats(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: stats,
|
|
||||||
message: '기본 통계 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('기본 통계 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일별 작업시간 추이
|
|
||||||
async getDailyTrend(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const trendData = await workAnalysis.getDailyTrend(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: trendData,
|
|
||||||
message: '일별 추이 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('일별 추이 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업자별 통계
|
|
||||||
async getWorkerStats(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const workerStats = await workAnalysis.getWorkerStats(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: workerStats,
|
|
||||||
message: '작업자별 통계 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업자별 통계 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트별 통계
|
|
||||||
async getProjectStats(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const projectStats = await workAnalysis.getProjectStats(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: projectStats,
|
|
||||||
message: '프로젝트별 통계 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('프로젝트별 통계 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업유형별 통계
|
|
||||||
async getWorkTypeStats(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const workTypeStats = await workAnalysis.getWorkTypeStats(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: workTypeStats,
|
|
||||||
message: '작업유형별 통계 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업유형별 통계 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최근 작업 현황
|
|
||||||
async getRecentWork(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end, limit = 10 } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
// limit 유효성 검사
|
|
||||||
const limitNum = parseInt(limit);
|
|
||||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
|
||||||
throw new Error('limit은 1~100 사이의 숫자여야 합니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const recentWork = await workAnalysis.getRecentWork(start, end, limitNum);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: recentWork,
|
|
||||||
message: '최근 작업 현황 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('최근 작업 현황 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 요일별 패턴 분석
|
|
||||||
async getWeekdayPattern(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: weekdayPattern,
|
|
||||||
message: '요일별 패턴 분석 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('요일별 패턴 분석 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 에러 분석
|
|
||||||
async getErrorAnalysis(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: errorAnalysis,
|
|
||||||
message: '에러 분석 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('에러 분석 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 월별 비교 분석
|
|
||||||
async getMonthlyComparison(req, res) {
|
|
||||||
try {
|
|
||||||
const { year = new Date().getFullYear() } = req.query;
|
|
||||||
|
|
||||||
const yearNum = parseInt(year);
|
|
||||||
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) {
|
|
||||||
throw new Error('올바른 연도를 입력해주세요. (2000-2050)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const monthlyData = await workAnalysis.getMonthlyComparison(yearNum);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: monthlyData,
|
|
||||||
message: '월별 비교 분석 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('월별 비교 분석 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업자별 전문분야 분석
|
|
||||||
async getWorkerSpecialization(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
const specializationData = await workAnalysis.getWorkerSpecialization(start, end);
|
|
||||||
|
|
||||||
// 작업자별로 그룹화하여 정리
|
|
||||||
const groupedData = specializationData.reduce((acc, item) => {
|
|
||||||
if (!acc[item.worker_id]) {
|
|
||||||
acc[item.worker_id] = [];
|
|
||||||
}
|
|
||||||
acc[item.worker_id].push({
|
|
||||||
work_type_id: item.work_type_id,
|
|
||||||
project_id: item.project_id,
|
|
||||||
totalHours: item.totalHours,
|
|
||||||
totalReports: item.totalReports,
|
|
||||||
percentage: item.percentage
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: groupedData,
|
|
||||||
message: '작업자별 전문분야 분석 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업자별 전문분야 분석 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대시보드용 종합 데이터
|
|
||||||
async getDashboardData(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const workAnalysis = new WorkAnalysis(db);
|
|
||||||
|
|
||||||
// 병렬로 여러 데이터 조회
|
|
||||||
const [
|
|
||||||
stats,
|
|
||||||
dailyTrend,
|
|
||||||
workerStats,
|
|
||||||
projectStats,
|
|
||||||
workTypeStats,
|
|
||||||
recentWork
|
|
||||||
] = await Promise.all([
|
|
||||||
workAnalysis.getBasicStats(start, end),
|
|
||||||
workAnalysis.getDailyTrend(start, end),
|
|
||||||
workAnalysis.getWorkerStats(start, end),
|
|
||||||
workAnalysis.getProjectStats(start, end),
|
|
||||||
workAnalysis.getWorkTypeStats(start, end),
|
|
||||||
workAnalysis.getRecentWork(start, end, 10)
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
stats,
|
|
||||||
dailyTrend,
|
|
||||||
workerStats,
|
|
||||||
projectStats,
|
|
||||||
workTypeStats,
|
|
||||||
recentWork
|
|
||||||
},
|
|
||||||
message: '대시보드 데이터 조회 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('대시보드 데이터 조회 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
|
|
||||||
async getProjectWorkTypeAnalysis(req, res) {
|
|
||||||
try {
|
|
||||||
const { start, end } = req.query;
|
|
||||||
this.validateDateRange(start, end);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
|
|
||||||
// 먼저 데이터 존재 여부 확인
|
|
||||||
const testQuery = `
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_count,
|
|
||||||
MIN(report_date) as min_date,
|
|
||||||
MAX(report_date) as max_date,
|
|
||||||
SUM(work_hours) as total_hours
|
|
||||||
FROM daily_work_reports
|
|
||||||
WHERE report_date BETWEEN ? AND ?
|
|
||||||
`;
|
|
||||||
|
|
||||||
const testResults = await db.query(testQuery, [start, end]);
|
|
||||||
console.log('📊 데이터 확인:', testResults[0]);
|
|
||||||
|
|
||||||
// 프로젝트별-작업별 시간 분석 쿼리 (간단한 버전으로 테스트)
|
|
||||||
const query = `
|
|
||||||
SELECT
|
|
||||||
COALESCE(p.project_id, 0) as project_id,
|
|
||||||
COALESCE(p.project_name, 'Unknown Project') as project_name,
|
|
||||||
COALESCE(p.job_no, 'N/A') as job_no,
|
|
||||||
dwr.work_type_id,
|
|
||||||
CONCAT('Work Type ', dwr.work_type_id) as work_type_name,
|
|
||||||
|
|
||||||
-- 총 시간
|
|
||||||
SUM(dwr.work_hours) as total_hours,
|
|
||||||
|
|
||||||
-- 정규 시간 (work_status_id = 1)
|
|
||||||
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
|
|
||||||
|
|
||||||
-- 에러 시간 (work_status_id = 2)
|
|
||||||
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
|
|
||||||
|
|
||||||
-- 작업 건수
|
|
||||||
COUNT(*) as total_reports,
|
|
||||||
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
|
|
||||||
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
|
|
||||||
|
|
||||||
-- 에러율 계산
|
|
||||||
ROUND(
|
|
||||||
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
|
|
||||||
SUM(dwr.work_hours)) * 100, 2
|
|
||||||
) as error_rate_percent
|
|
||||||
|
|
||||||
FROM daily_work_reports dwr
|
|
||||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
|
||||||
WHERE dwr.report_date BETWEEN ? AND ?
|
|
||||||
GROUP BY p.project_id, p.project_name, p.job_no, dwr.work_type_id
|
|
||||||
ORDER BY p.project_name, dwr.work_type_id
|
|
||||||
`;
|
|
||||||
|
|
||||||
const results = await db.query(query, [start, end]);
|
|
||||||
|
|
||||||
// 데이터를 프로젝트별로 그룹화
|
|
||||||
const groupedData = {};
|
|
||||||
|
|
||||||
results.forEach(row => {
|
|
||||||
const projectKey = `${row.project_id}_${row.project_name}`;
|
|
||||||
|
|
||||||
if (!groupedData[projectKey]) {
|
|
||||||
groupedData[projectKey] = {
|
|
||||||
project_id: row.project_id,
|
|
||||||
project_name: row.project_name,
|
|
||||||
job_no: row.job_no,
|
|
||||||
total_project_hours: 0,
|
|
||||||
total_regular_hours: 0,
|
|
||||||
total_error_hours: 0,
|
|
||||||
work_types: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트 총계 누적
|
|
||||||
groupedData[projectKey].total_project_hours += parseFloat(row.total_hours);
|
|
||||||
groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours);
|
|
||||||
groupedData[projectKey].total_error_hours += parseFloat(row.error_hours);
|
|
||||||
|
|
||||||
// 작업 유형별 데이터 추가
|
|
||||||
groupedData[projectKey].work_types.push({
|
|
||||||
work_type_id: row.work_type_id,
|
|
||||||
work_type_name: row.work_type_name,
|
|
||||||
total_hours: parseFloat(row.total_hours),
|
|
||||||
regular_hours: parseFloat(row.regular_hours),
|
|
||||||
error_hours: parseFloat(row.error_hours),
|
|
||||||
total_reports: row.total_reports,
|
|
||||||
regular_reports: row.regular_reports,
|
|
||||||
error_reports: row.error_reports,
|
|
||||||
error_rate_percent: parseFloat(row.error_rate_percent) || 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 프로젝트별 에러율 계산
|
|
||||||
Object.values(groupedData).forEach(project => {
|
|
||||||
project.project_error_rate = project.total_project_hours > 0
|
|
||||||
? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100
|
|
||||||
: 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전체 요약 통계
|
|
||||||
const totalStats = {
|
|
||||||
total_projects: Object.keys(groupedData).length,
|
|
||||||
total_work_types: new Set(results.map(r => r.work_type_id)).size,
|
|
||||||
grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0),
|
|
||||||
grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0),
|
|
||||||
grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
totalStats.grand_error_rate = totalStats.grand_total_hours > 0
|
|
||||||
? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
summary: totalStats,
|
|
||||||
projects: Object.values(groupedData),
|
|
||||||
period: { start, end }
|
|
||||||
},
|
|
||||||
message: '프로젝트별-작업별 시간 분석 완료'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('프로젝트별-작업별 시간 분석 오류:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = new WorkAnalysisController();
|
|
||||||
|
|||||||
674
api.hyungi.net/controllers/workIssueController.js
Normal file
674
api.hyungi.net/controllers/workIssueController.js
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
/**
|
||||||
|
* 작업 중 문제 신고 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
const workIssueModel = require('../models/workIssueModel');
|
||||||
|
const imageUploadService = require('../services/imageUploadService');
|
||||||
|
|
||||||
|
// ==================== 신고 카테고리 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 카테고리 조회
|
||||||
|
*/
|
||||||
|
exports.getAllCategories = (req, res) => {
|
||||||
|
workIssueModel.getAllCategories((err, categories) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타입별 카테고리 조회
|
||||||
|
*/
|
||||||
|
exports.getCategoriesByType = (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
|
||||||
|
if (!['nonconformity', 'safety'].includes(type)) {
|
||||||
|
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.getCategoriesByType(type, (err, categories) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 생성
|
||||||
|
*/
|
||||||
|
exports.createCategory = (req, res) => {
|
||||||
|
const { category_type, category_name, description, display_order } = req.body;
|
||||||
|
|
||||||
|
if (!category_type || !category_name) {
|
||||||
|
return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.createCategory(
|
||||||
|
{ category_type, category_name, description, display_order },
|
||||||
|
(err, categoryId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리 생성 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '카테고리 생성 실패' });
|
||||||
|
}
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '카테고리가 생성되었습니다.',
|
||||||
|
data: { category_id: categoryId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 수정
|
||||||
|
*/
|
||||||
|
exports.updateCategory = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { category_name, description, display_order, is_active } = req.body;
|
||||||
|
|
||||||
|
workIssueModel.updateCategory(
|
||||||
|
id,
|
||||||
|
{ category_name, description, display_order, is_active },
|
||||||
|
(err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리 수정 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '카테고리 수정 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '카테고리가 수정되었습니다.' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteCategory = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.deleteCategory(id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리 삭제 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '카테고리 삭제 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '카테고리가 삭제되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 사전 정의 항목 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 항목 조회
|
||||||
|
*/
|
||||||
|
exports.getItemsByCategory = (req, res) => {
|
||||||
|
const { categoryId } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.getItemsByCategory(categoryId, (err, items) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('항목 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: items });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 항목 조회
|
||||||
|
*/
|
||||||
|
exports.getAllItems = (req, res) => {
|
||||||
|
workIssueModel.getAllItems((err, items) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('항목 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: items });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항목 생성
|
||||||
|
*/
|
||||||
|
exports.createItem = (req, res) => {
|
||||||
|
const { category_id, item_name, description, severity, display_order } = req.body;
|
||||||
|
|
||||||
|
if (!category_id || !item_name) {
|
||||||
|
return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.createItem(
|
||||||
|
{ category_id, item_name, description, severity, display_order },
|
||||||
|
(err, itemId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('항목 생성 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 생성 실패' });
|
||||||
|
}
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '항목이 생성되었습니다.',
|
||||||
|
data: { item_id: itemId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항목 수정
|
||||||
|
*/
|
||||||
|
exports.updateItem = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { item_name, description, severity, display_order, is_active } = req.body;
|
||||||
|
|
||||||
|
workIssueModel.updateItem(
|
||||||
|
id,
|
||||||
|
{ item_name, description, severity, display_order, is_active },
|
||||||
|
(err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('항목 수정 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 수정 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '항목이 수정되었습니다.' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항목 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteItem = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.deleteItem(id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('항목 삭제 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 삭제 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '항목이 삭제되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 문제 신고 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 생성
|
||||||
|
*/
|
||||||
|
exports.createReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
factory_category_id,
|
||||||
|
workplace_id,
|
||||||
|
custom_location,
|
||||||
|
tbm_session_id,
|
||||||
|
visit_request_id,
|
||||||
|
issue_category_id,
|
||||||
|
issue_item_id,
|
||||||
|
custom_item_name, // 직접 입력한 항목명
|
||||||
|
additional_description,
|
||||||
|
photos = []
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const reporter_id = req.user.user_id;
|
||||||
|
|
||||||
|
if (!issue_category_id) {
|
||||||
|
return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위치 정보 검증 (지도 선택 또는 기타 위치)
|
||||||
|
if (!factory_category_id && !custom_location) {
|
||||||
|
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 항목 검증 (기존 항목 또는 직접 입력)
|
||||||
|
if (!issue_item_id && !custom_item_name) {
|
||||||
|
return res.status(400).json({ success: false, error: '신고 항목은 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 직접 입력한 항목이 있으면 DB에 저장
|
||||||
|
let finalItemId = issue_item_id;
|
||||||
|
if (custom_item_name && !issue_item_id) {
|
||||||
|
try {
|
||||||
|
finalItemId = await new Promise((resolve, reject) => {
|
||||||
|
workIssueModel.createItem(
|
||||||
|
{
|
||||||
|
category_id: issue_category_id,
|
||||||
|
item_name: custom_item_name,
|
||||||
|
description: '사용자 직접 입력',
|
||||||
|
severity: 'medium',
|
||||||
|
display_order: 999 // 마지막에 표시
|
||||||
|
},
|
||||||
|
(err, itemId) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(itemId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (itemErr) {
|
||||||
|
console.error('커스텀 항목 생성 실패:', itemErr);
|
||||||
|
return res.status(500).json({ success: false, error: '항목 저장 실패' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 저장 (최대 5장)
|
||||||
|
const photoPaths = {
|
||||||
|
photo_path1: null,
|
||||||
|
photo_path2: null,
|
||||||
|
photo_path3: null,
|
||||||
|
photo_path4: null,
|
||||||
|
photo_path5: null
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(photos.length, 5); i++) {
|
||||||
|
if (photos[i]) {
|
||||||
|
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
|
||||||
|
if (savedPath) {
|
||||||
|
photoPaths[`photo_path${i + 1}`] = savedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
reporter_id,
|
||||||
|
factory_category_id: factory_category_id || null,
|
||||||
|
workplace_id: workplace_id || null,
|
||||||
|
custom_location: custom_location || null,
|
||||||
|
tbm_session_id: tbm_session_id || null,
|
||||||
|
visit_request_id: visit_request_id || null,
|
||||||
|
issue_category_id,
|
||||||
|
issue_item_id: finalItemId || null,
|
||||||
|
additional_description: additional_description || null,
|
||||||
|
...photoPaths
|
||||||
|
};
|
||||||
|
|
||||||
|
workIssueModel.createReport(reportData, (err, reportId) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 생성 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 생성 실패' });
|
||||||
|
}
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: '문제 신고가 등록되었습니다.',
|
||||||
|
data: { report_id: reportId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('신고 생성 에러:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 목록 조회
|
||||||
|
*/
|
||||||
|
exports.getAllReports = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
status: req.query.status,
|
||||||
|
category_type: req.query.category_type,
|
||||||
|
issue_category_id: req.query.issue_category_id,
|
||||||
|
factory_category_id: req.query.factory_category_id,
|
||||||
|
workplace_id: req.query.workplace_id,
|
||||||
|
assigned_user_id: req.query.assigned_user_id,
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
search: req.query.search,
|
||||||
|
limit: req.query.limit,
|
||||||
|
offset: req.query.offset
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일반 사용자는 자신의 신고만 조회 (관리자 제외)
|
||||||
|
const userLevel = req.user.access_level;
|
||||||
|
if (!['admin', 'system', 'support_team'].includes(userLevel)) {
|
||||||
|
filters.reporter_id = req.user.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.getAllReports(filters, (err, reports) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 목록 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 목록 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: reports });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 상세 조회
|
||||||
|
*/
|
||||||
|
exports.getReportById = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.getReportById(id, (err, report) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 상세 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 상세 조회 실패' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인: 본인, 담당자, 또는 관리자
|
||||||
|
const userLevel = req.user.access_level;
|
||||||
|
const isOwner = report.reporter_id === req.user.user_id;
|
||||||
|
const isAssignee = report.assigned_user_id === req.user.user_id;
|
||||||
|
const isManager = ['admin', 'system', 'support_team'].includes(userLevel);
|
||||||
|
|
||||||
|
if (!isOwner && !isAssignee && !isManager) {
|
||||||
|
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: report });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 수정
|
||||||
|
*/
|
||||||
|
exports.updateReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// 기존 신고 확인
|
||||||
|
workIssueModel.getReportById(id, async (err, report) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 조회 실패' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인
|
||||||
|
const userLevel = req.user.access_level;
|
||||||
|
const isOwner = report.reporter_id === req.user.user_id;
|
||||||
|
const isManager = ['admin', 'system'].includes(userLevel);
|
||||||
|
|
||||||
|
if (!isOwner && !isManager) {
|
||||||
|
return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 확인: reported 상태에서만 수정 가능 (관리자 제외)
|
||||||
|
if (!isManager && report.status !== 'reported') {
|
||||||
|
return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
factory_category_id,
|
||||||
|
workplace_id,
|
||||||
|
custom_location,
|
||||||
|
issue_category_id,
|
||||||
|
issue_item_id,
|
||||||
|
additional_description,
|
||||||
|
photos = []
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 사진 업데이트 처리
|
||||||
|
const photoPaths = {};
|
||||||
|
for (let i = 0; i < Math.min(photos.length, 5); i++) {
|
||||||
|
if (photos[i]) {
|
||||||
|
// 기존 사진 삭제
|
||||||
|
const oldPath = report[`photo_path${i + 1}`];
|
||||||
|
if (oldPath) {
|
||||||
|
await imageUploadService.deleteFile(oldPath);
|
||||||
|
}
|
||||||
|
// 새 사진 저장
|
||||||
|
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
|
||||||
|
if (savedPath) {
|
||||||
|
photoPaths[`photo_path${i + 1}`] = savedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
factory_category_id,
|
||||||
|
workplace_id,
|
||||||
|
custom_location,
|
||||||
|
issue_category_id,
|
||||||
|
issue_item_id,
|
||||||
|
additional_description,
|
||||||
|
...photoPaths
|
||||||
|
};
|
||||||
|
|
||||||
|
workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('신고 수정 실패:', updateErr);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 수정 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '신고가 수정되었습니다.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('신고 수정 에러:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteReport = async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.getReportById(id, async (err, report) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 조회 실패' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인
|
||||||
|
const userLevel = req.user.access_level;
|
||||||
|
const isOwner = report.reporter_id === req.user.user_id;
|
||||||
|
const isManager = ['admin', 'system'].includes(userLevel);
|
||||||
|
|
||||||
|
if (!isOwner && !isManager) {
|
||||||
|
return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => {
|
||||||
|
if (deleteErr) {
|
||||||
|
console.error('신고 삭제 실패:', deleteErr);
|
||||||
|
return res.status(500).json({ success: false, error: '신고 삭제 실패' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 파일 삭제
|
||||||
|
if (photos) {
|
||||||
|
const allPhotos = [
|
||||||
|
photos.photo_path1, photos.photo_path2, photos.photo_path3,
|
||||||
|
photos.photo_path4, photos.photo_path5,
|
||||||
|
photos.resolution_photo_path1, photos.resolution_photo_path2
|
||||||
|
].filter(Boolean);
|
||||||
|
await imageUploadService.deleteMultipleFiles(allPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '신고가 삭제되었습니다.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 상태 관리 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 접수
|
||||||
|
*/
|
||||||
|
exports.receiveReport = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.receiveReport(id, req.user.user_id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 접수 실패:', err);
|
||||||
|
return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '신고가 접수되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 담당자 배정
|
||||||
|
*/
|
||||||
|
exports.assignReport = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { assigned_department, assigned_user_id } = req.body;
|
||||||
|
|
||||||
|
if (!assigned_user_id) {
|
||||||
|
return res.status(400).json({ success: false, error: '담당자는 필수입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.assignReport(id, {
|
||||||
|
assigned_department,
|
||||||
|
assigned_user_id,
|
||||||
|
assigned_by: req.user.user_id
|
||||||
|
}, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('담당자 배정 실패:', err);
|
||||||
|
return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '담당자가 배정되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 시작
|
||||||
|
*/
|
||||||
|
exports.startProcessing = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.startProcessing(id, req.user.user_id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('처리 시작 실패:', err);
|
||||||
|
return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '처리가 시작되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 완료
|
||||||
|
*/
|
||||||
|
exports.completeReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { resolution_notes, resolution_photos = [] } = req.body;
|
||||||
|
|
||||||
|
// 완료 사진 저장
|
||||||
|
let resolution_photo_path1 = null;
|
||||||
|
let resolution_photo_path2 = null;
|
||||||
|
|
||||||
|
if (resolution_photos[0]) {
|
||||||
|
resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution');
|
||||||
|
}
|
||||||
|
if (resolution_photos[1]) {
|
||||||
|
resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
workIssueModel.completeReport(id, {
|
||||||
|
resolution_notes,
|
||||||
|
resolution_photo_path1,
|
||||||
|
resolution_photo_path2,
|
||||||
|
resolved_by: req.user.user_id
|
||||||
|
}, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('처리 완료 실패:', err);
|
||||||
|
return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '처리가 완료되었습니다.' });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('처리 완료 에러:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 종료
|
||||||
|
*/
|
||||||
|
exports.closeReport = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.closeReport(id, req.user.user_id, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('신고 종료 실패:', err);
|
||||||
|
return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '신고가 종료되었습니다.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 변경 이력 조회
|
||||||
|
*/
|
||||||
|
exports.getStatusLogs = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
workIssueModel.getStatusLogs(id, (err, logs) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('상태 이력 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '상태 이력 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: logs });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 통계 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 요약
|
||||||
|
*/
|
||||||
|
exports.getStatsSummary = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
factory_category_id: req.query.factory_category_id
|
||||||
|
};
|
||||||
|
|
||||||
|
workIssueModel.getStatsSummary(filters, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('통계 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: stats });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 통계
|
||||||
|
*/
|
||||||
|
exports.getStatsByCategory = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date
|
||||||
|
};
|
||||||
|
|
||||||
|
workIssueModel.getStatsByCategory(filters, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('카테고리별 통계 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: stats });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장별 통계
|
||||||
|
*/
|
||||||
|
exports.getStatsByWorkplace = (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
start_date: req.query.start_date,
|
||||||
|
end_date: req.query.end_date,
|
||||||
|
factory_category_id: req.query.factory_category_id
|
||||||
|
};
|
||||||
|
|
||||||
|
workIssueModel.getStatsByWorkplace(filters, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('작업장별 통계 조회 실패:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: stats });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,46 +1,64 @@
|
|||||||
// controllers/workReportAnalysisController.js - 데일리 워크 레포트 분석 전용 컨트롤러
|
/**
|
||||||
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
|
* 데일리 워크 레포트 분석 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업 보고서 종합 분석 API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
|
const { ValidationError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
* 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
||||||
*/
|
*/
|
||||||
const getAnalysisFilters = async (req, res) => {
|
const getAnalysisFilters = asyncHandler(async (req, res) => {
|
||||||
|
logger.info('분석 필터 데이터 조회 요청');
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
|
||||||
|
|
||||||
// 프로젝트 목록
|
// 프로젝트 목록
|
||||||
const [projects] = await db.query(`
|
const [projects] = await db.query(`
|
||||||
SELECT DISTINCT p.project_id, p.project_name
|
SELECT DISTINCT p.project_id, p.project_name
|
||||||
FROM projects p
|
FROM projects p
|
||||||
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
|
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
|
||||||
ORDER BY p.project_name
|
ORDER BY p.project_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 작업자 목록
|
// 작업자 목록
|
||||||
const [workers] = await db.query(`
|
const [workers] = await db.query(`
|
||||||
SELECT DISTINCT w.worker_id, w.worker_name
|
SELECT DISTINCT w.worker_id, w.worker_name
|
||||||
FROM workers w
|
FROM workers w
|
||||||
INNER JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
|
INNER JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
|
||||||
ORDER BY w.worker_name
|
ORDER BY w.worker_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 작업 유형 목록
|
// 작업 유형 목록
|
||||||
const [workTypes] = await db.query(`
|
const [workTypes] = await db.query(`
|
||||||
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
|
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
|
||||||
FROM work_types wt
|
FROM work_types wt
|
||||||
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
|
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
|
||||||
ORDER BY wt.name
|
ORDER BY wt.name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 날짜 범위 (최초/최신 데이터)
|
// 날짜 범위
|
||||||
const [dateRange] = await db.query(`
|
const [dateRange] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
MIN(report_date) as min_date,
|
MIN(report_date) as min_date,
|
||||||
MAX(report_date) as max_date
|
MAX(report_date) as max_date
|
||||||
FROM daily_work_reports
|
FROM daily_work_reports
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
logger.info('분석 필터 데이터 조회 성공', {
|
||||||
|
projects: projects.length,
|
||||||
|
workers: workers.length,
|
||||||
|
workTypes: workTypes.length
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -48,57 +66,58 @@ const getAnalysisFilters = async (req, res) => {
|
|||||||
workers,
|
workers,
|
||||||
workTypes,
|
workTypes,
|
||||||
dateRange: dateRange[0]
|
dateRange: dateRange[0]
|
||||||
}
|
},
|
||||||
|
message: '분석 필터 데이터 조회 성공'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('필터 데이터 조회 오류:', error);
|
logger.error('분석 필터 데이터 조회 실패', { error: error.message });
|
||||||
res.status(500).json({
|
throw new DatabaseError('필터 데이터 조회 중 오류가 발생했습니다');
|
||||||
success: false,
|
|
||||||
error: '필터 데이터 조회 중 오류가 발생했습니다.',
|
|
||||||
detail: error.message
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📊 기간별 작업 분석 데이터 조회
|
* 기간별 작업 분석 데이터 조회
|
||||||
*/
|
*/
|
||||||
const getAnalyticsByPeriod = async (req, res) => {
|
const getAnalyticsByPeriod = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { start_date, end_date, project_id, worker_id } = req.query;
|
||||||
const { start_date, end_date, project_id, worker_id } = req.query;
|
|
||||||
|
|
||||||
if (!start_date || !end_date) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'start_date와 end_date가 필요합니다.',
|
|
||||||
example: 'start_date=2025-08-01&end_date=2025-08-31'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
if (!start_date || !end_date) {
|
||||||
|
throw new ValidationError('start_date와 end_date가 필요합니다', {
|
||||||
|
required: ['start_date', 'end_date'],
|
||||||
|
received: { start_date, end_date },
|
||||||
|
example: 'start_date=2025-08-01&end_date=2025-08-31'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('기간별 분석 데이터 조회 요청', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
project_id,
|
||||||
|
worker_id
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
// 기본 조건
|
// 기본 조건
|
||||||
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
||||||
let queryParams = [start_date, end_date];
|
let queryParams = [start_date, end_date];
|
||||||
|
|
||||||
// 프로젝트 필터
|
|
||||||
if (project_id) {
|
if (project_id) {
|
||||||
whereConditions.push('dwr.project_id = ?');
|
whereConditions.push('dwr.project_id = ?');
|
||||||
queryParams.push(project_id);
|
queryParams.push(project_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 작업자 필터
|
|
||||||
if (worker_id) {
|
if (worker_id) {
|
||||||
whereConditions.push('dwr.worker_id = ?');
|
whereConditions.push('dwr.worker_id = ?');
|
||||||
queryParams.push(worker_id);
|
queryParams.push(worker_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(' AND ');
|
const whereClause = whereConditions.join(' AND ');
|
||||||
|
|
||||||
// 1. 전체 요약 통계 (에러 분석 포함)
|
// 1. 전체 요약 통계
|
||||||
const overallSql = `
|
const overallSql = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_entries,
|
COUNT(*) as total_entries,
|
||||||
SUM(dwr.work_hours) as total_hours,
|
SUM(dwr.work_hours) as total_hours,
|
||||||
COUNT(DISTINCT dwr.worker_id) as unique_workers,
|
COUNT(DISTINCT dwr.worker_id) as unique_workers,
|
||||||
@@ -111,12 +130,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
FROM daily_work_reports dwr
|
FROM daily_work_reports dwr
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [overallStats] = await db.query(overallSql, queryParams);
|
const [overallStats] = await db.query(overallSql, queryParams);
|
||||||
|
|
||||||
// 2. 일별 통계
|
// 2. 일별 통계
|
||||||
const dailyStatsSql = `
|
const dailyStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
dwr.report_date,
|
dwr.report_date,
|
||||||
SUM(dwr.work_hours) as daily_hours,
|
SUM(dwr.work_hours) as daily_hours,
|
||||||
COUNT(*) as daily_entries,
|
COUNT(*) as daily_entries,
|
||||||
@@ -126,12 +145,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY dwr.report_date
|
GROUP BY dwr.report_date
|
||||||
ORDER BY dwr.report_date ASC
|
ORDER BY dwr.report_date ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [dailyStats] = await db.query(dailyStatsSql, queryParams);
|
const [dailyStats] = await db.query(dailyStatsSql, queryParams);
|
||||||
|
|
||||||
// 2.5. 일별 에러 발생 통계
|
// 3. 일별 에러 통계
|
||||||
const dailyErrorStatsSql = `
|
const dailyErrorStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
dwr.report_date,
|
dwr.report_date,
|
||||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
|
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
|
||||||
COUNT(*) as daily_total,
|
COUNT(*) as daily_total,
|
||||||
@@ -141,12 +160,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY dwr.report_date
|
GROUP BY dwr.report_date
|
||||||
ORDER BY dwr.report_date ASC
|
ORDER BY dwr.report_date ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams);
|
const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams);
|
||||||
|
|
||||||
// 3. 에러 유형별 분석 (간단한 방식으로 수정)
|
// 4. 에러 유형별 분석
|
||||||
const errorAnalysisSql = `
|
const errorAnalysisSql = `
|
||||||
SELECT
|
SELECT
|
||||||
et.id as error_type_id,
|
et.id as error_type_id,
|
||||||
et.name as error_type_name,
|
et.name as error_type_name,
|
||||||
COUNT(*) as error_count,
|
COUNT(*) as error_count,
|
||||||
@@ -158,12 +177,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY et.id, et.name
|
GROUP BY et.id, et.name
|
||||||
ORDER BY error_count DESC
|
ORDER BY error_count DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams);
|
const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams);
|
||||||
|
|
||||||
// 4. 작업 유형별 분석
|
// 5. 작업 유형별 분석
|
||||||
const workTypeAnalysisSql = `
|
const workTypeAnalysisSql = `
|
||||||
SELECT
|
SELECT
|
||||||
wt.id as work_type_id,
|
wt.id as work_type_id,
|
||||||
wt.name as work_type_name,
|
wt.name as work_type_name,
|
||||||
COUNT(*) as work_count,
|
COUNT(*) as work_count,
|
||||||
@@ -177,12 +196,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY wt.id, wt.name
|
GROUP BY wt.id, wt.name
|
||||||
ORDER BY total_hours DESC
|
ORDER BY total_hours DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams);
|
const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams);
|
||||||
|
|
||||||
// 5. 작업자별 성과 분석
|
// 6. 작업자별 성과 분석
|
||||||
const workerAnalysisSql = `
|
const workerAnalysisSql = `
|
||||||
SELECT
|
SELECT
|
||||||
w.worker_id,
|
w.worker_id,
|
||||||
w.worker_name,
|
w.worker_name,
|
||||||
COUNT(*) as total_entries,
|
COUNT(*) as total_entries,
|
||||||
@@ -198,12 +217,12 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY w.worker_id, w.worker_name
|
GROUP BY w.worker_id, w.worker_name
|
||||||
ORDER BY total_hours DESC
|
ORDER BY total_hours DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams);
|
const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams);
|
||||||
|
|
||||||
// 6. 프로젝트별 분석
|
// 7. 프로젝트별 분석
|
||||||
const projectAnalysisSql = `
|
const projectAnalysisSql = `
|
||||||
SELECT
|
SELECT
|
||||||
p.project_id,
|
p.project_id,
|
||||||
p.project_name,
|
p.project_name,
|
||||||
COUNT(*) as total_entries,
|
COUNT(*) as total_entries,
|
||||||
@@ -219,9 +238,16 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
GROUP BY p.project_id, p.project_name
|
GROUP BY p.project_id, p.project_name
|
||||||
ORDER BY total_hours DESC
|
ORDER BY total_hours DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams);
|
const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams);
|
||||||
|
|
||||||
|
logger.info('기간별 분석 데이터 조회 성공', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
total_entries: overallStats[0].total_entries,
|
||||||
|
total_hours: overallStats[0].total_hours
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -234,48 +260,53 @@ const getAnalyticsByPeriod = async (req, res) => {
|
|||||||
projectAnalysis,
|
projectAnalysis,
|
||||||
period: { start_date, end_date },
|
period: { start_date, end_date },
|
||||||
filters: { project_id, worker_id }
|
filters: { project_id, worker_id }
|
||||||
}
|
},
|
||||||
|
message: '기간별 분석 데이터 조회 성공'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('기간별 분석 데이터 조회 오류:', error);
|
logger.error('기간별 분석 데이터 조회 실패', {
|
||||||
res.status(500).json({
|
start_date,
|
||||||
success: false,
|
end_date,
|
||||||
error: '기간별 분석 데이터 조회 중 오류가 발생했습니다.',
|
error: error.message
|
||||||
detail: error.message
|
|
||||||
});
|
});
|
||||||
|
throw new DatabaseError('기간별 분석 데이터 조회 중 오류가 발생했습니다');
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📈 프로젝트별 상세 분석
|
* 프로젝트별 상세 분석
|
||||||
*/
|
*/
|
||||||
const getProjectAnalysis = async (req, res) => {
|
const getProjectAnalysis = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { start_date, end_date, project_id } = req.query;
|
||||||
const { start_date, end_date, project_id } = req.query;
|
|
||||||
|
|
||||||
if (!start_date || !end_date) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'start_date와 end_date가 필요합니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
if (!start_date || !end_date) {
|
||||||
|
throw new ValidationError('start_date와 end_date가 필요합니다', {
|
||||||
|
required: ['start_date', 'end_date'],
|
||||||
|
received: { start_date, end_date }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('프로젝트별 분석 조회 요청', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
project_id
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
||||||
let queryParams = [start_date, end_date];
|
let queryParams = [start_date, end_date];
|
||||||
|
|
||||||
if (project_id) {
|
if (project_id) {
|
||||||
whereConditions.push('dwr.project_id = ?');
|
whereConditions.push('dwr.project_id = ?');
|
||||||
queryParams.push(project_id);
|
queryParams.push(project_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(' AND ');
|
const whereClause = whereConditions.join(' AND ');
|
||||||
|
|
||||||
// 프로젝트별 통계
|
|
||||||
const projectStatsSql = `
|
const projectStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
dwr.project_id,
|
dwr.project_id,
|
||||||
p.project_name,
|
p.project_name,
|
||||||
SUM(dwr.work_hours) as total_hours,
|
SUM(dwr.work_hours) as total_hours,
|
||||||
@@ -289,56 +320,67 @@ const getProjectAnalysis = async (req, res) => {
|
|||||||
GROUP BY dwr.project_id
|
GROUP BY dwr.project_id
|
||||||
ORDER BY total_hours DESC
|
ORDER BY total_hours DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [projectStats] = await db.query(projectStatsSql, queryParams);
|
const [projectStats] = await db.query(projectStatsSql, queryParams);
|
||||||
|
|
||||||
|
logger.info('프로젝트별 분석 조회 성공', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
projectCount: projectStats.length
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
projectStats,
|
projectStats,
|
||||||
period: { start_date, end_date }
|
period: { start_date, end_date }
|
||||||
}
|
},
|
||||||
|
message: '프로젝트별 분석 조회 성공'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('프로젝트별 분석 데이터 조회 오류:', error);
|
logger.error('프로젝트별 분석 조회 실패', {
|
||||||
res.status(500).json({
|
start_date,
|
||||||
success: false,
|
end_date,
|
||||||
error: '프로젝트별 분석 데이터 조회 중 오류가 발생했습니다.',
|
error: error.message
|
||||||
detail: error.message
|
|
||||||
});
|
});
|
||||||
|
throw new DatabaseError('프로젝트별 분석 데이터 조회 중 오류가 발생했습니다');
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 👤 작업자별 상세 분석
|
* 작업자별 상세 분석
|
||||||
*/
|
*/
|
||||||
const getWorkerAnalysis = async (req, res) => {
|
const getWorkerAnalysis = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { start_date, end_date, worker_id } = req.query;
|
||||||
const { start_date, end_date, worker_id } = req.query;
|
|
||||||
|
|
||||||
if (!start_date || !end_date) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'start_date와 end_date가 필요합니다.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
if (!start_date || !end_date) {
|
||||||
|
throw new ValidationError('start_date와 end_date가 필요합니다', {
|
||||||
|
required: ['start_date', 'end_date'],
|
||||||
|
received: { start_date, end_date }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('작업자별 분석 조회 요청', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
worker_id
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
|
||||||
let queryParams = [start_date, end_date];
|
let queryParams = [start_date, end_date];
|
||||||
|
|
||||||
if (worker_id) {
|
if (worker_id) {
|
||||||
whereConditions.push('dwr.worker_id = ?');
|
whereConditions.push('dwr.worker_id = ?');
|
||||||
queryParams.push(worker_id);
|
queryParams.push(worker_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(' AND ');
|
const whereClause = whereConditions.join(' AND ');
|
||||||
|
|
||||||
// 작업자별 통계
|
|
||||||
const workerStatsSql = `
|
const workerStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
dwr.worker_id,
|
dwr.worker_id,
|
||||||
w.worker_name,
|
w.worker_name,
|
||||||
SUM(dwr.work_hours) as total_hours,
|
SUM(dwr.work_hours) as total_hours,
|
||||||
@@ -352,30 +394,36 @@ const getWorkerAnalysis = async (req, res) => {
|
|||||||
GROUP BY dwr.worker_id
|
GROUP BY dwr.worker_id
|
||||||
ORDER BY total_hours DESC
|
ORDER BY total_hours DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [workerStats] = await db.query(workerStatsSql, queryParams);
|
const [workerStats] = await db.query(workerStatsSql, queryParams);
|
||||||
|
|
||||||
|
logger.info('작업자별 분석 조회 성공', {
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
workerCount: workerStats.length
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
workerStats,
|
workerStats,
|
||||||
period: { start_date, end_date }
|
period: { start_date, end_date }
|
||||||
}
|
},
|
||||||
|
message: '작업자별 분석 조회 성공'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('작업자별 분석 데이터 조회 오류:', error);
|
logger.error('작업자별 분석 조회 실패', {
|
||||||
res.status(500).json({
|
start_date,
|
||||||
success: false,
|
end_date,
|
||||||
error: '작업자별 분석 데이터 조회 중 오류가 발생했습니다.',
|
error: error.message
|
||||||
detail: error.message
|
|
||||||
});
|
});
|
||||||
|
throw new DatabaseError('작업자별 분석 데이터 조회 중 오류가 발생했습니다');
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAnalysisFilters,
|
getAnalysisFilters,
|
||||||
getAnalyticsByPeriod,
|
getAnalyticsByPeriod,
|
||||||
getProjectAnalysis,
|
getProjectAnalysis,
|
||||||
getWorkerAnalysis
|
getWorkerAnalysis
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,134 +1,175 @@
|
|||||||
// controllers/workReportController.js
|
/**
|
||||||
const workReportModel = require('../models/workReportModel');
|
* 작업 보고서 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업 보고서 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. CREATE: 단일 또는 다중 보고서 등록
|
const workReportService = require('../services/workReportService');
|
||||||
exports.createWorkReport = async (req, res) => {
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
try {
|
|
||||||
const reports = Array.isArray(req.body) ? req.body : [req.body];
|
|
||||||
const workReport_ids = [];
|
|
||||||
|
|
||||||
for (const report of reports) {
|
/**
|
||||||
const id = await new Promise((resolve, reject) => {
|
* 작업 보고서 생성 (단일 또는 다중)
|
||||||
workReportModel.create(report, (err, insertId) => {
|
*/
|
||||||
if (err) reject(err);
|
exports.createWorkReport = asyncHandler(async (req, res) => {
|
||||||
else resolve(insertId);
|
const result = await workReportService.createWorkReportService(req.body);
|
||||||
});
|
|
||||||
});
|
|
||||||
workReport_ids.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, workReport_ids });
|
res.json({
|
||||||
} catch (err) {
|
success: true,
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
data: result,
|
||||||
}
|
message: '작업 보고서가 성공적으로 생성되었습니다'
|
||||||
};
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 2. READ BY DATE
|
/**
|
||||||
exports.getWorkReportsByDate = async (req, res) => {
|
* 날짜별 작업 보고서 조회
|
||||||
try {
|
*/
|
||||||
const { date } = req.params;
|
exports.getWorkReportsByDate = asyncHandler(async (req, res) => {
|
||||||
const rows = await new Promise((resolve, reject) => {
|
const { date } = req.params;
|
||||||
workReportModel.getAllByDate(date, (err, data) => {
|
const rows = await workReportService.getWorkReportsByDateService(date);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(data);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. READ BY RANGE
|
res.json({
|
||||||
exports.getWorkReportsInRange = async (req, res) => {
|
success: true,
|
||||||
try {
|
data: rows,
|
||||||
const { start, end } = req.query;
|
message: '작업 보고서 조회 성공'
|
||||||
const rows = await new Promise((resolve, reject) => {
|
});
|
||||||
workReportModel.getByRange(start, end, (err, data) => {
|
});
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(data);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. READ ONE
|
/**
|
||||||
exports.getWorkReportById = async (req, res) => {
|
* 기간별 작업 보고서 조회
|
||||||
try {
|
*/
|
||||||
const { id } = req.params;
|
exports.getWorkReportsInRange = asyncHandler(async (req, res) => {
|
||||||
const row = await new Promise((resolve, reject) => {
|
const { start, end } = req.query;
|
||||||
workReportModel.getById(id, (err, data) => {
|
const rows = await workReportService.getWorkReportsInRangeService(start, end);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(data);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (!row) return res.status(404).json({ error: 'WorkReport not found' });
|
|
||||||
res.json(row);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. UPDATE
|
res.json({
|
||||||
exports.updateWorkReport = async (req, res) => {
|
success: true,
|
||||||
try {
|
data: rows,
|
||||||
const { id } = req.params;
|
message: '작업 보고서 조회 성공'
|
||||||
const changes = await new Promise((resolve, reject) => {
|
});
|
||||||
workReportModel.update(id, req.body, (err, affectedRows) => {
|
});
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(affectedRows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (changes === 0) return res.status(404).json({ error: 'No changes or not found' });
|
|
||||||
res.json({ success: true, changes });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. DELETE
|
/**
|
||||||
exports.removeWorkReport = async (req, res) => {
|
* 단일 작업 보고서 조회
|
||||||
try {
|
*/
|
||||||
const { id } = req.params;
|
exports.getWorkReportById = asyncHandler(async (req, res) => {
|
||||||
const changes = await new Promise((resolve, reject) => {
|
const { id } = req.params;
|
||||||
workReportModel.remove(id, (err, affectedRows) => {
|
const row = await workReportService.getWorkReportByIdService(id);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(affectedRows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (changes === 0) return res.status(404).json({ error: 'WorkReport not found' });
|
|
||||||
res.json({ success: true, changes });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 7. SUMMARY (월간)
|
res.json({
|
||||||
exports.getSummary = async (req, res) => {
|
success: true,
|
||||||
try {
|
data: row,
|
||||||
const { year, month } = req.query;
|
message: '작업 보고서 조회 성공'
|
||||||
if (!year || !month) {
|
});
|
||||||
return res.status(400).json({ error: '연도와 월이 필요합니다 (year, month)' });
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const start = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-01`;
|
/**
|
||||||
const end = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-31`;
|
* 작업 보고서 수정
|
||||||
|
*/
|
||||||
|
exports.updateWorkReport = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await workReportService.updateWorkReportService(id, req.body);
|
||||||
|
|
||||||
const rows = await new Promise((resolve, reject) => {
|
res.json({
|
||||||
workReportModel.getByRange(start, end, (err, data) => {
|
success: true,
|
||||||
if (err) reject(err);
|
data: result,
|
||||||
else resolve(data);
|
message: '작업 보고서가 성공적으로 수정되었습니다'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'WorkReport not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows);
|
/**
|
||||||
} catch (err) {
|
* 작업 보고서 삭제
|
||||||
res.status(500).json({ error: err.message || String(err) });
|
*/
|
||||||
}
|
exports.removeWorkReport = asyncHandler(async (req, res) => {
|
||||||
};
|
const { id } = req.params;
|
||||||
|
const result = await workReportService.removeWorkReportService(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '작업 보고서가 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월간 요약 조회
|
||||||
|
*/
|
||||||
|
exports.getSummary = asyncHandler(async (req, res) => {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const rows = await workReportService.getSummaryService(year, month);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '월간 요약 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 부적합 원인 관리 API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 보고서의 부적합 원인 목록 조회
|
||||||
|
*/
|
||||||
|
exports.getReportDefects = asyncHandler(async (req, res) => {
|
||||||
|
const { reportId } = req.params;
|
||||||
|
const rows = await workReportService.getReportDefectsService(reportId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '부적합 원인 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 원인 저장 (전체 교체)
|
||||||
|
* 기존 부적합 원인을 모두 삭제하고 새로 저장
|
||||||
|
*/
|
||||||
|
exports.saveReportDefects = asyncHandler(async (req, res) => {
|
||||||
|
const { reportId } = req.params;
|
||||||
|
const { defects } = req.body; // [{ error_type_id, defect_hours, note }]
|
||||||
|
|
||||||
|
const result = await workReportService.saveReportDefectsService(reportId, defects);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '부적합 원인이 저장되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 원인 추가 (단일)
|
||||||
|
*/
|
||||||
|
exports.addReportDefect = asyncHandler(async (req, res) => {
|
||||||
|
const { reportId } = req.params;
|
||||||
|
const { error_type_id, defect_hours, note } = req.body;
|
||||||
|
|
||||||
|
const result = await workReportService.addReportDefectService(reportId, {
|
||||||
|
error_type_id,
|
||||||
|
defect_hours,
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '부적합 원인이 추가되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 원인 삭제
|
||||||
|
*/
|
||||||
|
exports.removeReportDefect = asyncHandler(async (req, res) => {
|
||||||
|
const { defectId } = req.params;
|
||||||
|
const result = await workReportService.removeReportDefectService(defectId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '부적합 원인이 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,138 +1,278 @@
|
|||||||
// controllers/workerController.js
|
/**
|
||||||
|
* 작업자 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업자 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
const workerModel = require('../models/workerModel');
|
const workerModel = require('../models/workerModel');
|
||||||
const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler');
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
const { validateSchema, schemas } = require('../utils/validator');
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { optimizedQueries } = require('../utils/queryOptimizer');
|
const { optimizedQueries } = require('../utils/queryOptimizer');
|
||||||
|
const { hangulToRoman, generateUniqueUsername } = require('../utils/hangulToRoman');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
// 1. 작업자 생성
|
/**
|
||||||
|
* 작업자 생성
|
||||||
|
*/
|
||||||
exports.createWorker = asyncHandler(async (req, res) => {
|
exports.createWorker = asyncHandler(async (req, res) => {
|
||||||
const workerData = req.body;
|
const workerData = req.body;
|
||||||
|
const createAccount = req.body.create_account;
|
||||||
// 스키마 기반 유효성 검사
|
|
||||||
validateSchema(workerData, schemas.createWorker);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lastID = await new Promise((resolve, reject) => {
|
|
||||||
workerModel.create(workerData, (err, id) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 작업자 관련 캐시 무효화
|
|
||||||
await cache.invalidateCache.worker();
|
|
||||||
|
|
||||||
res.created({ worker_id: lastID }, '작업자가 성공적으로 생성되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업자 생성');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
|
logger.info('작업자 생성 요청', { name: workerData.worker_name, create_account: createAccount });
|
||||||
exports.getAllWorkers = asyncHandler(async (req, res) => {
|
|
||||||
const { page = 1, limit = 10, search = '' } = req.query;
|
const lastID = await workerModel.create(workerData);
|
||||||
|
|
||||||
// 캐시 키 생성
|
// 계정 생성 요청이 있으면 users 테이블에 계정 생성
|
||||||
const cacheKey = cache.createKey('workers', 'list', page, limit, search);
|
if (createAccount && workerData.worker_name) {
|
||||||
|
try {
|
||||||
try {
|
const db = await getDb();
|
||||||
// 캐시에서 조회
|
const username = await generateUniqueUsername(workerData.worker_name, db);
|
||||||
const cachedData = await cache.get(cacheKey);
|
const hashedPassword = await bcrypt.hash('1234', 10);
|
||||||
if (cachedData) {
|
|
||||||
console.log(`🎯 캐시 히트: ${cacheKey}`);
|
// User 역할 조회
|
||||||
return res.paginated(cachedData.data, cachedData.pagination.totalCount, page, limit, '작업자 목록 조회 성공 (캐시)');
|
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
|
||||||
|
|
||||||
|
if (userRole && userRole.length > 0) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||||
|
[username, hashedPassword, workerData.worker_name, lastID, userRole[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, username });
|
||||||
|
}
|
||||||
|
} catch (accountError) {
|
||||||
|
logger.error('계정 생성 실패 (작업자는 생성됨)', { worker_id: lastID, error: accountError.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 최적화된 쿼리 사용
|
|
||||||
const result = await optimizedQueries.getWorkersPaged(page, limit, search);
|
|
||||||
|
|
||||||
// 캐시에 저장 (5분)
|
|
||||||
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
|
|
||||||
console.log(`💾 캐시 저장: ${cacheKey}`);
|
|
||||||
|
|
||||||
res.paginated(result.data, result.pagination.totalCount, page, limit, '작업자 목록 조회 성공');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업자 목록 조회');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 작업자 관련 캐시 무효화
|
||||||
|
await cache.invalidateCache.worker();
|
||||||
|
|
||||||
|
logger.info('작업자 생성 성공', { worker_id: lastID });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { worker_id: lastID },
|
||||||
|
message: '작업자가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 단일 작업자 조회
|
/**
|
||||||
|
* 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
|
||||||
|
*/
|
||||||
|
exports.getAllWorkers = asyncHandler(async (req, res) => {
|
||||||
|
const { page = 1, limit = 100, search = '', status = '', department_id = null } = req.query;
|
||||||
|
|
||||||
|
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id);
|
||||||
|
|
||||||
|
// 캐시에서 조회
|
||||||
|
const cachedData = await cache.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.debug('캐시 히트', { cacheKey });
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: cachedData.data,
|
||||||
|
pagination: cachedData.pagination,
|
||||||
|
message: '작업자 목록 조회 성공 (캐시)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최적화된 쿼리 사용
|
||||||
|
const result = await optimizedQueries.getWorkersPaged(page, limit, search, status, department_id);
|
||||||
|
|
||||||
|
// 캐시에 저장 (5분)
|
||||||
|
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
|
||||||
|
logger.debug('캐시 저장', { cacheKey });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: result.pagination,
|
||||||
|
message: '작업자 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 작업자 조회
|
||||||
|
*/
|
||||||
exports.getWorkerById = asyncHandler(async (req, res) => {
|
exports.getWorkerById = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.worker_id, 10);
|
const id = parseInt(req.params.worker_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 작업자 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const row = await workerModel.getById(id);
|
||||||
const row = await new Promise((resolve, reject) => {
|
|
||||||
workerModel.getById(id, (err, data) => {
|
if (!row) {
|
||||||
if (err) reject(err);
|
throw new NotFoundError('작업자를 찾을 수 없습니다');
|
||||||
else resolve(data);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
handleNotFoundError('작업자', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.success(row, '작업자 조회 성공');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업자 조회');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: row,
|
||||||
|
message: '작업자 조회 성공'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 작업자 수정
|
/**
|
||||||
|
* 작업자 수정
|
||||||
|
*/
|
||||||
exports.updateWorker = asyncHandler(async (req, res) => {
|
exports.updateWorker = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.worker_id, 10);
|
const id = parseInt(req.params.worker_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 작업자 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
const workerData = { ...req.body, worker_id: id };
|
const workerData = { ...req.body, worker_id: id };
|
||||||
|
const createAccount = req.body.create_account;
|
||||||
try {
|
|
||||||
const changes = await new Promise((resolve, reject) => {
|
console.log('🔧 작업자 수정 요청:', {
|
||||||
workerModel.update(workerData, (err, affected) => {
|
worker_id: id,
|
||||||
if (err) reject(err);
|
받은데이터: req.body,
|
||||||
else resolve(affected);
|
처리할데이터: workerData,
|
||||||
});
|
create_account: createAccount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changes === 0) {
|
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
|
||||||
handleNotFoundError('작업자', id);
|
const currentWorker = await workerModel.getById(id);
|
||||||
}
|
|
||||||
|
if (!currentWorker) {
|
||||||
res.updated({ changes }, '작업자 정보가 성공적으로 수정되었습니다.');
|
throw new NotFoundError('작업자를 찾을 수 없습니다');
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업자 수정');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 작업자 정보 업데이트
|
||||||
|
const changes = await workerModel.update(workerData);
|
||||||
|
|
||||||
|
// 계정 생성/해제 처리
|
||||||
|
const db = await getDb();
|
||||||
|
const hasAccount = currentWorker.user_id !== null && currentWorker.user_id !== undefined;
|
||||||
|
let accountAction = null;
|
||||||
|
let accountUsername = null;
|
||||||
|
|
||||||
|
console.log('🔍 계정 생성 체크:', {
|
||||||
|
createAccount,
|
||||||
|
hasAccount,
|
||||||
|
currentWorker_user_id: currentWorker.user_id,
|
||||||
|
worker_name: workerData.worker_name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createAccount && !hasAccount && workerData.worker_name) {
|
||||||
|
// 계정 생성
|
||||||
|
console.log('✅ 계정 생성 로직 시작');
|
||||||
|
try {
|
||||||
|
console.log('🔑 사용자명 생성 중...');
|
||||||
|
const username = await generateUniqueUsername(workerData.worker_name, db);
|
||||||
|
console.log('🔑 생성된 사용자명:', username);
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('1234', 10);
|
||||||
|
console.log('🔒 비밀번호 해싱 완료');
|
||||||
|
|
||||||
|
// User 역할 조회
|
||||||
|
console.log('👤 User 역할 조회 중...');
|
||||||
|
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
|
||||||
|
console.log('👤 User 역할 조회 결과:', userRole);
|
||||||
|
|
||||||
|
if (userRole && userRole.length > 0) {
|
||||||
|
console.log('💾 계정 DB 삽입 시작...');
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||||
|
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
|
||||||
|
);
|
||||||
|
console.log('✅ 계정 DB 삽입 완료');
|
||||||
|
|
||||||
|
accountAction = 'created';
|
||||||
|
accountUsername = username;
|
||||||
|
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
|
||||||
|
} else {
|
||||||
|
console.log('❌ User 역할을 찾을 수 없음');
|
||||||
|
}
|
||||||
|
} catch (accountError) {
|
||||||
|
console.error('❌ 계정 생성 오류:', accountError);
|
||||||
|
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
|
||||||
|
accountAction = 'failed';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createAccount && hasAccount) {
|
||||||
|
// 계정 연동 해제 (users.worker_id = NULL)
|
||||||
|
try {
|
||||||
|
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
|
||||||
|
accountAction = 'unlinked';
|
||||||
|
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
|
||||||
|
} catch (unlinkError) {
|
||||||
|
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
|
||||||
|
accountAction = 'unlink_failed';
|
||||||
|
}
|
||||||
|
} else if (createAccount && hasAccount) {
|
||||||
|
accountAction = 'already_exists';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자 관련 캐시 무효화
|
||||||
|
logger.info('작업자 수정 후 캐시 무효화', { worker_id: id });
|
||||||
|
await cache.invalidateCache.worker();
|
||||||
|
|
||||||
|
logger.info('작업자 수정 성공', { worker_id: id });
|
||||||
|
|
||||||
|
// 응답 메시지 구성
|
||||||
|
let message = '작업자 정보가 성공적으로 수정되었습니다';
|
||||||
|
if (accountAction === 'created') {
|
||||||
|
message += ` (계정 생성 완료: ${accountUsername}, 초기 비밀번호: 1234)`;
|
||||||
|
} else if (accountAction === 'unlinked') {
|
||||||
|
message += ' (계정 연동 해제 완료)';
|
||||||
|
} else if (accountAction === 'already_exists') {
|
||||||
|
message += ' (이미 계정이 존재합니다)';
|
||||||
|
} else if (accountAction === 'failed') {
|
||||||
|
message += ' (계정 생성 실패)';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
changes,
|
||||||
|
account_action: accountAction,
|
||||||
|
account_username: accountUsername
|
||||||
|
},
|
||||||
|
message
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 작업자 삭제
|
/**
|
||||||
|
* 작업자 삭제
|
||||||
|
*/
|
||||||
exports.removeWorker = asyncHandler(async (req, res) => {
|
exports.removeWorker = asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.worker_id, 10);
|
const id = parseInt(req.params.worker_id, 10);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
throw new ApiError('유효하지 않은 작업자 ID입니다.', 400);
|
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const changes = await workerModel.remove(id);
|
||||||
const changes = await new Promise((resolve, reject) => {
|
|
||||||
workerModel.remove(id, (err, affected) => {
|
if (changes === 0) {
|
||||||
if (err) reject(err);
|
throw new NotFoundError('작업자를 찾을 수 없습니다');
|
||||||
else resolve(affected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changes === 0) {
|
|
||||||
handleNotFoundError('작업자', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.deleted('작업자가 성공적으로 삭제되었습니다.');
|
|
||||||
} catch (err) {
|
|
||||||
handleDatabaseError(err, '작업자 삭제');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 작업자 관련 캐시 무효화
|
||||||
|
logger.info('작업자 삭제 후 캐시 무효화 시작', { worker_id: id });
|
||||||
|
await cache.invalidateCache.worker();
|
||||||
|
await cache.delPattern('workers:*');
|
||||||
|
await cache.flush();
|
||||||
|
logger.info('작업자 삭제 후 캐시 무효화 완료', { worker_id: id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업자가 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
575
api.hyungi.net/controllers/workplaceController.js
Normal file
575
api.hyungi.net/controllers/workplaceController.js
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
/**
|
||||||
|
* 작업장 관리 컨트롤러
|
||||||
|
*
|
||||||
|
* 작업장 카테고리(공장) 및 작업장 CRUD API 엔드포인트 핸들러
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-01-26
|
||||||
|
*/
|
||||||
|
|
||||||
|
const workplaceModel = require('../models/workplaceModel');
|
||||||
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// ==================== 카테고리(공장) 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 생성
|
||||||
|
*/
|
||||||
|
exports.createCategory = asyncHandler(async (req, res) => {
|
||||||
|
const categoryData = req.body;
|
||||||
|
|
||||||
|
if (!categoryData.category_name) {
|
||||||
|
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('카테고리 생성 요청', { name: categoryData.category_name });
|
||||||
|
|
||||||
|
const id = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.createCategory(categoryData, (err, lastID) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 생성 중 오류가 발생했습니다'));
|
||||||
|
else resolve(lastID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('카테고리 생성 성공', { category_id: id });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { category_id: id },
|
||||||
|
message: '카테고리가 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 카테고리 조회
|
||||||
|
*/
|
||||||
|
exports.getAllCategories = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getAllCategories((err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 목록 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '카테고리 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 카테고리만 조회
|
||||||
|
*/
|
||||||
|
exports.getActiveCategories = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getActiveCategories((err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('활성 카테고리 목록 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '활성 카테고리 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 카테고리 조회
|
||||||
|
*/
|
||||||
|
exports.getCategoryById = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.params.id;
|
||||||
|
|
||||||
|
const category = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getCategoryById(categoryId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('카테고리를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: category,
|
||||||
|
message: '카테고리 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 수정
|
||||||
|
*/
|
||||||
|
exports.updateCategory = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.params.id;
|
||||||
|
const categoryData = req.body;
|
||||||
|
|
||||||
|
if (!categoryData.category_name) {
|
||||||
|
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('카테고리 수정 요청', { category_id: categoryId });
|
||||||
|
|
||||||
|
// 기존 카테고리 정보 가져오기
|
||||||
|
const existingCategory = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getCategoryById(categoryId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCategory) {
|
||||||
|
throw new NotFoundError('카테고리를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout_image가 요청에 없거나 null이면 기존 값 보존
|
||||||
|
const updateData = {
|
||||||
|
...categoryData,
|
||||||
|
layout_image: (categoryData.layout_image !== undefined && categoryData.layout_image !== null)
|
||||||
|
? categoryData.layout_image
|
||||||
|
: existingCategory.layout_image
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.updateCategory(categoryId, updateData, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 수정 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('카테고리 수정 성공', { category_id: categoryId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '카테고리가 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteCategory = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.params.id;
|
||||||
|
|
||||||
|
logger.info('카테고리 삭제 요청', { category_id: categoryId });
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.deleteCategory(categoryId, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 삭제 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('카테고리 삭제 성공', { category_id: categoryId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '카테고리가 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 작업장 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 생성
|
||||||
|
*/
|
||||||
|
exports.createWorkplace = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceData = req.body;
|
||||||
|
|
||||||
|
if (!workplaceData.workplace_name) {
|
||||||
|
throw new ValidationError('작업장명은 필수 입력 항목입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('작업장 생성 요청', { name: workplaceData.workplace_name });
|
||||||
|
|
||||||
|
const id = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.createWorkplace(workplaceData, (err, lastID) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 생성 중 오류가 발생했습니다'));
|
||||||
|
else resolve(lastID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('작업장 생성 성공', { workplace_id: id });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { workplace_id: id },
|
||||||
|
message: '작업장이 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 작업장 조회
|
||||||
|
*/
|
||||||
|
exports.getAllWorkplaces = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.query.category_id;
|
||||||
|
|
||||||
|
// 카테고리별 필터링
|
||||||
|
if (categoryId) {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getWorkplacesByCategory(categoryId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '작업장 목록 조회 성공'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 조회
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getAllWorkplaces((err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '작업장 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 작업장만 조회
|
||||||
|
*/
|
||||||
|
exports.getActiveWorkplaces = asyncHandler(async (req, res) => {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getActiveWorkplaces((err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('활성 작업장 목록 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '활성 작업장 목록 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 작업장 조회
|
||||||
|
*/
|
||||||
|
exports.getWorkplaceById = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceId = req.params.id;
|
||||||
|
|
||||||
|
const workplace = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workplace) {
|
||||||
|
throw new NotFoundError('작업장을 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: workplace,
|
||||||
|
message: '작업장 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 수정
|
||||||
|
*/
|
||||||
|
exports.updateWorkplace = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceId = req.params.id;
|
||||||
|
const workplaceData = req.body;
|
||||||
|
|
||||||
|
if (!workplaceData.workplace_name) {
|
||||||
|
throw new ValidationError('작업장명은 필수 입력 항목입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('작업장 수정 요청', { workplace_id: workplaceId });
|
||||||
|
|
||||||
|
// 기존 작업장 정보 가져오기
|
||||||
|
const existingWorkplace = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingWorkplace) {
|
||||||
|
throw new NotFoundError('작업장을 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout_image가 요청에 없거나 null이면 기존 값 보존
|
||||||
|
const updateData = {
|
||||||
|
...workplaceData,
|
||||||
|
layout_image: (workplaceData.layout_image !== undefined && workplaceData.layout_image !== null)
|
||||||
|
? workplaceData.layout_image
|
||||||
|
: existingWorkplace.layout_image
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.updateWorkplace(workplaceId, updateData, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 수정 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('작업장 수정 성공', { workplace_id: workplaceId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업장이 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteWorkplace = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceId = req.params.id;
|
||||||
|
|
||||||
|
logger.info('작업장 삭제 요청', { workplace_id: workplaceId });
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.deleteWorkplace(workplaceId, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 삭제 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('작업장 삭제 성공', { workplace_id: workplaceId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '작업장이 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 작업장 지도 영역 관련 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 레이아웃 이미지 업로드
|
||||||
|
*/
|
||||||
|
exports.uploadCategoryLayoutImage = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.params.id;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
throw new ValidationError('이미지 파일이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = `/uploads/${req.file.filename}`;
|
||||||
|
|
||||||
|
logger.info('카테고리 레이아웃 이미지 업로드 요청', { category_id: categoryId, path: imagePath });
|
||||||
|
|
||||||
|
// 현재 카테고리 정보 가져오기
|
||||||
|
const category = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getCategoryById(categoryId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('카테고리를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 정보 업데이트 (이미지 경로만 변경)
|
||||||
|
const updatedData = {
|
||||||
|
category_name: category.category_name,
|
||||||
|
description: category.description,
|
||||||
|
display_order: category.display_order,
|
||||||
|
is_active: category.is_active,
|
||||||
|
layout_image: imagePath
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.updateCategory(categoryId, updatedData, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('레이아웃 이미지 업로드 성공', { category_id: categoryId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { image_path: imagePath },
|
||||||
|
message: '레이아웃 이미지가 성공적으로 업로드되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 레이아웃 이미지 업로드
|
||||||
|
*/
|
||||||
|
exports.uploadWorkplaceLayoutImage = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceId = req.params.id;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
throw new ValidationError('이미지 파일이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = `/uploads/${req.file.filename}`;
|
||||||
|
|
||||||
|
logger.info('작업장 레이아웃 이미지 업로드 요청', { workplace_id: workplaceId, path: imagePath });
|
||||||
|
|
||||||
|
// 현재 작업장 정보 가져오기
|
||||||
|
const workplace = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workplace) {
|
||||||
|
throw new NotFoundError('작업장을 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업장 정보 업데이트 (이미지 경로만 변경)
|
||||||
|
const updatedData = {
|
||||||
|
workplace_name: workplace.workplace_name,
|
||||||
|
category_id: workplace.category_id,
|
||||||
|
description: workplace.description,
|
||||||
|
workplace_purpose: workplace.workplace_purpose,
|
||||||
|
display_priority: workplace.display_priority,
|
||||||
|
is_active: workplace.is_active,
|
||||||
|
layout_image: imagePath
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.updateWorkplace(workplaceId, updatedData, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('작업장 레이아웃 이미지 업로드 성공', { workplace_id: workplaceId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { image_path: imagePath },
|
||||||
|
message: '작업장 레이아웃 이미지가 성공적으로 업로드되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지도 영역 생성
|
||||||
|
*/
|
||||||
|
exports.createMapRegion = asyncHandler(async (req, res) => {
|
||||||
|
const regionData = req.body;
|
||||||
|
|
||||||
|
if (!regionData.workplace_id || !regionData.category_id) {
|
||||||
|
throw new ValidationError('작업장 ID와 카테고리 ID는 필수 입력 항목입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('지도 영역 생성 요청', { workplace_id: regionData.workplace_id });
|
||||||
|
|
||||||
|
const id = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.createMapRegion(regionData, (err, lastID) => {
|
||||||
|
if (err) reject(new DatabaseError('지도 영역 생성 중 오류가 발생했습니다'));
|
||||||
|
else resolve(lastID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('지도 영역 생성 성공', { region_id: id });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { region_id: id },
|
||||||
|
message: '지도 영역이 성공적으로 생성되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 지도 영역 조회 (작업장 정보 포함)
|
||||||
|
*/
|
||||||
|
exports.getMapRegionsByCategory = asyncHandler(async (req, res) => {
|
||||||
|
const categoryId = req.params.categoryId;
|
||||||
|
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getMapRegionsByCategory(categoryId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
message: '지도 영역 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장별 지도 영역 조회
|
||||||
|
*/
|
||||||
|
exports.getMapRegionByWorkplace = asyncHandler(async (req, res) => {
|
||||||
|
const workplaceId = req.params.workplaceId;
|
||||||
|
|
||||||
|
const region = await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.getMapRegionByWorkplace(workplaceId, (err, data) => {
|
||||||
|
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: region,
|
||||||
|
message: '지도 영역 조회 성공'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지도 영역 수정
|
||||||
|
*/
|
||||||
|
exports.updateMapRegion = asyncHandler(async (req, res) => {
|
||||||
|
const regionId = req.params.id;
|
||||||
|
const regionData = req.body;
|
||||||
|
|
||||||
|
logger.info('지도 영역 수정 요청', { region_id: regionId });
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.updateMapRegion(regionId, regionData, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('지도 영역 수정 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('지도 영역 수정 성공', { region_id: regionId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '지도 영역이 성공적으로 수정되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지도 영역 삭제
|
||||||
|
*/
|
||||||
|
exports.deleteMapRegion = asyncHandler(async (req, res) => {
|
||||||
|
const regionId = req.params.id;
|
||||||
|
|
||||||
|
logger.info('지도 영역 삭제 요청', { region_id: regionId });
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
workplaceModel.deleteMapRegion(regionId, (err, result) => {
|
||||||
|
if (err) reject(new DatabaseError('지도 영역 삭제 중 오류가 발생했습니다'));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('지도 영역 삭제 성공', { region_id: regionId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '지도 영역이 성공적으로 삭제되었습니다'
|
||||||
|
});
|
||||||
|
});
|
||||||
193
api.hyungi.net/create-attendance-tables.js
Normal file
193
api.hyungi.net/create-attendance-tables.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// 근태 관리 테이블 생성 스크립트
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
async function createAttendanceTables() {
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 로컬 MySQL 연결 (기본 설정)
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
host: 'localhost',
|
||||||
|
user: 'root',
|
||||||
|
password: '', // 비밀번호가 있다면 여기에 입력
|
||||||
|
database: 'hyungi'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ MySQL 연결 성공');
|
||||||
|
|
||||||
|
// 1. 근로 유형 테이블 생성
|
||||||
|
console.log('📋 근로 유형 테이블 생성 중...');
|
||||||
|
await connection.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS work_attendance_types (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드',
|
||||||
|
type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명',
|
||||||
|
description TEXT COMMENT '설명',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) COMMENT='근로 유형 관리 테이블'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 휴가 유형 테이블 생성
|
||||||
|
console.log('🏖️ 휴가 유형 테이블 생성 중...');
|
||||||
|
await connection.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vacation_types (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드',
|
||||||
|
type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명',
|
||||||
|
hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간',
|
||||||
|
description TEXT COMMENT '설명',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) COMMENT='휴가 유형 관리 테이블'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 일일 근태 기록 테이블 생성
|
||||||
|
console.log('📊 일일 근태 기록 테이블 생성 중...');
|
||||||
|
await connection.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_attendance_records (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
record_date DATE NOT NULL COMMENT '기록 날짜',
|
||||||
|
worker_id INT NOT NULL COMMENT '작업자 ID',
|
||||||
|
total_work_hours DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간',
|
||||||
|
attendance_type_id INT COMMENT '근로 유형 ID',
|
||||||
|
vacation_type_id INT NULL COMMENT '휴가 유형 ID',
|
||||||
|
is_vacation_processed BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부',
|
||||||
|
overtime_approved BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부',
|
||||||
|
overtime_approved_by INT NULL COMMENT '초과근무 승인자 ID',
|
||||||
|
overtime_approved_at TIMESTAMP NULL COMMENT '초과근무 승인 시간',
|
||||||
|
status ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태',
|
||||||
|
notes TEXT COMMENT '비고',
|
||||||
|
created_by INT NOT NULL DEFAULT 1 COMMENT '생성자 ID',
|
||||||
|
updated_by INT NULL COMMENT '수정자 ID',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY unique_worker_date (worker_id, record_date),
|
||||||
|
INDEX idx_record_date (record_date),
|
||||||
|
INDEX idx_worker_date (worker_id, record_date),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) COMMENT='일일 근태 기록 테이블'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. 작업자 휴가 잔여 관리 테이블 생성
|
||||||
|
console.log('📅 휴가 잔여 관리 테이블 생성 중...');
|
||||||
|
await connection.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_vacation_balance (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
worker_id INT NOT NULL COMMENT '작업자 ID',
|
||||||
|
year YEAR NOT NULL COMMENT '연도',
|
||||||
|
total_annual_leave DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)',
|
||||||
|
used_annual_leave DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)',
|
||||||
|
remaining_annual_leave DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)',
|
||||||
|
notes TEXT COMMENT '비고',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY unique_worker_year (worker_id, year),
|
||||||
|
INDEX idx_worker_year (worker_id, year)
|
||||||
|
) COMMENT='작업자별 휴가 잔여 관리 테이블'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 5. 기본 데이터 삽입
|
||||||
|
console.log('📝 기본 데이터 삽입 중...');
|
||||||
|
|
||||||
|
// 근로 유형 기본 데이터
|
||||||
|
await connection.execute(`
|
||||||
|
INSERT IGNORE INTO work_attendance_types (type_code, type_name, description) VALUES
|
||||||
|
('REGULAR', '정시근로', '8시간 정규 근무'),
|
||||||
|
('OVERTIME', '연장근로', '8시간 초과 근무'),
|
||||||
|
('PARTIAL', '부분근로', '8시간 미만 근무'),
|
||||||
|
('VACATION', '휴가근로', '휴가와 함께하는 부분 근무')
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 휴가 유형 기본 데이터
|
||||||
|
await connection.execute(`
|
||||||
|
INSERT IGNORE INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES
|
||||||
|
('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'),
|
||||||
|
('ANNUAL_HALF', '반차', 4.0, '반일 연차'),
|
||||||
|
('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'),
|
||||||
|
('SICK_FULL', '병가', 8.0, '하루 전체 병가'),
|
||||||
|
('SICK_HALF', '반일병가', 4.0, '반일 병가'),
|
||||||
|
('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'),
|
||||||
|
('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가')
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 6. 휴가 전용 작업 유형 추가
|
||||||
|
console.log('🏖️ 휴가 전용 작업 유형 추가 중...');
|
||||||
|
await connection.execute(`
|
||||||
|
INSERT IGNORE INTO work_types (name, description, is_active) VALUES
|
||||||
|
('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 7. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 (이미 있으면 무시)
|
||||||
|
try {
|
||||||
|
await connection.execute(`
|
||||||
|
ALTER TABLE daily_work_reports
|
||||||
|
ADD COLUMN attendance_record_id INT NULL COMMENT '근태 기록 ID' AFTER updated_by
|
||||||
|
`);
|
||||||
|
console.log('✅ daily_work_reports 테이블에 attendance_record_id 컬럼 추가됨');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ER_DUP_FIELDNAME') {
|
||||||
|
console.log('⚠️ attendance_record_id 컬럼 추가 실패:', error.message);
|
||||||
|
} else {
|
||||||
|
console.log('✅ attendance_record_id 컬럼이 이미 존재함');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 인덱스 추가
|
||||||
|
try {
|
||||||
|
await connection.execute(`CREATE INDEX idx_attendance_record ON daily_work_reports(attendance_record_id)`);
|
||||||
|
console.log('✅ attendance_record_id 인덱스 추가됨');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ 인덱스 추가 실패 (이미 존재할 수 있음):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 근태 관리 DB 설정 완료!');
|
||||||
|
console.log('');
|
||||||
|
console.log('📋 생성된 테이블:');
|
||||||
|
console.log(' - work_attendance_types (근로 유형)');
|
||||||
|
console.log(' - vacation_types (휴가 유형)');
|
||||||
|
console.log(' - daily_attendance_records (일일 근태 기록)');
|
||||||
|
console.log(' - worker_vacation_balance (휴가 잔여 관리)');
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ 기본 데이터도 모두 삽입되었습니다.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ DB 설정 중 오류 발생:', error);
|
||||||
|
|
||||||
|
// 다른 연결 정보로 시도
|
||||||
|
if (error.code === 'ECONNREFUSED' || error.code === 'ER_ACCESS_DENIED_ERROR') {
|
||||||
|
console.log('');
|
||||||
|
console.log('💡 다른 DB 연결 정보를 시도해보세요:');
|
||||||
|
console.log(' - host: localhost 또는 127.0.0.1');
|
||||||
|
console.log(' - port: 3306 (기본값)');
|
||||||
|
console.log(' - user: root 또는 다른 사용자');
|
||||||
|
console.log(' - password: 설정된 비밀번호');
|
||||||
|
console.log(' - database: hyungi');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (connection) {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 직접 실행
|
||||||
|
if (require.main === module) {
|
||||||
|
createAttendanceTables()
|
||||||
|
.then(() => {
|
||||||
|
console.log('✅ 설정 완료');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ 설정 실패:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAttendanceTables };
|
||||||
17
api.hyungi.net/db/connection.js
Normal file
17
api.hyungi.net/db/connection.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// db/connection.js - 레거시 콜백 방식 DB 래퍼
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
// 콜백 방식 쿼리 래퍼
|
||||||
|
const query = async (sql, params, callback) => {
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const [results] = await db.query(sql, params);
|
||||||
|
callback(null, results);
|
||||||
|
} catch (error) {
|
||||||
|
callback(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
query
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
const schemaSql = fs.readFileSync(path.join(__dirname, '../../hyungi_schema_v2.sql'), 'utf8');
|
||||||
|
return knex.raw(schemaSql);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
// down 마이그레이션은 모든 테이블을 역순으로 삭제하도록 구현합니다.
|
||||||
|
const tables = [
|
||||||
|
'cutting_plans',
|
||||||
|
'daily_issue_reports',
|
||||||
|
'daily_work_reports',
|
||||||
|
'codes',
|
||||||
|
'code_types',
|
||||||
|
'factory_info',
|
||||||
|
'equipment_list',
|
||||||
|
'pipe_specs',
|
||||||
|
'tasks',
|
||||||
|
'worker_groups',
|
||||||
|
'workers',
|
||||||
|
'projects',
|
||||||
|
'password_change_logs',
|
||||||
|
'login_logs',
|
||||||
|
'users'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 외래 키 제약 조건을 먼저 비활성화합니다.
|
||||||
|
return knex.raw('SET FOREIGN_KEY_CHECKS = 0;')
|
||||||
|
.then(() => {
|
||||||
|
// 각 테이블을 순회하며 drop table if exists를 실행합니다.
|
||||||
|
return tables.reduce((promise, tableName) => {
|
||||||
|
return promise.then(() => knex.schema.dropTableIfExists(tableName));
|
||||||
|
}, Promise.resolve());
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// 외래 키 제약 조건을 다시 활성화합니다.
|
||||||
|
return knex.raw('SET FOREIGN_KEY_CHECKS = 1;');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.table('projects', function (table) {
|
||||||
|
table.boolean('is_active').defaultTo(true).after('pm');
|
||||||
|
table.string('project_status').defaultTo('active').after('is_active');
|
||||||
|
table.date('completed_date').nullable().after('project_status');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.table('projects', function (table) {
|
||||||
|
table.dropColumn('is_active');
|
||||||
|
table.dropColumn('project_status');
|
||||||
|
table.dropColumn('completed_date');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
// 1. roles 테이블 생성
|
||||||
|
.createTable('roles', function(table) {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 50).notNullable().unique();
|
||||||
|
table.string('description', 255);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
})
|
||||||
|
// 2. permissions 테이블 생성
|
||||||
|
.createTable('permissions', function(table) {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 100).notNullable().unique(); // 예: 'user:create'
|
||||||
|
table.string('description', 255);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
})
|
||||||
|
// 3. role_permissions (역할-권한) 조인 테이블 생성
|
||||||
|
.createTable('role_permissions', function(table) {
|
||||||
|
table.integer('role_id').unsigned().notNullable().references('id').inTable('roles').onDelete('CASCADE');
|
||||||
|
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
|
||||||
|
table.primary(['role_id', 'permission_id']);
|
||||||
|
})
|
||||||
|
// 4. users 테이블에 role_id 추가 및 기존 컬럼 삭제
|
||||||
|
.table('users', function(table) {
|
||||||
|
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL').after('email');
|
||||||
|
// 기존 컬럼들은 삭제 또는 비활성화 (데이터 보존을 위해 일단 이름 변경)
|
||||||
|
table.renameColumn('role', '_role_old');
|
||||||
|
table.renameColumn('access_level', '_access_level_old');
|
||||||
|
})
|
||||||
|
// 5. user_permissions (사용자-개별 권한) 조인 테이블 생성
|
||||||
|
.createTable('user_permissions', function(table) {
|
||||||
|
table.integer('user_id').notNullable().references('user_id').inTable('users').onDelete('CASCADE');
|
||||||
|
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
|
||||||
|
table.primary(['user_id', 'permission_id']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('user_permissions')
|
||||||
|
.dropTableIfExists('role_permissions')
|
||||||
|
.dropTableIfExists('permissions')
|
||||||
|
.dropTableIfExists('roles')
|
||||||
|
.table('users', function(table) {
|
||||||
|
table.dropColumn('role_id');
|
||||||
|
table.renameColumn('_role_old', 'role');
|
||||||
|
table.renameColumn('_access_level_old', 'access_level');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. Roles 생성
|
||||||
|
await knex('roles').insert([
|
||||||
|
{ id: 1, name: 'System Admin', description: '시스템 전체 관리자. 모든 권한을 가짐.' },
|
||||||
|
{ id: 2, name: 'Admin', description: '관리자. 사용자 및 프로젝트 관리 등 대부분의 권한을 가짐.' },
|
||||||
|
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' },
|
||||||
|
{ id: 4, name: 'Worker', description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Permissions 생성 (예시)
|
||||||
|
const permissions = [
|
||||||
|
// User
|
||||||
|
{ name: 'user:create', description: '사용자 생성' },
|
||||||
|
{ name: 'user:read', description: '사용자 정보 조회' },
|
||||||
|
{ name: 'user:update', description: '사용자 정보 수정' },
|
||||||
|
{ name: 'user:delete', description: '사용자 삭제' },
|
||||||
|
// Project
|
||||||
|
{ name: 'project:create', description: '프로젝트 생성' },
|
||||||
|
{ name: 'project:read', description: '프로젝트 조회' },
|
||||||
|
{ name: 'project:update', description: '프로젝트 수정' },
|
||||||
|
{ name: 'project:delete', description: '프로젝트 삭제' },
|
||||||
|
// Work Report
|
||||||
|
{ name: 'work-report:create', description: '작업 보고서 생성' },
|
||||||
|
{ name: 'work-report:read-own', description: '자신의 작업 보고서 조회' },
|
||||||
|
{ name: 'work-report:read-team', description: '팀의 작업 보고서 조회' },
|
||||||
|
{ name: 'work-report:read-all', description: '모든 작업 보고서 조회' },
|
||||||
|
{ name: 'work-report:update', description: '작업 보고서 수정' },
|
||||||
|
{ name: 'work-report:delete', description: '작업 보고서 삭제' },
|
||||||
|
// System
|
||||||
|
{ name: 'system:read-logs', description: '시스템 로그 조회' },
|
||||||
|
{ name: 'system:manage-settings', description: '시스템 설정 관리' },
|
||||||
|
];
|
||||||
|
await knex('permissions').insert(permissions);
|
||||||
|
|
||||||
|
// 3. Role-Permissions 매핑
|
||||||
|
const allPermissions = await knex('permissions').select('id', 'name');
|
||||||
|
const permissionMap = allPermissions.reduce((acc, p) => {
|
||||||
|
acc[p.name] = p.id;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const rolePermissions = {
|
||||||
|
// System Admin (모든 권한)
|
||||||
|
'System Admin': allPermissions.map(p => p.id),
|
||||||
|
// Admin
|
||||||
|
'Admin': [
|
||||||
|
permissionMap['user:create'], permissionMap['user:read'], permissionMap['user:update'], permissionMap['user:delete'],
|
||||||
|
permissionMap['project:create'], permissionMap['project:read'], permissionMap['project:update'], permissionMap['project:delete'],
|
||||||
|
permissionMap['work-report:read-all'], permissionMap['work-report:update'], permissionMap['work-report:delete'],
|
||||||
|
],
|
||||||
|
// Leader
|
||||||
|
'Leader': [
|
||||||
|
permissionMap['user:read'],
|
||||||
|
permissionMap['project:read'],
|
||||||
|
permissionMap['work-report:read-team'],
|
||||||
|
permissionMap['work-report:read-own'],
|
||||||
|
permissionMap['work-report:create'],
|
||||||
|
],
|
||||||
|
// Worker
|
||||||
|
'Worker': [
|
||||||
|
permissionMap['work-report:create'],
|
||||||
|
permissionMap['work-report:read-own'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rolePermissionInserts = [];
|
||||||
|
for (const roleName in rolePermissions) {
|
||||||
|
const roleId = (await knex('roles').where('name', roleName).first()).id;
|
||||||
|
rolePermissions[roleName].forEach(permissionId => {
|
||||||
|
rolePermissionInserts.push({ role_id: roleId, permission_id: permissionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await knex('role_permissions').insert(rolePermissionInserts);
|
||||||
|
|
||||||
|
// 4. 기존 사용자에게 역할 부여 (예: 기존 admin -> Admin, leader -> Leader, user -> Worker)
|
||||||
|
await knex.raw(`
|
||||||
|
UPDATE users SET role_id =
|
||||||
|
CASE
|
||||||
|
WHEN _role_old = 'system' THEN (SELECT id FROM roles WHERE name = 'System Admin')
|
||||||
|
WHEN _role_old = 'admin' THEN (SELECT id FROM roles WHERE name = 'Admin')
|
||||||
|
WHEN _role_old = 'leader' THEN (SELECT id FROM roles WHERE name = 'Leader')
|
||||||
|
ELSE (SELECT id FROM roles WHERE name = 'Worker')
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('role_permissions').del();
|
||||||
|
await knex('user_permissions').del();
|
||||||
|
await knex('roles').del();
|
||||||
|
await knex('permissions').del();
|
||||||
|
|
||||||
|
// 역할 롤백 (단순화된 버전)
|
||||||
|
await knex.raw("UPDATE users SET _role_old = 'user' WHERE role_id IS NOT NULL");
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
|
||||||
|
|
||||||
|
if (!hasHireDate) {
|
||||||
|
await knex.schema.alterTable('workers', function (table) {
|
||||||
|
// Modify status to ENUM
|
||||||
|
// Note: Knex might not support modifying to ENUM easily across DBs, but valid for MySQL
|
||||||
|
// We use raw SQL for status modification to be safe with existing data
|
||||||
|
|
||||||
|
// Add new columns
|
||||||
|
table.string('phone_number', 20).nullable().comment('전화번호');
|
||||||
|
table.string('email', 100).nullable().comment('이메일');
|
||||||
|
table.date('hire_date').nullable().comment('입사일');
|
||||||
|
table.string('department', 100).nullable().comment('부서');
|
||||||
|
table.text('notes').nullable().comment('비고');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update status column using raw query
|
||||||
|
await knex.raw(`
|
||||||
|
ALTER TABLE workers
|
||||||
|
MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '작업자 상태 (active: 활성, inactive: 비활성)'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_status ON workers(status)`);
|
||||||
|
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_hire_date ON workers(hire_date)`);
|
||||||
|
|
||||||
|
// Set NULL status to active
|
||||||
|
await knex('workers').whereNull('status').update({ status: 'active' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
// We generally don't want to lose data on rollback of this critical schema fix,
|
||||||
|
// but technically we should revert changes.
|
||||||
|
// For safety, we might skip dropping columns or implement it carefully.
|
||||||
|
|
||||||
|
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
|
||||||
|
if (hasHireDate) {
|
||||||
|
await knex.schema.alterTable('workers', function (table) {
|
||||||
|
table.dropColumn('phone_number');
|
||||||
|
table.dropColumn('email');
|
||||||
|
table.dropColumn('hire_date');
|
||||||
|
table.dropColumn('department');
|
||||||
|
table.dropColumn('notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`
|
||||||
|
ALTER TABLE workers
|
||||||
|
MODIFY COLUMN status VARCHAR(20) DEFAULT 'active' COMMENT '상태 (active, inactive)'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 권한 시스템 단순화 및 페이지 접근 권한 추가
|
||||||
|
* - Leader와 Worker를 User로 통합
|
||||||
|
* - 페이지 접근 권한 테이블 생성
|
||||||
|
* - Admin이 사용자별 페이지 접근 권한을 설정할 수 있도록 함
|
||||||
|
*
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 페이지 목록 테이블 생성
|
||||||
|
await knex.schema.createTable('pages', function(table) {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('page_key', 100).notNullable().unique(); // 예: 'worker-management', 'project-management'
|
||||||
|
table.string('page_name', 100).notNullable(); // 예: '작업자 관리', '프로젝트 관리'
|
||||||
|
table.string('page_path', 255).notNullable(); // 예: '/pages/management/worker-management.html'
|
||||||
|
table.string('category', 50); // 예: 'management', 'dashboard', 'admin'
|
||||||
|
table.string('description', 255);
|
||||||
|
table.boolean('is_admin_only').defaultTo(false); // Admin 전용 페이지 여부
|
||||||
|
table.integer('display_order').defaultTo(0); // 표시 순서
|
||||||
|
table.timestamps(true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 사용자별 페이지 접근 권한 테이블 생성
|
||||||
|
await knex.schema.createTable('user_page_access', function(table) {
|
||||||
|
table.integer('user_id').notNullable()
|
||||||
|
.references('user_id').inTable('users').onDelete('CASCADE');
|
||||||
|
table.integer('page_id').unsigned().notNullable()
|
||||||
|
.references('id').inTable('pages').onDelete('CASCADE');
|
||||||
|
table.boolean('can_access').defaultTo(true); // 접근 가능 여부
|
||||||
|
table.timestamp('granted_at').defaultTo(knex.fn.now());
|
||||||
|
table.integer('granted_by') // 권한을 부여한 Admin의 user_id
|
||||||
|
.references('user_id').inTable('users').onDelete('SET NULL');
|
||||||
|
table.primary(['user_id', 'page_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 기본 페이지 목록 삽입
|
||||||
|
await knex('pages').insert([
|
||||||
|
// Dashboard
|
||||||
|
{ page_key: 'dashboard-user', page_name: '사용자 대시보드', page_path: '/pages/dashboard/user.html', category: 'dashboard', is_admin_only: false, display_order: 1 },
|
||||||
|
{ page_key: 'dashboard-leader', page_name: '그룹장 대시보드', page_path: '/pages/dashboard/group-leader.html', category: 'dashboard', is_admin_only: false, display_order: 2 },
|
||||||
|
|
||||||
|
// Management
|
||||||
|
{ page_key: 'worker-management', page_name: '작업자 관리', page_path: '/pages/management/worker-management.html', category: 'management', is_admin_only: false, display_order: 10 },
|
||||||
|
{ page_key: 'project-management', page_name: '프로젝트 관리', page_path: '/pages/management/project-management.html', category: 'management', is_admin_only: false, display_order: 11 },
|
||||||
|
{ page_key: 'work-management', page_name: '작업 관리', page_path: '/pages/management/work-management.html', category: 'management', is_admin_only: false, display_order: 12 },
|
||||||
|
{ page_key: 'code-management', page_name: '코드 관리', page_path: '/pages/management/code-management.html', category: 'management', is_admin_only: false, display_order: 13 },
|
||||||
|
|
||||||
|
// Common
|
||||||
|
{ page_key: 'daily-work-report', page_name: '작업 현황 확인', page_path: '/pages/common/daily-work-report-viewer.html', category: 'common', is_admin_only: false, display_order: 20 },
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
{ page_key: 'user-management', page_name: '사용자 관리', page_path: '/pages/admin/manage-user.html', category: 'admin', is_admin_only: true, display_order: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. roles 테이블 업데이트: Leader와 Worker를 User로 통합
|
||||||
|
// Leader와 Worker 역할을 가진 사용자를 모두 User로 변경
|
||||||
|
const userRoleId = await knex('roles').where('name', 'Worker').first().then(r => r.id);
|
||||||
|
const leaderRoleId = await knex('roles').where('name', 'Leader').first().then(r => r ? r.id : null);
|
||||||
|
|
||||||
|
if (leaderRoleId) {
|
||||||
|
// Leader를 User로 변경
|
||||||
|
await knex('users').where('role_id', leaderRoleId).update({ role_id: userRoleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. role_permissions 업데이트: Worker 권한을 확장하여 모든 일반 기능 사용 가능하게
|
||||||
|
const allPermissions = await knex('permissions').select('id', 'name');
|
||||||
|
const permissionMap = allPermissions.reduce((acc, p) => {
|
||||||
|
acc[p.name] = p.id;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Worker 역할의 기존 권한 삭제
|
||||||
|
await knex('role_permissions').where('role_id', userRoleId).del();
|
||||||
|
|
||||||
|
// Worker(이제 User) 역할에 모든 일반 권한 부여 (Admin/System 권한 제외)
|
||||||
|
const userPermissions = [
|
||||||
|
permissionMap['user:read'],
|
||||||
|
permissionMap['project:read'],
|
||||||
|
permissionMap['project:create'],
|
||||||
|
permissionMap['project:update'],
|
||||||
|
permissionMap['work-report:create'],
|
||||||
|
permissionMap['work-report:read-own'],
|
||||||
|
permissionMap['work-report:read-team'],
|
||||||
|
permissionMap['work-report:read-all'],
|
||||||
|
permissionMap['work-report:update'],
|
||||||
|
permissionMap['work-report:delete'],
|
||||||
|
].filter(Boolean); // undefined 제거
|
||||||
|
|
||||||
|
const rolePermissionInserts = userPermissions.map(permissionId => ({
|
||||||
|
role_id: userRoleId,
|
||||||
|
permission_id: permissionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
await knex('role_permissions').insert(rolePermissionInserts);
|
||||||
|
|
||||||
|
// 6. Leader 역할 삭제 (더 이상 사용하지 않음)
|
||||||
|
if (leaderRoleId) {
|
||||||
|
await knex('role_permissions').where('role_id', leaderRoleId).del();
|
||||||
|
await knex('roles').where('id', leaderRoleId).del();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Worker 역할 이름을 'User'로 변경
|
||||||
|
await knex('roles').where('id', userRoleId).update({
|
||||||
|
name: 'User',
|
||||||
|
description: '일반 사용자. 작업 보고서 및 프로젝트 관리 등 모든 일반 기능을 사용할 수 있음.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 모든 일반 사용자에게 모든 페이지 접근 권한 부여 (Admin 페이지 제외)
|
||||||
|
const normalPages = await knex('pages').where('is_admin_only', false).select('id');
|
||||||
|
const normalUsers = await knex('users').where('role_id', userRoleId).select('user_id');
|
||||||
|
|
||||||
|
const userPageAccessInserts = [];
|
||||||
|
normalUsers.forEach(user => {
|
||||||
|
normalPages.forEach(page => {
|
||||||
|
userPageAccessInserts.push({
|
||||||
|
user_id: user.user_id,
|
||||||
|
page_id: page.id,
|
||||||
|
can_access: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userPageAccessInserts.length > 0) {
|
||||||
|
await knex('user_page_access').insert(userPageAccessInserts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 테이블 삭제 (역순)
|
||||||
|
await knex.schema.dropTableIfExists('user_page_access');
|
||||||
|
await knex.schema.dropTableIfExists('pages');
|
||||||
|
|
||||||
|
// User 역할을 다시 Worker로 변경
|
||||||
|
const userRoleId = await knex('roles').where('name', 'User').first().then(r => r ? r.id : null);
|
||||||
|
if (userRoleId) {
|
||||||
|
await knex('roles').where('id', userRoleId).update({
|
||||||
|
name: 'Worker',
|
||||||
|
description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leader 역할 재생성
|
||||||
|
await knex('roles').insert([
|
||||||
|
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' }
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await knex.schema.alterTable('workers', (table) => {
|
||||||
|
// 재직 상태 (employed: 재직, resigned: 퇴사)
|
||||||
|
table.enum('employment_status', ['employed', 'resigned'])
|
||||||
|
.defaultTo('employed')
|
||||||
|
.notNullable()
|
||||||
|
.comment('재직 상태 (employed: 재직, resigned: 퇴사)');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ workers 테이블에 employment_status 컬럼 추가 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.alterTable('workers', (table) => {
|
||||||
|
table.dropColumn('employment_status');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ workers 테이블에서 employment_status 컬럼 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: Workers 테이블에 급여 및 기본 연차 컬럼 추가
|
||||||
|
* 작성일: 2026-01-19
|
||||||
|
*
|
||||||
|
* 변경사항:
|
||||||
|
* - salary 컬럼 추가 (NULL 허용, 선택 사항)
|
||||||
|
* - base_annual_leave 컬럼 추가 (기본값: 15일)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ Workers 테이블에 salary, base_annual_leave 컬럼 추가 중...');
|
||||||
|
|
||||||
|
await knex.schema.alterTable('workers', (table) => {
|
||||||
|
// 급여 정보 (선택 사항, NULL 허용)
|
||||||
|
table.decimal('salary', 12, 2).nullable().comment('급여 (선택)');
|
||||||
|
|
||||||
|
// 기본 연차 일수 (기본값: 15일)
|
||||||
|
table.integer('base_annual_leave').defaultTo(15).notNullable().comment('기본 연차 일수');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Workers 테이블 컬럼 추가 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ Workers 테이블에서 salary, base_annual_leave 컬럼 제거 중...');
|
||||||
|
|
||||||
|
await knex.schema.alterTable('workers', (table) => {
|
||||||
|
table.dropColumn('salary');
|
||||||
|
table.dropColumn('base_annual_leave');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Workers 테이블 컬럼 제거 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 출근/근태 관련 테이블 생성
|
||||||
|
* 작성일: 2026-01-19
|
||||||
|
*
|
||||||
|
* 생성 테이블:
|
||||||
|
* - work_attendance_types: 출근 유형 (정상, 지각, 조퇴, 결근, 휴가)
|
||||||
|
* - vacation_types: 휴가 유형 (연차, 반차, 병가, 경조사)
|
||||||
|
* - daily_attendance_records: 일일 출근 기록
|
||||||
|
* - worker_vacation_balance: 작업자 연차 잔액 (연도별)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ 출근/근태 관련 테이블 생성 중...');
|
||||||
|
|
||||||
|
// 1. 출근 유형 테이블
|
||||||
|
await knex.schema.createTable('work_attendance_types', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('type_code', 20).unique().notNullable().comment('유형 코드');
|
||||||
|
table.string('type_name', 50).notNullable().comment('유형 이름');
|
||||||
|
table.text('description').nullable().comment('설명');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
console.log('✅ work_attendance_types 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 초기 데이터 입력
|
||||||
|
await knex('work_attendance_types').insert([
|
||||||
|
{ type_code: 'NORMAL', type_name: '정상 출근', description: '정상 출근' },
|
||||||
|
{ type_code: 'LATE', type_name: '지각', description: '지각' },
|
||||||
|
{ type_code: 'EARLY_LEAVE', type_name: '조퇴', description: '조퇴' },
|
||||||
|
{ type_code: 'ABSENT', type_name: '결근', description: '무단 결근' },
|
||||||
|
{ type_code: 'VACATION', type_name: '휴가', description: '승인된 휴가' }
|
||||||
|
]);
|
||||||
|
console.log('✅ work_attendance_types 초기 데이터 입력 완료');
|
||||||
|
|
||||||
|
// 2. 휴가 유형 테이블
|
||||||
|
await knex.schema.createTable('vacation_types', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
|
||||||
|
table.string('type_name', 50).notNullable().comment('휴가 이름');
|
||||||
|
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
console.log('✅ vacation_types 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 초기 데이터 입력
|
||||||
|
await knex('vacation_types').insert([
|
||||||
|
{ type_code: 'ANNUAL', type_name: '연차', deduct_days: 1.0 },
|
||||||
|
{ type_code: 'HALF_ANNUAL', type_name: '반차', deduct_days: 0.5 },
|
||||||
|
{ type_code: 'SICK', type_name: '병가', deduct_days: 1.0 },
|
||||||
|
{ type_code: 'SPECIAL', type_name: '경조사', deduct_days: 0 }
|
||||||
|
]);
|
||||||
|
console.log('✅ vacation_types 초기 데이터 입력 완료');
|
||||||
|
|
||||||
|
// 3. 일일 출근 기록 테이블
|
||||||
|
await knex.schema.createTable('daily_attendance_records', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
|
||||||
|
table.date('record_date').notNullable().comment('기록 날짜');
|
||||||
|
table.integer('attendance_type_id').unsigned().notNullable().comment('출근 유형 ID');
|
||||||
|
table.integer('vacation_type_id').unsigned().nullable().comment('휴가 유형 ID');
|
||||||
|
table.time('check_in_time').nullable().comment('출근 시간');
|
||||||
|
table.time('check_out_time').nullable().comment('퇴근 시간');
|
||||||
|
table.decimal('total_work_hours', 4, 2).defaultTo(0).comment('총 근무 시간');
|
||||||
|
table.boolean('is_overtime_approved').defaultTo(false).comment('초과근무 승인 여부');
|
||||||
|
table.text('notes').nullable().comment('비고');
|
||||||
|
table.integer('created_by').unsigned().notNullable().comment('등록자 user_id');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.unique(['worker_id', 'record_date']);
|
||||||
|
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
|
||||||
|
table.foreign('attendance_type_id').references('work_attendance_types.id');
|
||||||
|
table.foreign('vacation_type_id').references('vacation_types.id');
|
||||||
|
table.foreign('created_by').references('users.user_id');
|
||||||
|
});
|
||||||
|
console.log('✅ daily_attendance_records 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 4. 작업자 연차 잔액 테이블
|
||||||
|
await knex.schema.createTable('worker_vacation_balance', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
|
||||||
|
table.integer('year').notNullable().comment('연도');
|
||||||
|
table.decimal('total_annual_leave', 4, 1).defaultTo(15.0).comment('총 연차');
|
||||||
|
table.decimal('used_annual_leave', 4, 1).defaultTo(0).comment('사용 연차');
|
||||||
|
// remaining_annual_leave는 애플리케이션 레벨에서 계산
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.unique(['worker_id', 'year']);
|
||||||
|
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
|
||||||
|
});
|
||||||
|
console.log('✅ worker_vacation_balance 테이블 생성 완료');
|
||||||
|
|
||||||
|
console.log('✅ 모든 출근/근태 관련 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ 출근/근태 관련 테이블 제거 중...');
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists('worker_vacation_balance');
|
||||||
|
await knex.schema.dropTableIfExists('daily_attendance_records');
|
||||||
|
await knex.schema.dropTableIfExists('vacation_types');
|
||||||
|
await knex.schema.dropTableIfExists('work_attendance_types');
|
||||||
|
|
||||||
|
console.log('✅ 모든 출근/근태 관련 테이블 제거 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 기존 작업자에게 계정 자동 생성
|
||||||
|
* 작성일: 2026-01-19
|
||||||
|
*
|
||||||
|
* 작업 내용:
|
||||||
|
* 1. 계정이 없는 기존 작업자 조회
|
||||||
|
* 2. 각 작업자에 대해 users 테이블에 계정 생성
|
||||||
|
* 3. username은 이름 기반으로 자동 생성 (예: 홍길동 → hong.gildong)
|
||||||
|
* 4. 초기 비밀번호는 '1234'로 통일 (첫 로그인 시 변경 권장)
|
||||||
|
* 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const { generateUniqueUsername } = require('../../utils/hangulToRoman');
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ 기존 작업자들에게 계정 자동 생성 중...');
|
||||||
|
|
||||||
|
// 1. 계정이 없는 작업자 조회
|
||||||
|
const workersWithoutAccount = await knex('workers')
|
||||||
|
.leftJoin('users', 'workers.worker_id', 'users.worker_id')
|
||||||
|
.whereNull('users.user_id')
|
||||||
|
.select(
|
||||||
|
'workers.worker_id',
|
||||||
|
'workers.worker_name',
|
||||||
|
'workers.email',
|
||||||
|
'workers.status',
|
||||||
|
'workers.annual_leave'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📊 계정이 없는 작업자: ${workersWithoutAccount.length}명`);
|
||||||
|
|
||||||
|
if (workersWithoutAccount.length === 0) {
|
||||||
|
console.log('ℹ️ 계정이 필요한 작업자가 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 각 작업자에 대해 계정 생성
|
||||||
|
const initialPassword = '1234'; // 초기 비밀번호
|
||||||
|
const hashedPassword = await bcrypt.hash(initialPassword, 10);
|
||||||
|
|
||||||
|
// User 역할 ID 조회
|
||||||
|
const userRole = await knex('roles')
|
||||||
|
.where('name', 'User')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!userRole) {
|
||||||
|
throw new Error('User 역할이 존재하지 않습니다. 권한 마이그레이션을 먼저 실행하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const worker of workersWithoutAccount) {
|
||||||
|
try {
|
||||||
|
// username 생성 (중복 체크 포함)
|
||||||
|
const username = await generateUniqueUsername(worker.worker_name, knex);
|
||||||
|
|
||||||
|
// 계정 생성
|
||||||
|
await knex('users').insert({
|
||||||
|
username: username,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: worker.worker_name,
|
||||||
|
email: worker.email,
|
||||||
|
worker_id: worker.worker_id,
|
||||||
|
role_id: userRole.id,
|
||||||
|
is_active: worker.status === 'active' ? 1 : 0,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${worker.worker_name} (ID: ${worker.worker_id}) → username: ${username}`);
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
// 현재 연도 연차 잔액 초기화
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
await knex('worker_vacation_balance').insert({
|
||||||
|
worker_id: worker.worker_id,
|
||||||
|
year: currentYear,
|
||||||
|
total_annual_leave: worker.annual_leave || 15,
|
||||||
|
used_annual_leave: 0,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${worker.worker_name} 계정 생성 실패:`, error.message);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 작업 완료: 성공 ${successCount}명, 실패 ${errorCount}명`);
|
||||||
|
console.log(`🔐 초기 비밀번호: ${initialPassword} (모든 계정 공통)`);
|
||||||
|
console.log('⚠️ 사용자들에게 첫 로그인 후 비밀번호를 변경하도록 안내해주세요!');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ 자동 생성된 계정 제거 중...');
|
||||||
|
|
||||||
|
// 이 마이그레이션으로 생성된 계정은 구분하기 어려우므로
|
||||||
|
// rollback 시 주의가 필요합니다.
|
||||||
|
console.log('⚠️ 경고: 이 마이그레이션의 rollback은 권장하지 않습니다.');
|
||||||
|
console.log('ℹ️ 필요시 수동으로 users 테이블을 관리하세요.');
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 게스트 역할 추가
|
||||||
|
* 작성일: 2026-01-19
|
||||||
|
*
|
||||||
|
* 변경사항:
|
||||||
|
* - Guest 역할 추가 (계정 없이 특정 기능 접근 가능)
|
||||||
|
* - 게스트 전용 페이지 추가 (신고 채널 등)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ 게스트 역할 추가 중...');
|
||||||
|
|
||||||
|
// 1. Guest 역할 추가
|
||||||
|
const [guestRoleId] = await knex('roles').insert({
|
||||||
|
name: 'Guest',
|
||||||
|
description: '게스트 (계정 없이 특정 기능 접근 가능)',
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
console.log(`✅ Guest 역할 추가 완료 (ID: ${guestRoleId})`);
|
||||||
|
|
||||||
|
// 2. 게스트 전용 페이지 추가
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'guest_report',
|
||||||
|
page_name: '신고 채널',
|
||||||
|
page_path: '/pages/guest/report.html',
|
||||||
|
category: 'guest',
|
||||||
|
is_admin_only: false,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
console.log('✅ 게스트 전용 페이지 추가 완료 (신고 채널)');
|
||||||
|
|
||||||
|
console.log('✅ 게스트 역할 추가 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ 게스트 역할 제거 중...');
|
||||||
|
|
||||||
|
// 페이지 제거
|
||||||
|
await knex('pages')
|
||||||
|
.where('page_key', 'guest_report')
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// 역할 제거
|
||||||
|
await knex('roles')
|
||||||
|
.where('name', 'Guest')
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
console.log('✅ 게스트 역할 제거 완료');
|
||||||
|
};
|
||||||
158
api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
Normal file
158
api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: TBM (Tool Box Meeting) 시스템
|
||||||
|
* 작성일: 2026-01-20
|
||||||
|
*
|
||||||
|
* 생성 테이블:
|
||||||
|
* - tbm_sessions: TBM 세션 (아침 미팅 기록)
|
||||||
|
* - tbm_team_assignments: TBM 팀 구성 (리더가 선택한 작업자들)
|
||||||
|
* - tbm_safety_checks: TBM 안전 체크리스트
|
||||||
|
* - tbm_safety_records: TBM 안전 체크 기록
|
||||||
|
* - team_handovers: 작업 인계 기록 (반차/조퇴 시)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ TBM 시스템 테이블 생성 중...');
|
||||||
|
|
||||||
|
// 1. TBM 세션 테이블 (아침 미팅)
|
||||||
|
await knex.schema.createTable('tbm_sessions', (table) => {
|
||||||
|
table.increments('session_id').primary();
|
||||||
|
table.date('session_date').notNullable().comment('TBM 날짜');
|
||||||
|
table.integer('leader_id').notNullable().comment('팀장 worker_id');
|
||||||
|
table.integer('project_id').nullable().comment('프로젝트 ID');
|
||||||
|
table.string('work_location', 200).nullable().comment('작업 장소');
|
||||||
|
table.text('work_description').nullable().comment('작업 내용');
|
||||||
|
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
|
||||||
|
table.enum('status', ['draft', 'completed', 'cancelled']).defaultTo('draft').comment('상태');
|
||||||
|
table.time('start_time').nullable().comment('TBM 시작 시간');
|
||||||
|
table.time('end_time').nullable().comment('TBM 종료 시간');
|
||||||
|
table.integer('created_by').notNullable().comment('생성자 user_id');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.index(['session_date', 'leader_id']);
|
||||||
|
table.foreign('leader_id').references('workers.worker_id');
|
||||||
|
table.foreign('project_id').references('projects.project_id').onDelete('SET NULL');
|
||||||
|
table.foreign('created_by').references('users.user_id');
|
||||||
|
});
|
||||||
|
console.log('✅ tbm_sessions 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 2. TBM 팀 구성 테이블 (리더가 선택한 팀원들)
|
||||||
|
await knex.schema.createTable('tbm_team_assignments', (table) => {
|
||||||
|
table.increments('assignment_id').primary();
|
||||||
|
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
|
||||||
|
table.integer('worker_id').notNullable().comment('팀원 worker_id');
|
||||||
|
table.string('assigned_role', 100).nullable().comment('역할/담당');
|
||||||
|
table.text('work_detail').nullable().comment('세부 작업 내용');
|
||||||
|
table.boolean('is_present').defaultTo(true).comment('출석 여부');
|
||||||
|
table.text('absence_reason').nullable().comment('결석 사유');
|
||||||
|
table.timestamp('assigned_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.unique(['session_id', 'worker_id']);
|
||||||
|
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
|
||||||
|
table.foreign('worker_id').references('workers.worker_id');
|
||||||
|
});
|
||||||
|
console.log('✅ tbm_team_assignments 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 3. TBM 안전 체크리스트 마스터 테이블
|
||||||
|
await knex.schema.createTable('tbm_safety_checks', (table) => {
|
||||||
|
table.increments('check_id').primary();
|
||||||
|
table.string('check_category', 50).notNullable().comment('카테고리 (장비, PPE, 환경 등)');
|
||||||
|
table.string('check_item', 200).notNullable().comment('체크 항목');
|
||||||
|
table.text('description').nullable().comment('설명');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_required').defaultTo(true).comment('필수 체크 여부');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index('check_category');
|
||||||
|
});
|
||||||
|
console.log('✅ tbm_safety_checks 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 초기 안전 체크리스트 데이터
|
||||||
|
await knex('tbm_safety_checks').insert([
|
||||||
|
// PPE (개인 보호 장비)
|
||||||
|
{ check_category: 'PPE', check_item: '안전모 착용 확인', display_order: 1, is_required: true },
|
||||||
|
{ check_category: 'PPE', check_item: '안전화 착용 확인', display_order: 2, is_required: true },
|
||||||
|
{ check_category: 'PPE', check_item: '안전조끼 착용 확인', display_order: 3, is_required: true },
|
||||||
|
{ check_category: 'PPE', check_item: '안전벨트 착용 확인 (고소작업 시)', display_order: 4, is_required: false },
|
||||||
|
{ check_category: 'PPE', check_item: '보안경/마스크 착용 확인', display_order: 5, is_required: false },
|
||||||
|
|
||||||
|
// 장비 점검
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '작업 도구 점검 완료', display_order: 10, is_required: true },
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '전동공구 안전 점검', display_order: 11, is_required: true },
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '사다리/비계 안전 확인', display_order: 12, is_required: false },
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '차량/중장비 점검 완료', display_order: 13, is_required: false },
|
||||||
|
|
||||||
|
// 작업 환경
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '작업 장소 정리정돈 확인', display_order: 20, is_required: true },
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '위험 구역 표시 확인', display_order: 21, is_required: true },
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '기상 상태 확인 (우천, 강풍 등)', display_order: 22, is_required: true },
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '작업 동선 안전 확인', display_order: 23, is_required: true },
|
||||||
|
|
||||||
|
// 비상 대응
|
||||||
|
{ check_category: 'EMERGENCY', check_item: '비상연락망 공유 완료', display_order: 30, is_required: true },
|
||||||
|
{ check_category: 'EMERGENCY', check_item: '소화기 위치 확인', display_order: 31, is_required: true },
|
||||||
|
{ check_category: 'EMERGENCY', check_item: '응급처치 키트 위치 확인', display_order: 32, is_required: true },
|
||||||
|
]);
|
||||||
|
console.log('✅ tbm_safety_checks 초기 데이터 입력 완료');
|
||||||
|
|
||||||
|
// 4. TBM 안전 체크 기록 테이블
|
||||||
|
await knex.schema.createTable('tbm_safety_records', (table) => {
|
||||||
|
table.increments('record_id').primary();
|
||||||
|
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
|
||||||
|
table.integer('check_id').unsigned().notNullable().comment('체크 항목 ID');
|
||||||
|
table.boolean('is_checked').defaultTo(false).comment('체크 여부');
|
||||||
|
table.text('notes').nullable().comment('비고/특이사항');
|
||||||
|
table.integer('checked_by').nullable().comment('체크한 user_id');
|
||||||
|
table.timestamp('checked_at').nullable().comment('체크 시간');
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.unique(['session_id', 'check_id']);
|
||||||
|
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
|
||||||
|
table.foreign('check_id').references('tbm_safety_checks.check_id');
|
||||||
|
table.foreign('checked_by').references('users.user_id');
|
||||||
|
});
|
||||||
|
console.log('✅ tbm_safety_records 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 5. 작업 인계 테이블 (반차/조퇴 시)
|
||||||
|
await knex.schema.createTable('team_handovers', (table) => {
|
||||||
|
table.increments('handover_id').primary();
|
||||||
|
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
|
||||||
|
table.integer('from_leader_id').notNullable().comment('인계자 worker_id');
|
||||||
|
table.integer('to_leader_id').notNullable().comment('인수자 worker_id');
|
||||||
|
table.date('handover_date').notNullable().comment('인계 날짜');
|
||||||
|
table.time('handover_time').nullable().comment('인계 시간');
|
||||||
|
table.enum('reason', ['half_day', 'early_leave', 'emergency', 'other']).notNullable().comment('인계 사유');
|
||||||
|
table.text('handover_notes').nullable().comment('인계 내용');
|
||||||
|
table.text('worker_ids').nullable().comment('인계하는 작업자 IDs (JSON array)');
|
||||||
|
table.boolean('is_confirmed').defaultTo(false).comment('인수 확인 여부');
|
||||||
|
table.timestamp('confirmed_at').nullable().comment('인수 확인 시간');
|
||||||
|
table.integer('confirmed_by').nullable().comment('인수 확인자 user_id');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스 및 제약조건
|
||||||
|
table.index(['session_id', 'handover_date']);
|
||||||
|
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
|
||||||
|
table.foreign('from_leader_id').references('workers.worker_id');
|
||||||
|
table.foreign('to_leader_id').references('workers.worker_id');
|
||||||
|
table.foreign('confirmed_by').references('users.user_id');
|
||||||
|
});
|
||||||
|
console.log('✅ team_handovers 테이블 생성 완료');
|
||||||
|
|
||||||
|
console.log('✅ 모든 TBM 시스템 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ TBM 시스템 테이블 제거 중...');
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists('team_handovers');
|
||||||
|
await knex.schema.dropTableIfExists('tbm_safety_records');
|
||||||
|
await knex.schema.dropTableIfExists('tbm_safety_checks');
|
||||||
|
await knex.schema.dropTableIfExists('tbm_team_assignments');
|
||||||
|
await knex.schema.dropTableIfExists('tbm_sessions');
|
||||||
|
|
||||||
|
console.log('✅ 모든 TBM 시스템 테이블 제거 완료');
|
||||||
|
};
|
||||||
33
api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
Normal file
33
api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: TBM 페이지 등록
|
||||||
|
* 작성일: 2026-01-20
|
||||||
|
*
|
||||||
|
* pages 테이블에 TBM 페이지 추가
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ TBM 페이지 등록 중...');
|
||||||
|
|
||||||
|
// TBM 페이지 추가
|
||||||
|
await knex('pages').insert([
|
||||||
|
{
|
||||||
|
page_key: 'tbm',
|
||||||
|
page_name: 'TBM 관리',
|
||||||
|
page_path: '/pages/work/tbm.html',
|
||||||
|
category: 'work',
|
||||||
|
description: 'Tool Box Meeting - 아침 안전 회의 및 팀 구성 관리',
|
||||||
|
is_admin_only: false,
|
||||||
|
display_order: 10
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ TBM 페이지 등록 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ TBM 페이지 제거 중...');
|
||||||
|
|
||||||
|
await knex('pages').where('page_key', 'tbm').del();
|
||||||
|
|
||||||
|
console.log('✅ TBM 페이지 제거 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 작업장 카테고리(공장) 테이블 생성 마이그레이션
|
||||||
|
* 대분류: 제 1공장, 제 2공장 등
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.createTable('workplace_categories', function(table) {
|
||||||
|
table.increments('category_id').primary().comment('카테고리 ID');
|
||||||
|
table.string('category_name', 100).notNullable().comment('카테고리명 (예: 제 1공장)');
|
||||||
|
table.text('description').nullable().comment('설명');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('is_active');
|
||||||
|
table.index('display_order');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTableIfExists('workplace_categories');
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 작업장(작업 구역) 테이블 생성 마이그레이션
|
||||||
|
* 소분류: 서스작업장, 조립구역 등
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.createTable('workplaces', function(table) {
|
||||||
|
table.increments('workplace_id').primary().comment('작업장 ID');
|
||||||
|
table.integer('category_id').unsigned().nullable().comment('카테고리 ID (공장)');
|
||||||
|
table.string('workplace_name', 255).notNullable().comment('작업장명');
|
||||||
|
table.text('description').nullable().comment('설명');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('workplace_categories')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('category_id');
|
||||||
|
table.index('is_active');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTableIfExists('workplaces');
|
||||||
|
};
|
||||||
36
api.hyungi.net/db/migrations/20260126010002_create_tasks.js
Normal file
36
api.hyungi.net/db/migrations/20260126010002_create_tasks.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 작업 테이블 생성 (공정=work_types에 속함)
|
||||||
|
*
|
||||||
|
* @param {import('knex').Knex} knex
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.createTable('tasks', function(table) {
|
||||||
|
table.increments('task_id').primary().comment('작업 ID');
|
||||||
|
table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)');
|
||||||
|
table.string('task_name', 255).notNullable().comment('작업명');
|
||||||
|
table.text('description').nullable().comment('작업 설명');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
|
||||||
|
|
||||||
|
// 외래키 (work_types 테이블 참조)
|
||||||
|
table.foreign('work_type_id')
|
||||||
|
.references('id')
|
||||||
|
.inTable('work_types')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('work_type_id');
|
||||||
|
table.index('is_active');
|
||||||
|
}).then(() => {
|
||||||
|
console.log('✅ tasks 테이블 생성 완료');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('knex').Knex} knex
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTableIfExists('tasks');
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* TBM 세션에 공정/작업 컬럼 추가
|
||||||
|
*
|
||||||
|
* @param {import('knex').Knex} knex
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.table('tbm_sessions', function(table) {
|
||||||
|
table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)');
|
||||||
|
table.integer('task_id').unsigned().nullable().comment('작업 ID (tasks 참조)');
|
||||||
|
|
||||||
|
// 외래키 추가
|
||||||
|
table.foreign('work_type_id')
|
||||||
|
.references('id')
|
||||||
|
.inTable('work_types')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('task_id')
|
||||||
|
.references('task_id')
|
||||||
|
.inTable('tasks')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스 추가
|
||||||
|
table.index('work_type_id');
|
||||||
|
table.index('task_id');
|
||||||
|
}).then(() => {
|
||||||
|
console.log('✅ tbm_sessions 테이블에 work_type_id, task_id 컬럼 추가 완료');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('knex').Knex} knex
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.table('tbm_sessions', function(table) {
|
||||||
|
table.dropForeign('work_type_id');
|
||||||
|
table.dropForeign('task_id');
|
||||||
|
table.dropColumn('work_type_id');
|
||||||
|
table.dropColumn('task_id');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: tbm_team_assignments 테이블 확장
|
||||||
|
* 작업자별 프로젝트/공정/작업/작업장 정보 저장 가능하도록 컬럼 추가 및 외래키 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. workplace_category_id와 workplace_id를 UNSIGNED로 변경
|
||||||
|
await knex.raw(`
|
||||||
|
ALTER TABLE tbm_team_assignments
|
||||||
|
MODIFY COLUMN workplace_category_id INT UNSIGNED NULL COMMENT '작업자별 작업장 대분류 (공장)',
|
||||||
|
MODIFY COLUMN workplace_id INT UNSIGNED NULL COMMENT '작업자별 작업장 ID'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 외래키 제약조건 추가
|
||||||
|
return knex.schema.alterTable('tbm_team_assignments', function(table) {
|
||||||
|
// 외래키 제약조건 추가
|
||||||
|
table.foreign('workplace_category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('workplace_categories')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('workplace_id')
|
||||||
|
.references('workplace_id')
|
||||||
|
.inTable('workplaces')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.alterTable('tbm_team_assignments', function(table) {
|
||||||
|
// 외래키 제약조건 제거
|
||||||
|
table.dropForeign('workplace_category_id');
|
||||||
|
table.dropForeign('workplace_id');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: tbm_sessions 테이블에서 불필요한 컬럼 제거
|
||||||
|
* work_description, safety_notes, start_time 컬럼 제거
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.alterTable('tbm_sessions', function(table) {
|
||||||
|
table.dropColumn('work_description');
|
||||||
|
table.dropColumn('safety_notes');
|
||||||
|
table.dropColumn('start_time');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.alterTable('tbm_sessions', function(table) {
|
||||||
|
table.text('work_description').nullable().comment('작업 내용');
|
||||||
|
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
|
||||||
|
table.time('start_time').nullable().comment('시작 시간');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 작업장 지도 이미지 기능 추가
|
||||||
|
* - workplace_categories에 layout_image 필드 추가
|
||||||
|
* - workplace_map_regions 테이블 생성 (클릭 가능한 영역 정의)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. workplace_categories 테이블에 layout_image 필드 추가
|
||||||
|
await knex.schema.alterTable('workplace_categories', function(table) {
|
||||||
|
table.string('layout_image', 500).nullable().comment('공장 배치도 이미지 경로');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 작업장 지도 클릭 영역 정의 테이블 생성
|
||||||
|
await knex.schema.createTable('workplace_map_regions', function(table) {
|
||||||
|
table.increments('region_id').primary().comment('영역 ID');
|
||||||
|
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
|
||||||
|
table.integer('category_id').unsigned().notNullable().comment('공장 카테고리 ID');
|
||||||
|
|
||||||
|
// 좌표 정보 (비율 기반: 0~100%)
|
||||||
|
table.decimal('x_start', 5, 2).notNullable().comment('시작 X 좌표 (%)');
|
||||||
|
table.decimal('y_start', 5, 2).notNullable().comment('시작 Y 좌표 (%)');
|
||||||
|
table.decimal('x_end', 5, 2).notNullable().comment('끝 X 좌표 (%)');
|
||||||
|
table.decimal('y_end', 5, 2).notNullable().comment('끝 Y 좌표 (%)');
|
||||||
|
|
||||||
|
table.string('shape', 20).defaultTo('rect').comment('영역 모양 (rect, circle, polygon)');
|
||||||
|
table.text('polygon_points').nullable().comment('다각형인 경우 좌표 배열 (JSON)');
|
||||||
|
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('workplace_id')
|
||||||
|
.references('workplace_id')
|
||||||
|
.inTable('workplaces')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('workplace_categories')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 테이블 삭제
|
||||||
|
await knex.schema.dropTableIfExists('workplace_map_regions');
|
||||||
|
|
||||||
|
// 필드 제거
|
||||||
|
await knex.schema.alterTable('workplace_categories', function(table) {
|
||||||
|
table.dropColumn('layout_image');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 작업장 용도 및 표시 순서 필드 추가
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.alterTable('workplaces', function(table) {
|
||||||
|
table.string('workplace_purpose', 50).nullable().comment('작업장 용도 (작업구역, 설비, 휴게시설, 회의실 등)');
|
||||||
|
table.integer('display_priority').defaultTo(0).comment('표시 우선순위 (숫자가 작을수록 먼저 표시)');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.alterTable('workplaces', function(table) {
|
||||||
|
table.dropColumn('workplace_purpose');
|
||||||
|
table.dropColumn('display_priority');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* leader_id를 nullable로 변경
|
||||||
|
* 관리자가 TBM을 입력할 때 leader_id를 NULL로 설정하고 created_by를 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 외래 키 제약조건 삭제 (존재하는 경우에만)
|
||||||
|
try {
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('외래 키가 이미 존재하지 않음 (정상)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. leader_id를 nullable로 변경 (UNSIGNED 제거하여 workers.worker_id와 타입 일치)
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NULL');
|
||||||
|
|
||||||
|
// 3. 외래 키 제약조건 다시 추가 (nullable 허용)
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE SET NULL');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 1. 외래 키 제약조건 삭제
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
|
||||||
|
|
||||||
|
// 2. leader_id를 NOT NULL로 되돌림
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NOT NULL');
|
||||||
|
|
||||||
|
// 3. 외래 키 제약조건 다시 추가
|
||||||
|
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE CASCADE');
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* daily_work_reports 테이블에 TBM 연동 필드 추가
|
||||||
|
* - TBM 세션 및 팀 배정과 연결
|
||||||
|
* - 작업 시간 및 오류 시간 추적
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await knex.schema.table('daily_work_reports', (table) => {
|
||||||
|
// TBM 연동 필드
|
||||||
|
table.integer('tbm_session_id').unsigned().nullable()
|
||||||
|
.comment('연결된 TBM 세션 ID');
|
||||||
|
table.integer('tbm_assignment_id').unsigned().nullable()
|
||||||
|
.comment('연결된 TBM 팀 배정 ID');
|
||||||
|
|
||||||
|
// 작업 시간 추적
|
||||||
|
table.time('start_time').nullable()
|
||||||
|
.comment('작업 시작 시간');
|
||||||
|
table.time('end_time').nullable()
|
||||||
|
.comment('작업 종료 시간');
|
||||||
|
table.decimal('total_hours', 5, 2).nullable()
|
||||||
|
.comment('총 작업 시간');
|
||||||
|
table.decimal('regular_hours', 5, 2).nullable()
|
||||||
|
.comment('정규 작업 시간 (총 시간 - 오류 시간)');
|
||||||
|
table.decimal('error_hours', 5, 2).nullable()
|
||||||
|
.comment('부적합 사항 처리 시간');
|
||||||
|
|
||||||
|
// 외래 키 제약조건
|
||||||
|
table.foreign('tbm_session_id')
|
||||||
|
.references('session_id')
|
||||||
|
.inTable('tbm_sessions')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
|
||||||
|
table.foreign('tbm_assignment_id')
|
||||||
|
.references('assignment_id')
|
||||||
|
.inTable('tbm_team_assignments')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.table('daily_work_reports', (table) => {
|
||||||
|
// 외래 키 제약조건 삭제
|
||||||
|
table.dropForeign('tbm_session_id');
|
||||||
|
table.dropForeign('tbm_assignment_id');
|
||||||
|
|
||||||
|
// 컬럼 삭제
|
||||||
|
table.dropColumn('tbm_session_id');
|
||||||
|
table.dropColumn('tbm_assignment_id');
|
||||||
|
table.dropColumn('start_time');
|
||||||
|
table.dropColumn('end_time');
|
||||||
|
table.dropColumn('total_hours');
|
||||||
|
table.dropColumn('regular_hours');
|
||||||
|
table.dropColumn('error_hours');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 현재 사용 중인 페이지를 pages 테이블에 업데이트
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 기존 페이지 모두 삭제
|
||||||
|
await knex('pages').del();
|
||||||
|
|
||||||
|
// 현재 사용 중인 페이지들을 등록
|
||||||
|
await knex('pages').insert([
|
||||||
|
// 공통 페이지
|
||||||
|
{
|
||||||
|
page_key: 'dashboard',
|
||||||
|
page_name: '대시보드',
|
||||||
|
page_path: '/pages/dashboard.html',
|
||||||
|
category: 'common',
|
||||||
|
description: '전체 현황 대시보드',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업 관련 페이지
|
||||||
|
{
|
||||||
|
page_key: 'work.tbm',
|
||||||
|
page_name: 'TBM',
|
||||||
|
page_path: '/pages/work/tbm.html',
|
||||||
|
category: 'work',
|
||||||
|
description: 'TBM (Tool Box Meeting) 관리',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'work.report_create',
|
||||||
|
page_name: '작업보고서 작성',
|
||||||
|
page_path: '/pages/work/report-create.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '일일 작업보고서 작성',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'work.report_view',
|
||||||
|
page_name: '작업보고서 조회',
|
||||||
|
page_path: '/pages/work/report-view.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '작업보고서 조회 및 검색',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'work.analysis',
|
||||||
|
page_name: '작업 분석',
|
||||||
|
page_path: '/pages/work/analysis.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '작업 통계 및 분석',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 13
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin 페이지
|
||||||
|
{
|
||||||
|
page_key: 'admin.accounts',
|
||||||
|
page_name: '계정 관리',
|
||||||
|
page_path: '/pages/admin/accounts.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '사용자 계정 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.page_access',
|
||||||
|
page_name: '페이지 권한 관리',
|
||||||
|
page_path: '/pages/admin/page-access.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '사용자별 페이지 접근 권한 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.workers',
|
||||||
|
page_name: '작업자 관리',
|
||||||
|
page_path: '/pages/admin/workers.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '작업자 정보 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.projects',
|
||||||
|
page_name: '프로젝트 관리',
|
||||||
|
page_path: '/pages/admin/projects.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '프로젝트 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.workplaces',
|
||||||
|
page_name: '작업장 관리',
|
||||||
|
page_path: '/pages/admin/workplaces.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '작업장소 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.codes',
|
||||||
|
page_name: '코드 관리',
|
||||||
|
page_path: '/pages/admin/codes.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '시스템 코드 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'admin.tasks',
|
||||||
|
page_name: '작업 관리',
|
||||||
|
page_path: '/pages/admin/tasks.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '작업 유형 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 26
|
||||||
|
},
|
||||||
|
|
||||||
|
// 프로필 페이지
|
||||||
|
{
|
||||||
|
page_key: 'profile.info',
|
||||||
|
page_name: '내 정보',
|
||||||
|
page_path: '/pages/profile/info.html',
|
||||||
|
category: 'profile',
|
||||||
|
description: '내 프로필 정보',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'profile.password',
|
||||||
|
page_name: '비밀번호 변경',
|
||||||
|
page_path: '/pages/profile/password.html',
|
||||||
|
category: 'profile',
|
||||||
|
description: '비밀번호 변경',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 31
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ 현재 사용 중인 페이지 목록 업데이트 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('pages').del();
|
||||||
|
console.log('✅ 페이지 목록 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 작업장 테이블에 레이아웃 이미지 컬럼 추가
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-01-28
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await knex.schema.table('workplaces', (table) => {
|
||||||
|
table.string('layout_image', 500).nullable().comment('작업장 레이아웃 이미지 경로');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ workplaces 테이블에 layout_image 컬럼 추가 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.table('workplaces', (table) => {
|
||||||
|
table.dropColumn('layout_image');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ workplaces 테이블에서 layout_image 컬럼 제거 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 설비 관리 테이블 생성
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-01-28
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await knex.schema.createTable('equipments', (table) => {
|
||||||
|
table.increments('equipment_id').primary().comment('설비 ID');
|
||||||
|
table.string('equipment_code', 50).notNullable().unique().comment('설비 코드 (예: CNC-01, LATHE-A)');
|
||||||
|
table.string('equipment_name', 100).notNullable().comment('설비명');
|
||||||
|
table.string('equipment_type', 50).nullable().comment('설비 유형 (예: CNC, 선반, 밀링 등)');
|
||||||
|
table.string('model_name', 100).nullable().comment('모델명');
|
||||||
|
table.string('manufacturer', 100).nullable().comment('제조사');
|
||||||
|
table.date('installation_date').nullable().comment('설치일');
|
||||||
|
table.string('serial_number', 100).nullable().comment('시리얼 번호');
|
||||||
|
table.text('specifications').nullable().comment('사양 정보 (JSON 형태로 저장 가능)');
|
||||||
|
table.enum('status', ['active', 'maintenance', 'inactive']).defaultTo('active').comment('설비 상태');
|
||||||
|
table.text('notes').nullable().comment('비고');
|
||||||
|
|
||||||
|
// 작업장 연결
|
||||||
|
table.integer('workplace_id').unsigned().nullable().comment('연결된 작업장 ID');
|
||||||
|
table.foreign('workplace_id').references('workplace_id').inTable('workplaces').onDelete('SET NULL');
|
||||||
|
|
||||||
|
// 지도상 위치 정보 (백분율 기반)
|
||||||
|
table.decimal('map_x_percent', 5, 2).nullable().comment('지도상 X 위치 (%)');
|
||||||
|
table.decimal('map_y_percent', 5, 2).nullable().comment('지도상 Y 위치 (%)');
|
||||||
|
table.decimal('map_width_percent', 5, 2).nullable().comment('지도상 영역 너비 (%)');
|
||||||
|
table.decimal('map_height_percent', 5, 2).nullable().comment('지도상 영역 높이 (%)');
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('workplace_id');
|
||||||
|
table.index('equipment_type');
|
||||||
|
table.index('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ equipments 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.dropTableIfExists('equipments');
|
||||||
|
console.log('✅ equipments 테이블 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Create vacation_requests table
|
||||||
|
* Purpose: Track vacation request workflow (request, approval/rejection)
|
||||||
|
* Date: 2026-01-29
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// Create vacation_requests table
|
||||||
|
await knex.schema.createTable('vacation_requests', (table) => {
|
||||||
|
table.increments('request_id').primary().comment('휴가 신청 ID');
|
||||||
|
|
||||||
|
// 작업자 정보
|
||||||
|
table.integer('worker_id').notNullable().comment('작업자 ID');
|
||||||
|
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
|
||||||
|
|
||||||
|
// 휴가 정보
|
||||||
|
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
|
||||||
|
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
|
||||||
|
|
||||||
|
table.date('start_date').notNullable().comment('휴가 시작일');
|
||||||
|
table.date('end_date').notNullable().comment('휴가 종료일');
|
||||||
|
table.decimal('days_used', 4, 1).notNullable().comment('사용 일수 (0.5일 단위)');
|
||||||
|
|
||||||
|
table.text('reason').nullable().comment('휴가 사유');
|
||||||
|
|
||||||
|
// 신청 및 승인 정보
|
||||||
|
table.enum('status', ['pending', 'approved', 'rejected'])
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo('pending')
|
||||||
|
.comment('승인 상태: pending(대기), approved(승인), rejected(거부)');
|
||||||
|
|
||||||
|
table.integer('requested_by').notNullable().comment('신청자 user_id');
|
||||||
|
table.foreign('requested_by').references('user_id').inTable('users').onDelete('RESTRICT');
|
||||||
|
|
||||||
|
table.integer('reviewed_by').nullable().comment('승인/거부자 user_id');
|
||||||
|
table.foreign('reviewed_by').references('user_id').inTable('users').onDelete('SET NULL');
|
||||||
|
|
||||||
|
table.timestamp('reviewed_at').nullable().comment('승인/거부 일시');
|
||||||
|
table.text('review_note').nullable().comment('승인/거부 메모');
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('신청 일시');
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정 일시');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('worker_id', 'idx_vacation_requests_worker');
|
||||||
|
table.index('status', 'idx_vacation_requests_status');
|
||||||
|
table.index(['start_date', 'end_date'], 'idx_vacation_requests_dates');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ vacation_requests 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.dropTableIfExists('vacation_requests');
|
||||||
|
console.log('✅ vacation_requests 테이블 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Register attendance management pages
|
||||||
|
* Purpose: Add 4 new pages to pages table for attendance management system
|
||||||
|
* Date: 2026-01-29
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 페이지 등록 (실제 pages 테이블 컬럼에 맞춤)
|
||||||
|
await knex('pages').insert([
|
||||||
|
{
|
||||||
|
page_key: 'daily-attendance',
|
||||||
|
page_name: '일일 출퇴근 입력',
|
||||||
|
page_path: '/pages/common/daily-attendance.html',
|
||||||
|
description: '일일 출퇴근 기록 입력 페이지 (관리자/조장)',
|
||||||
|
category: 'common',
|
||||||
|
is_admin_only: false,
|
||||||
|
display_order: 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'monthly-attendance',
|
||||||
|
page_name: '월별 출퇴근 현황',
|
||||||
|
page_path: '/pages/common/monthly-attendance.html',
|
||||||
|
description: '월별 출퇴근 현황 조회 페이지',
|
||||||
|
category: 'common',
|
||||||
|
is_admin_only: false,
|
||||||
|
display_order: 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'vacation-management',
|
||||||
|
page_name: '휴가 관리',
|
||||||
|
page_path: '/pages/common/vacation-management.html',
|
||||||
|
description: '휴가 신청 및 승인 관리 페이지',
|
||||||
|
category: 'common',
|
||||||
|
is_admin_only: false,
|
||||||
|
display_order: 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'attendance-report-comparison',
|
||||||
|
page_name: '출퇴근-작업보고서 대조',
|
||||||
|
page_path: '/pages/admin/attendance-report-comparison.html',
|
||||||
|
description: '출퇴근 기록과 작업보고서 대조 페이지 (관리자)',
|
||||||
|
category: 'admin',
|
||||||
|
is_admin_only: true,
|
||||||
|
display_order: 120
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ 출퇴근 관리 페이지 4개 등록 완료');
|
||||||
|
|
||||||
|
// Admin 사용자(user_id=1)에게 페이지 접근 권한 부여
|
||||||
|
const adminUserId = 1;
|
||||||
|
const pages = await knex('pages')
|
||||||
|
.whereIn('page_key', [
|
||||||
|
'daily-attendance',
|
||||||
|
'monthly-attendance',
|
||||||
|
'vacation-management',
|
||||||
|
'attendance-report-comparison'
|
||||||
|
])
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
const accessRecords = pages.map(page => ({
|
||||||
|
user_id: adminUserId,
|
||||||
|
page_id: page.id,
|
||||||
|
can_access: true,
|
||||||
|
granted_by: adminUserId
|
||||||
|
}));
|
||||||
|
|
||||||
|
await knex('user_page_access').insert(accessRecords);
|
||||||
|
console.log('✅ Admin 사용자에게 출퇴근 관리 페이지 접근 권한 부여 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 페이지 삭제 (user_page_access는 FK CASCADE로 자동 삭제됨)
|
||||||
|
await knex('pages')
|
||||||
|
.whereIn('page_key', [
|
||||||
|
'daily-attendance',
|
||||||
|
'monthly-attendance',
|
||||||
|
'vacation-management',
|
||||||
|
'attendance-report-comparison'
|
||||||
|
])
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
console.log('✅ 출퇴근 관리 페이지 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 출퇴근 출근 여부 필드 추가
|
||||||
|
* 아침 출근 확인용 간단한 필드
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 컬럼 존재 여부 확인
|
||||||
|
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
|
||||||
|
|
||||||
|
if (!hasColumn) {
|
||||||
|
await knex.schema.table('daily_attendance_records', (table) => {
|
||||||
|
// 출근 여부 (아침에 체크)
|
||||||
|
table.boolean('is_present').defaultTo(true).comment('출근 여부');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 데이터는 모두 출근으로 처리
|
||||||
|
await knex('daily_attendance_records')
|
||||||
|
.whereNotNull('id')
|
||||||
|
.update({ is_present: true });
|
||||||
|
|
||||||
|
console.log('✅ is_present 컬럼 추가 완료');
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ is_present 컬럼이 이미 존재합니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
|
||||||
|
|
||||||
|
if (hasColumn) {
|
||||||
|
await knex.schema.table('daily_attendance_records', (table) => {
|
||||||
|
table.dropColumn('is_present');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 휴가 관리 페이지 분리 및 등록
|
||||||
|
* - 기존 vacation-management.html을 2개 페이지로 분리
|
||||||
|
* - vacation-request.html: 작업자 휴가 신청 및 본인 내역 확인
|
||||||
|
* - vacation-management.html: 관리자 휴가 승인/직접입력/전체내역 (3개 탭)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 기존 vacation-management 페이지 삭제
|
||||||
|
await knex('pages')
|
||||||
|
.where('page_key', 'vacation-management')
|
||||||
|
.del();
|
||||||
|
|
||||||
|
// 새로운 휴가 관리 페이지 2개 등록
|
||||||
|
await knex('pages').insert([
|
||||||
|
{
|
||||||
|
page_key: 'vacation-request',
|
||||||
|
page_name: '휴가 신청',
|
||||||
|
page_path: '/pages/common/vacation-request.html',
|
||||||
|
category: 'common',
|
||||||
|
description: '작업자가 휴가를 신청하고 본인의 신청 내역을 확인하는 페이지',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'vacation-management',
|
||||||
|
page_name: '휴가 관리',
|
||||||
|
page_path: '/pages/common/vacation-management.html',
|
||||||
|
category: 'common',
|
||||||
|
description: '관리자가 휴가 승인, 직접 입력, 전체 내역을 관리하는 페이지',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 52
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ 휴가 관리 페이지 분리 완료 (기존 1개 → 신규 2개)');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 새로운 페이지 삭제
|
||||||
|
await knex('pages')
|
||||||
|
.whereIn('page_key', ['vacation-request', 'vacation-management'])
|
||||||
|
.del();
|
||||||
|
|
||||||
|
// 기존 vacation-management 페이지 복원
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'vacation-management',
|
||||||
|
page_name: '휴가 관리',
|
||||||
|
page_path: '/pages/common/vacation-management.html.old',
|
||||||
|
category: 'common',
|
||||||
|
description: '휴가 신청 및 승인 관리',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 휴가 관리 페이지 롤백 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* vacation_types 테이블 확장
|
||||||
|
* - 특별 휴가 유형 추가 기능
|
||||||
|
* - 차감 우선순위 관리
|
||||||
|
* - 시스템 기본 휴가 보호
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// vacation_types 테이블 확장
|
||||||
|
await knex.schema.table('vacation_types', (table) => {
|
||||||
|
table.boolean('is_special').defaultTo(false).comment('특별 휴가 여부 (장기근속, 출산 등)');
|
||||||
|
table.integer('priority').defaultTo(99).comment('차감 우선순위 (낮을수록 먼저 차감)');
|
||||||
|
table.text('description').nullable().comment('휴가 설명');
|
||||||
|
table.boolean('is_system').defaultTo(true).comment('시스템 기본 휴가 (삭제 불가)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 휴가 유형에 우선순위 설정
|
||||||
|
await knex('vacation_types').where('type_code', 'ANNUAL').update({
|
||||||
|
priority: 10,
|
||||||
|
is_system: true,
|
||||||
|
description: '근로기준법에 따른 연차 유급휴가'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('vacation_types').where('type_code', 'HALF_ANNUAL').update({
|
||||||
|
priority: 10,
|
||||||
|
is_system: true,
|
||||||
|
description: '반일 연차 (0.5일)'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('vacation_types').where('type_code', 'SICK').update({
|
||||||
|
priority: 20,
|
||||||
|
is_system: true,
|
||||||
|
description: '병가'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('vacation_types').where('type_code', 'SPECIAL').update({
|
||||||
|
priority: 0,
|
||||||
|
is_system: true,
|
||||||
|
description: '경조사 휴가 (무급)'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ vacation_types 테이블 확장 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 컬럼 삭제
|
||||||
|
await knex.schema.table('vacation_types', (table) => {
|
||||||
|
table.dropColumn('is_special');
|
||||||
|
table.dropColumn('priority');
|
||||||
|
table.dropColumn('description');
|
||||||
|
table.dropColumn('is_system');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ vacation_types 테이블 롤백 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* vacation_balance_details 테이블 생성 및 데이터 마이그레이션
|
||||||
|
* - 작업자별, 휴가 유형별, 연도별 휴가 잔액 관리
|
||||||
|
* - 기존 worker_vacation_balance 데이터 이관
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// vacation_balance_details 테이블 생성
|
||||||
|
await knex.schema.createTable('vacation_balance_details', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('worker_id').notNullable().comment('작업자 ID');
|
||||||
|
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
|
||||||
|
table.integer('year').notNullable().comment('연도');
|
||||||
|
table.decimal('total_days', 4, 1).defaultTo(0).comment('총 발생 일수');
|
||||||
|
table.decimal('used_days', 4, 1).defaultTo(0).comment('사용 일수');
|
||||||
|
table.text('notes').nullable().comment('비고');
|
||||||
|
table.integer('created_by').notNullable().comment('생성자 ID');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.unique(['worker_id', 'vacation_type_id', 'year'], 'unique_worker_vacation_year');
|
||||||
|
table.index(['worker_id', 'year'], 'idx_worker_year');
|
||||||
|
table.index('vacation_type_id', 'idx_vacation_type');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
|
||||||
|
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
|
||||||
|
table.foreign('created_by').references('user_id').inTable('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
// remaining_days를 generated column으로 추가 (Raw SQL)
|
||||||
|
await knex.raw(`
|
||||||
|
ALTER TABLE vacation_balance_details
|
||||||
|
ADD COLUMN remaining_days DECIMAL(4,1)
|
||||||
|
GENERATED ALWAYS AS (total_days - used_days) STORED
|
||||||
|
COMMENT '잔여 일수'
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ vacation_balance_details 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 기존 worker_vacation_balance 데이터를 vacation_balance_details로 마이그레이션
|
||||||
|
const existingBalances = await knex('worker_vacation_balance').select('*');
|
||||||
|
|
||||||
|
if (existingBalances.length > 0) {
|
||||||
|
// ANNUAL 휴가 유형 ID 조회
|
||||||
|
const annualType = await knex('vacation_types')
|
||||||
|
.where('type_code', 'ANNUAL')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!annualType) {
|
||||||
|
throw new Error('ANNUAL 휴가 유형을 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 사용자 ID 조회 (created_by 용)
|
||||||
|
// role_id 1 = System Admin, 2 = Admin
|
||||||
|
const adminUser = await knex('users')
|
||||||
|
.whereIn('role_id', [1, 2])
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const createdById = adminUser ? adminUser.user_id : 1;
|
||||||
|
|
||||||
|
// 데이터 변환 및 삽입
|
||||||
|
const balanceDetails = existingBalances.map(balance => ({
|
||||||
|
worker_id: balance.worker_id,
|
||||||
|
vacation_type_id: annualType.id,
|
||||||
|
year: balance.year,
|
||||||
|
total_days: balance.total_annual_leave || 0,
|
||||||
|
used_days: balance.used_annual_leave || 0,
|
||||||
|
notes: 'Migrated from worker_vacation_balance',
|
||||||
|
created_by: createdById,
|
||||||
|
created_at: balance.created_at,
|
||||||
|
updated_at: balance.updated_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
await knex('vacation_balance_details').insert(balanceDetails);
|
||||||
|
|
||||||
|
console.log(`✅ ${balanceDetails.length}건의 기존 휴가 데이터 마이그레이션 완료`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// vacation_balance_details 테이블 삭제
|
||||||
|
await knex.schema.dropTableIfExists('vacation_balance_details');
|
||||||
|
|
||||||
|
console.log('✅ vacation_balance_details 테이블 롤백 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 새로운 휴가 관리 페이지 등록
|
||||||
|
* - annual-vacation-overview: 연간 연차 현황 (차트)
|
||||||
|
* - vacation-allocation: 휴가 발생 입력 및 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await knex('pages').insert([
|
||||||
|
{
|
||||||
|
page_key: 'annual-vacation-overview',
|
||||||
|
page_name: '연간 연차 현황',
|
||||||
|
page_path: '/pages/common/annual-vacation-overview.html',
|
||||||
|
category: 'common',
|
||||||
|
description: '모든 작업자의 연간 연차 현황을 차트로 시각화',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'vacation-allocation',
|
||||||
|
page_name: '휴가 발생 입력',
|
||||||
|
page_path: '/pages/common/vacation-allocation.html',
|
||||||
|
category: 'common',
|
||||||
|
description: '작업자별 휴가 발생 입력 및 특별 휴가 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 55
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ 휴가 관리 신규 페이지 2개 등록 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('pages')
|
||||||
|
.whereIn('page_key', ['annual-vacation-overview', 'vacation-allocation'])
|
||||||
|
.del();
|
||||||
|
|
||||||
|
console.log('✅ 휴가 관리 페이지 롤백 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 출입 신청 및 안전교육 시스템
|
||||||
|
* - 방문 목적 타입 테이블
|
||||||
|
* - 출입 신청 테이블
|
||||||
|
* - 안전교육 기록 테이블
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 방문 목적 타입 테이블 생성
|
||||||
|
await knex.schema.createTable('visit_purpose_types', function(table) {
|
||||||
|
table.increments('purpose_id').primary().comment('방문 목적 ID');
|
||||||
|
table.string('purpose_name', 100).notNullable().comment('방문 목적명');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기 데이터 삽입
|
||||||
|
await knex('visit_purpose_types').insert([
|
||||||
|
{ purpose_name: '외주작업', display_order: 1, is_active: true },
|
||||||
|
{ purpose_name: '검사', display_order: 2, is_active: true },
|
||||||
|
{ purpose_name: '견학', display_order: 3, is_active: true },
|
||||||
|
{ purpose_name: '기타', display_order: 99, is_active: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 출입 신청 테이블 생성
|
||||||
|
await knex.schema.createTable('workplace_visit_requests', function(table) {
|
||||||
|
table.increments('request_id').primary().comment('신청 ID');
|
||||||
|
|
||||||
|
// 신청자 정보
|
||||||
|
table.integer('requester_id').notNullable().comment('신청자 user_id');
|
||||||
|
|
||||||
|
// 방문자 정보
|
||||||
|
table.string('visitor_company', 200).notNullable().comment('방문자 소속 (회사명 또는 "일용직")');
|
||||||
|
table.integer('visitor_count').defaultTo(1).comment('방문 인원');
|
||||||
|
|
||||||
|
// 방문 장소
|
||||||
|
table.integer('category_id').unsigned().notNullable().comment('방문 구역 (공장)');
|
||||||
|
table.integer('workplace_id').unsigned().notNullable().comment('방문 작업장');
|
||||||
|
|
||||||
|
// 방문 일시
|
||||||
|
table.date('visit_date').notNullable().comment('방문 날짜');
|
||||||
|
table.time('visit_time').notNullable().comment('방문 시간');
|
||||||
|
|
||||||
|
// 방문 목적
|
||||||
|
table.integer('purpose_id').unsigned().notNullable().comment('방문 목적 ID');
|
||||||
|
table.text('notes').nullable().comment('비고');
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
table.enum('status', ['pending', 'approved', 'rejected', 'training_completed'])
|
||||||
|
.defaultTo('pending')
|
||||||
|
.comment('신청 상태');
|
||||||
|
|
||||||
|
// 승인 정보
|
||||||
|
table.integer('approved_by').nullable().comment('승인자 user_id');
|
||||||
|
table.timestamp('approved_at').nullable().comment('승인 시간');
|
||||||
|
table.text('rejection_reason').nullable().comment('반려 사유');
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('requester_id')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('workplace_categories')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('workplace_id')
|
||||||
|
.references('workplace_id')
|
||||||
|
.inTable('workplaces')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('purpose_id')
|
||||||
|
.references('purpose_id')
|
||||||
|
.inTable('visit_purpose_types')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('approved_by')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('visit_date', 'idx_visit_date');
|
||||||
|
table.index('status', 'idx_status');
|
||||||
|
table.index(['visit_date', 'status'], 'idx_visit_date_status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 안전교육 기록 테이블 생성
|
||||||
|
await knex.schema.createTable('safety_training_records', function(table) {
|
||||||
|
table.increments('training_id').primary().comment('교육 기록 ID');
|
||||||
|
|
||||||
|
table.integer('request_id').unsigned().notNullable().comment('출입 신청 ID');
|
||||||
|
|
||||||
|
// 교육 진행 정보
|
||||||
|
table.integer('trainer_id').notNullable().comment('교육 진행자 user_id');
|
||||||
|
table.date('training_date').notNullable().comment('교육 날짜');
|
||||||
|
table.time('training_start_time').notNullable().comment('교육 시작 시간');
|
||||||
|
table.time('training_end_time').nullable().comment('교육 종료 시간');
|
||||||
|
|
||||||
|
// 교육 내용
|
||||||
|
table.text('training_topics').nullable().comment('교육 내용 (JSON 배열)');
|
||||||
|
|
||||||
|
// 서명 데이터 (Base64 이미지)
|
||||||
|
table.text('signature_data', 'longtext').nullable().comment('교육 이수자 서명 (Base64 PNG)');
|
||||||
|
|
||||||
|
// 완료 정보
|
||||||
|
table.timestamp('completed_at').nullable().comment('교육 완료 시간');
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('request_id')
|
||||||
|
.references('request_id')
|
||||||
|
.inTable('workplace_visit_requests')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('trainer_id')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('training_date', 'idx_training_date');
|
||||||
|
table.index('request_id', 'idx_request_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 역순으로 테이블 삭제
|
||||||
|
await knex.schema.dropTableIfExists('safety_training_records');
|
||||||
|
await knex.schema.dropTableIfExists('workplace_visit_requests');
|
||||||
|
await knex.schema.dropTableIfExists('visit_purpose_types');
|
||||||
|
|
||||||
|
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 출입 신청 및 안전관리 페이지 등록
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 출입 신청 페이지 등록
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'visit-request',
|
||||||
|
page_name: '출입 신청',
|
||||||
|
page_path: '/pages/work/visit-request.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '작업장 출입 신청 및 안전교육 신청',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 15
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 안전관리 대시보드 페이지 등록
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'safety-management',
|
||||||
|
page_name: '안전관리',
|
||||||
|
page_path: '/pages/admin/safety-management.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '출입 신청 승인 및 안전교육 관리',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 60
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 안전교육 진행 페이지 등록
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'safety-training-conduct',
|
||||||
|
page_name: '안전교육 진행',
|
||||||
|
page_path: '/pages/admin/safety-training-conduct.html',
|
||||||
|
category: 'admin',
|
||||||
|
description: '안전교육 실시 및 서명 관리',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 61
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 출입 신청 및 안전관리 페이지 등록 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('pages').whereIn('page_key', [
|
||||||
|
'visit-request',
|
||||||
|
'safety-management',
|
||||||
|
'safety-training-conduct'
|
||||||
|
]).delete();
|
||||||
|
|
||||||
|
console.log('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 작업 중 문제 신고 시스템
|
||||||
|
* - 신고 카테고리 테이블 (부적합/안전)
|
||||||
|
* - 사전 정의 신고 항목 테이블
|
||||||
|
* - 문제 신고 메인 테이블
|
||||||
|
* - 상태 변경 이력 테이블
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 신고 카테고리 테이블 생성
|
||||||
|
await knex.schema.createTable('issue_report_categories', function(table) {
|
||||||
|
table.increments('category_id').primary().comment('카테고리 ID');
|
||||||
|
table.enum('category_type', ['nonconformity', 'safety']).notNullable().comment('카테고리 유형 (부적합/안전)');
|
||||||
|
table.string('category_name', 100).notNullable().comment('카테고리명');
|
||||||
|
table.text('description').nullable().comment('카테고리 설명');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index('category_type', 'idx_irc_category_type');
|
||||||
|
table.index('is_active', 'idx_irc_is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 초기 데이터 삽입
|
||||||
|
await knex('issue_report_categories').insert([
|
||||||
|
// 부적합 사항
|
||||||
|
{ category_type: 'nonconformity', category_name: '자재누락', display_order: 1, is_active: true },
|
||||||
|
{ category_type: 'nonconformity', category_name: '설계미스', display_order: 2, is_active: true },
|
||||||
|
{ category_type: 'nonconformity', category_name: '입고불량', display_order: 3, is_active: true },
|
||||||
|
{ category_type: 'nonconformity', category_name: '검사미스', display_order: 4, is_active: true },
|
||||||
|
{ category_type: 'nonconformity', category_name: '기타 부적합', display_order: 99, is_active: true },
|
||||||
|
// 안전 관련
|
||||||
|
{ category_type: 'safety', category_name: '보호구 미착용', display_order: 1, is_active: true },
|
||||||
|
{ category_type: 'safety', category_name: '위험구역 출입', display_order: 2, is_active: true },
|
||||||
|
{ category_type: 'safety', category_name: '안전시설 파손', display_order: 3, is_active: true },
|
||||||
|
{ category_type: 'safety', category_name: '안전수칙 위반', display_order: 4, is_active: true },
|
||||||
|
{ category_type: 'safety', category_name: '기타 안전', display_order: 99, is_active: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 사전 정의 신고 항목 테이블 생성
|
||||||
|
await knex.schema.createTable('issue_report_items', function(table) {
|
||||||
|
table.increments('item_id').primary().comment('항목 ID');
|
||||||
|
table.integer('category_id').unsigned().notNullable().comment('소속 카테고리 ID');
|
||||||
|
table.string('item_name', 200).notNullable().comment('신고 항목명');
|
||||||
|
table.text('description').nullable().comment('항목 설명');
|
||||||
|
table.enum('severity', ['low', 'medium', 'high', 'critical']).defaultTo('medium').comment('심각도');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.foreign('category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('issue_report_categories')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.index('category_id', 'idx_iri_category_id');
|
||||||
|
table.index('is_active', 'idx_iri_is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사전 정의 항목 초기 데이터 삽입
|
||||||
|
await knex('issue_report_items').insert([
|
||||||
|
// 자재누락 (category_id: 1)
|
||||||
|
{ category_id: 1, item_name: '배관 자재 미입고', severity: 'high', display_order: 1 },
|
||||||
|
{ category_id: 1, item_name: '피팅류 부족', severity: 'medium', display_order: 2 },
|
||||||
|
{ category_id: 1, item_name: '밸브류 미입고', severity: 'high', display_order: 3 },
|
||||||
|
{ category_id: 1, item_name: '가스켓/볼트류 부족', severity: 'low', display_order: 4 },
|
||||||
|
{ category_id: 1, item_name: '서포트 자재 부족', severity: 'medium', display_order: 5 },
|
||||||
|
|
||||||
|
// 설계미스 (category_id: 2)
|
||||||
|
{ category_id: 2, item_name: '도면 치수 오류', severity: 'high', display_order: 1 },
|
||||||
|
{ category_id: 2, item_name: '스펙 불일치', severity: 'high', display_order: 2 },
|
||||||
|
{ category_id: 2, item_name: '누락된 상세도', severity: 'medium', display_order: 3 },
|
||||||
|
{ category_id: 2, item_name: '간섭 발생', severity: 'critical', display_order: 4 },
|
||||||
|
|
||||||
|
// 입고불량 (category_id: 3)
|
||||||
|
{ category_id: 3, item_name: '외관 불량', severity: 'medium', display_order: 1 },
|
||||||
|
{ category_id: 3, item_name: '치수 불량', severity: 'high', display_order: 2 },
|
||||||
|
{ category_id: 3, item_name: '수량 부족', severity: 'medium', display_order: 3 },
|
||||||
|
{ category_id: 3, item_name: '재질 불일치', severity: 'critical', display_order: 4 },
|
||||||
|
|
||||||
|
// 검사미스 (category_id: 4)
|
||||||
|
{ category_id: 4, item_name: '치수 검사 누락', severity: 'high', display_order: 1 },
|
||||||
|
{ category_id: 4, item_name: '외관 검사 누락', severity: 'medium', display_order: 2 },
|
||||||
|
{ category_id: 4, item_name: '용접 검사 누락', severity: 'critical', display_order: 3 },
|
||||||
|
{ category_id: 4, item_name: '도장 검사 누락', severity: 'medium', display_order: 4 },
|
||||||
|
|
||||||
|
// 보호구 미착용 (category_id: 6)
|
||||||
|
{ category_id: 6, item_name: '안전모 미착용', severity: 'high', display_order: 1 },
|
||||||
|
{ category_id: 6, item_name: '안전화 미착용', severity: 'high', display_order: 2 },
|
||||||
|
{ category_id: 6, item_name: '보안경 미착용', severity: 'medium', display_order: 3 },
|
||||||
|
{ category_id: 6, item_name: '안전대 미착용', severity: 'critical', display_order: 4 },
|
||||||
|
{ category_id: 6, item_name: '귀마개 미착용', severity: 'low', display_order: 5 },
|
||||||
|
{ category_id: 6, item_name: '안전장갑 미착용', severity: 'medium', display_order: 6 },
|
||||||
|
|
||||||
|
// 위험구역 출입 (category_id: 7)
|
||||||
|
{ category_id: 7, item_name: '통제구역 무단 출입', severity: 'critical', display_order: 1 },
|
||||||
|
{ category_id: 7, item_name: '고소 작업 구역 무단 출입', severity: 'critical', display_order: 2 },
|
||||||
|
{ category_id: 7, item_name: '밀폐공간 무단 진입', severity: 'critical', display_order: 3 },
|
||||||
|
{ category_id: 7, item_name: '장비 가동 구역 무단 접근', severity: 'high', display_order: 4 },
|
||||||
|
|
||||||
|
// 안전시설 파손 (category_id: 8)
|
||||||
|
{ category_id: 8, item_name: '안전난간 파손', severity: 'high', display_order: 1 },
|
||||||
|
{ category_id: 8, item_name: '경고 표지판 훼손', severity: 'medium', display_order: 2 },
|
||||||
|
{ category_id: 8, item_name: '안전망 파손', severity: 'high', display_order: 3 },
|
||||||
|
{ category_id: 8, item_name: '비상조명 고장', severity: 'medium', display_order: 4 },
|
||||||
|
{ category_id: 8, item_name: '소화설비 파손', severity: 'critical', display_order: 5 },
|
||||||
|
|
||||||
|
// 안전수칙 위반 (category_id: 9)
|
||||||
|
{ category_id: 9, item_name: '지정 통로 미사용', severity: 'medium', display_order: 1 },
|
||||||
|
{ category_id: 9, item_name: '고소 작업 안전 미준수', severity: 'critical', display_order: 2 },
|
||||||
|
{ category_id: 9, item_name: '화기 작업 절차 미준수', severity: 'critical', display_order: 3 },
|
||||||
|
{ category_id: 9, item_name: '정리정돈 미흡', severity: 'low', display_order: 4 },
|
||||||
|
{ category_id: 9, item_name: '장비 조작 절차 미준수', severity: 'high', display_order: 5 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. 문제 신고 메인 테이블 생성
|
||||||
|
await knex.schema.createTable('work_issue_reports', function(table) {
|
||||||
|
table.increments('report_id').primary().comment('신고 ID');
|
||||||
|
|
||||||
|
// 신고자 정보
|
||||||
|
table.integer('reporter_id').notNullable().comment('신고자 user_id');
|
||||||
|
table.datetime('report_date').defaultTo(knex.fn.now()).comment('신고 일시');
|
||||||
|
|
||||||
|
// 위치 정보
|
||||||
|
table.integer('factory_category_id').unsigned().nullable().comment('공장 카테고리 ID (지도 외 위치 시 null)');
|
||||||
|
table.integer('workplace_id').unsigned().nullable().comment('작업장 ID (지도 외 위치 시 null)');
|
||||||
|
table.string('custom_location', 200).nullable().comment('기타 위치 (지도 외 선택 시)');
|
||||||
|
|
||||||
|
// 작업 연결 정보 (선택적)
|
||||||
|
table.integer('tbm_session_id').unsigned().nullable().comment('연결된 TBM 세션');
|
||||||
|
table.integer('visit_request_id').unsigned().nullable().comment('연결된 출입 신청');
|
||||||
|
|
||||||
|
// 신고 내용
|
||||||
|
table.integer('issue_category_id').unsigned().notNullable().comment('신고 카테고리 ID');
|
||||||
|
table.integer('issue_item_id').unsigned().nullable().comment('사전 정의 신고 항목 ID');
|
||||||
|
table.text('additional_description').nullable().comment('추가 설명');
|
||||||
|
|
||||||
|
// 사진 (최대 5장)
|
||||||
|
table.string('photo_path1', 255).nullable().comment('사진 1');
|
||||||
|
table.string('photo_path2', 255).nullable().comment('사진 2');
|
||||||
|
table.string('photo_path3', 255).nullable().comment('사진 3');
|
||||||
|
table.string('photo_path4', 255).nullable().comment('사진 4');
|
||||||
|
table.string('photo_path5', 255).nullable().comment('사진 5');
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
table.enum('status', ['reported', 'received', 'in_progress', 'completed', 'closed'])
|
||||||
|
.defaultTo('reported')
|
||||||
|
.comment('상태: 신고→접수→처리중→완료→종료');
|
||||||
|
|
||||||
|
// 담당자 배정
|
||||||
|
table.string('assigned_department', 100).nullable().comment('담당 부서');
|
||||||
|
table.integer('assigned_user_id').nullable().comment('담당자 user_id');
|
||||||
|
table.datetime('assigned_at').nullable().comment('배정 일시');
|
||||||
|
table.integer('assigned_by').nullable().comment('배정자 user_id');
|
||||||
|
|
||||||
|
// 처리 정보
|
||||||
|
table.text('resolution_notes').nullable().comment('처리 내용');
|
||||||
|
table.string('resolution_photo_path1', 255).nullable().comment('처리 완료 사진 1');
|
||||||
|
table.string('resolution_photo_path2', 255).nullable().comment('처리 완료 사진 2');
|
||||||
|
table.datetime('resolved_at').nullable().comment('처리 완료 일시');
|
||||||
|
table.integer('resolved_by').nullable().comment('처리 완료자 user_id');
|
||||||
|
|
||||||
|
// 수정 이력 (JSON)
|
||||||
|
table.json('modification_history').nullable().comment('수정 이력 추적');
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('reporter_id')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('factory_category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('workplace_categories')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('workplace_id')
|
||||||
|
.references('workplace_id')
|
||||||
|
.inTable('workplaces')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('issue_category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('issue_report_categories')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('issue_item_id')
|
||||||
|
.references('item_id')
|
||||||
|
.inTable('issue_report_items')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('assigned_user_id')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('assigned_by')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('resolved_by')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('reporter_id', 'idx_wir_reporter_id');
|
||||||
|
table.index('status', 'idx_wir_status');
|
||||||
|
table.index('report_date', 'idx_wir_report_date');
|
||||||
|
table.index(['factory_category_id', 'workplace_id'], 'idx_wir_workplace');
|
||||||
|
table.index('issue_category_id', 'idx_wir_issue_category');
|
||||||
|
table.index('assigned_user_id', 'idx_wir_assigned_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 상태 변경 이력 테이블 생성
|
||||||
|
await knex.schema.createTable('work_issue_status_logs', function(table) {
|
||||||
|
table.increments('log_id').primary().comment('로그 ID');
|
||||||
|
table.integer('report_id').unsigned().notNullable().comment('신고 ID');
|
||||||
|
table.string('previous_status', 50).nullable().comment('이전 상태');
|
||||||
|
table.string('new_status', 50).notNullable().comment('새 상태');
|
||||||
|
table.integer('changed_by').notNullable().comment('변경자 user_id');
|
||||||
|
table.text('change_reason').nullable().comment('변경 사유');
|
||||||
|
table.timestamp('changed_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.foreign('report_id')
|
||||||
|
.references('report_id')
|
||||||
|
.inTable('work_issue_reports')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.foreign('changed_by')
|
||||||
|
.references('user_id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
|
||||||
|
table.index('report_id', 'idx_wisl_report_id');
|
||||||
|
table.index('changed_at', 'idx_wisl_changed_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('작업 중 문제 신고 시스템 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 역순으로 테이블 삭제
|
||||||
|
await knex.schema.dropTableIfExists('work_issue_status_logs');
|
||||||
|
await knex.schema.dropTableIfExists('work_issue_reports');
|
||||||
|
await knex.schema.dropTableIfExists('issue_report_items');
|
||||||
|
await knex.schema.dropTableIfExists('issue_report_categories');
|
||||||
|
|
||||||
|
console.log('작업 중 문제 신고 시스템 테이블 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 문제 신고 관련 페이지 등록
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 문제 신고 등록 페이지
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'issue-report',
|
||||||
|
page_name: '문제 신고',
|
||||||
|
page_path: '/pages/work/issue-report.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '작업 중 문제(부적합/안전) 신고 등록',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
// 신고 목록 페이지
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'issue-list',
|
||||||
|
page_name: '신고 목록',
|
||||||
|
page_path: '/pages/work/issue-list.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '문제 신고 목록 조회 및 관리',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 17
|
||||||
|
});
|
||||||
|
|
||||||
|
// 신고 상세 페이지
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'issue-detail',
|
||||||
|
page_name: '신고 상세',
|
||||||
|
page_path: '/pages/work/issue-detail.html',
|
||||||
|
category: 'work',
|
||||||
|
description: '문제 신고 상세 조회',
|
||||||
|
is_admin_only: 0,
|
||||||
|
display_order: 18
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 문제 신고 페이지 등록 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('pages').whereIn('page_key', [
|
||||||
|
'issue-report',
|
||||||
|
'issue-list',
|
||||||
|
'issue-detail'
|
||||||
|
]).delete();
|
||||||
|
|
||||||
|
console.log('✅ 문제 신고 페이지 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 작업보고서 부적합 상세 테이블 마이그레이션
|
||||||
|
*
|
||||||
|
* 기존: error_hours, error_type_id (단일 값)
|
||||||
|
* 변경: 여러 부적합 원인 + 각 원인별 시간 저장 가능
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
// 1. work_report_defects 테이블 생성
|
||||||
|
.createTable('work_report_defects', function(table) {
|
||||||
|
table.increments('defect_id').primary();
|
||||||
|
table.integer('report_id').notNullable()
|
||||||
|
.comment('daily_work_reports의 id');
|
||||||
|
table.integer('error_type_id').notNullable()
|
||||||
|
.comment('error_types의 id (부적합 원인)');
|
||||||
|
table.decimal('defect_hours', 4, 1).notNullable().defaultTo(0)
|
||||||
|
.comment('해당 원인의 부적합 시간');
|
||||||
|
table.text('note').nullable()
|
||||||
|
.comment('추가 메모');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('report_id').references('id').inTable('daily_work_reports').onDelete('CASCADE');
|
||||||
|
table.foreign('error_type_id').references('id').inTable('error_types');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('report_id');
|
||||||
|
table.index('error_type_id');
|
||||||
|
|
||||||
|
// 같은 보고서에 같은 원인이 중복되지 않도록
|
||||||
|
table.unique(['report_id', 'error_type_id']);
|
||||||
|
})
|
||||||
|
// 2. 기존 데이터 마이그레이션 (error_hours > 0인 경우)
|
||||||
|
.then(function() {
|
||||||
|
return knex.raw(`
|
||||||
|
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, created_at)
|
||||||
|
SELECT id, error_type_id, error_hours, created_at
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE error_hours > 0 AND error_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTableIfExists('work_report_defects');
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 안전 체크리스트 확장 마이그레이션
|
||||||
|
*
|
||||||
|
* 1. tbm_safety_checks 테이블 확장 (check_type, weather_condition, task_id)
|
||||||
|
* 2. weather_conditions 테이블 생성 (날씨 조건 코드)
|
||||||
|
* 3. tbm_weather_records 테이블 생성 (세션별 날씨 기록)
|
||||||
|
* 4. 초기 날씨별 체크항목 데이터
|
||||||
|
*
|
||||||
|
* @since 2026-02-02
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
// 1. tbm_safety_checks 테이블 확장
|
||||||
|
.alterTable('tbm_safety_checks', function(table) {
|
||||||
|
table.enum('check_type', ['basic', 'weather', 'task']).defaultTo('basic').after('check_category');
|
||||||
|
table.string('weather_condition', 50).nullable().after('check_type');
|
||||||
|
table.integer('task_id').unsigned().nullable().after('weather_condition');
|
||||||
|
|
||||||
|
// 인덱스 추가
|
||||||
|
table.index('check_type');
|
||||||
|
table.index('weather_condition');
|
||||||
|
table.index('task_id');
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. weather_conditions 테이블 생성
|
||||||
|
.createTable('weather_conditions', function(table) {
|
||||||
|
table.string('condition_code', 50).primary();
|
||||||
|
table.string('condition_name', 100).notNullable();
|
||||||
|
table.text('description').nullable();
|
||||||
|
table.string('icon', 50).nullable();
|
||||||
|
table.decimal('temp_threshold_min', 4, 1).nullable(); // 최소 기온 기준
|
||||||
|
table.decimal('temp_threshold_max', 4, 1).nullable(); // 최대 기온 기준
|
||||||
|
table.decimal('wind_threshold', 4, 1).nullable(); // 풍속 기준 (m/s)
|
||||||
|
table.decimal('precip_threshold', 5, 1).nullable(); // 강수량 기준 (mm)
|
||||||
|
table.boolean('is_active').defaultTo(true);
|
||||||
|
table.integer('display_order').defaultTo(0);
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. tbm_weather_records 테이블 생성
|
||||||
|
.createTable('tbm_weather_records', function(table) {
|
||||||
|
table.increments('record_id').primary();
|
||||||
|
table.integer('session_id').unsigned().notNullable();
|
||||||
|
table.date('weather_date').notNullable();
|
||||||
|
table.decimal('temperature', 4, 1).nullable(); // 기온 (섭씨)
|
||||||
|
table.integer('humidity').nullable(); // 습도 (%)
|
||||||
|
table.decimal('wind_speed', 4, 1).nullable(); // 풍속 (m/s)
|
||||||
|
table.decimal('precipitation', 5, 1).nullable(); // 강수량 (mm)
|
||||||
|
table.string('sky_condition', 50).nullable(); // 하늘 상태
|
||||||
|
table.string('weather_condition', 50).nullable(); // 주요 날씨 상태
|
||||||
|
table.json('weather_conditions').nullable(); // 복수 조건 ['rain', 'wind']
|
||||||
|
table.string('data_source', 50).defaultTo('api'); // 데이터 출처
|
||||||
|
table.timestamp('fetched_at').nullable();
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
table.foreign('session_id').references('session_id').inTable('tbm_sessions').onDelete('CASCADE');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
table.index('weather_date');
|
||||||
|
table.unique(['session_id']);
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 초기 데이터 삽입
|
||||||
|
.then(function() {
|
||||||
|
// 기존 체크항목을 'basic' 유형으로 업데이트
|
||||||
|
return knex('tbm_safety_checks').update({ check_type: 'basic' });
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
// 날씨 조건 코드 삽입
|
||||||
|
return knex('weather_conditions').insert([
|
||||||
|
{ condition_code: 'clear', condition_name: '맑음', description: '맑은 날씨', icon: 'sunny', display_order: 1 },
|
||||||
|
{ condition_code: 'rain', condition_name: '비', description: '비 오는 날씨', icon: 'rainy', precip_threshold: 0.1, display_order: 2 },
|
||||||
|
{ condition_code: 'snow', condition_name: '눈', description: '눈 오는 날씨', icon: 'snowy', display_order: 3 },
|
||||||
|
{ condition_code: 'heat', condition_name: '폭염', description: '기온 35도 이상', icon: 'hot', temp_threshold_min: 35, display_order: 4 },
|
||||||
|
{ condition_code: 'cold', condition_name: '한파', description: '기온 영하 10도 이하', icon: 'cold', temp_threshold_max: -10, display_order: 5 },
|
||||||
|
{ condition_code: 'wind', condition_name: '강풍', description: '풍속 10m/s 이상', icon: 'windy', wind_threshold: 10, display_order: 6 },
|
||||||
|
{ condition_code: 'fog', condition_name: '안개', description: '시정 1km 미만', icon: 'foggy', display_order: 7 },
|
||||||
|
{ condition_code: 'dust', condition_name: '미세먼지', description: '미세먼지 나쁨 이상', icon: 'dusty', display_order: 8 }
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
// 날씨별 안전 체크항목 삽입
|
||||||
|
return knex('tbm_safety_checks').insert([
|
||||||
|
// 비 (rain)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '우의/우산 준비 확인', description: '비 오는 날 우의 또는 우산 준비 여부', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '미끄럼 방지 조치 확인', description: '빗물로 인한 미끄러움 방지 조치', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '전기 작업 중단 여부 확인', description: '우천 시 전기 작업 중단 필요성 확인', is_required: true, display_order: 3 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '배수 상태 확인', description: '작업장 배수 상태 점검', is_required: false, display_order: 4 },
|
||||||
|
|
||||||
|
// 눈 (snow)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '제설 작업 완료 확인', description: '작업장 주변 제설 작업 완료 여부', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '동파 방지 조치 확인', description: '배관 및 설비 동파 방지 조치', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '미끄럼 방지 모래/염화칼슘 비치', description: '미끄럼 방지를 위한 모래 또는 염화칼슘 비치', is_required: true, display_order: 3 },
|
||||||
|
|
||||||
|
// 폭염 (heat)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '그늘막/휴게소 확보', description: '무더위 휴식을 위한 그늘막 또는 휴게소 확보', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '음료수/식염 포도당 비치', description: '열사병 예방을 위한 음료수 및 염분 보충제 비치', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '무더위 휴식 시간 확보', description: '10~15시 사이 충분한 휴식 시간 확보', is_required: true, display_order: 3 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '작업자 건강 상태 확인', description: '열사병 증상 체크 및 건강 상태 확인', is_required: true, display_order: 4 },
|
||||||
|
|
||||||
|
// 한파 (cold)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '방한복/방한장갑 착용 확인', description: '동상 방지를 위한 방한복 및 방한장갑 착용', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '난방시설 가동 확인', description: '휴게 공간 난방시설 가동 상태 확인', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '온열 음료 비치', description: '체온 유지를 위한 따뜻한 음료 비치', is_required: false, display_order: 3 },
|
||||||
|
|
||||||
|
// 강풍 (wind)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '고소 작업 중단 여부 확인', description: '강풍 시 고소 작업 중단 필요성 확인', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '자재/장비 결박 확인', description: '바람에 날릴 수 있는 자재 및 장비 고정', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '가설물 안전 점검', description: '가설 구조물 및 비계 안전 상태 점검', is_required: true, display_order: 3 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '크레인 작업 중단 여부 확인', description: '강풍 시 크레인 작업 중단 필요성 확인', is_required: true, display_order: 4 },
|
||||||
|
|
||||||
|
// 안개 (fog)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '경광등/조명 확보', description: '시정 확보를 위한 경광등 및 조명 설치', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '차량 운행 주의 안내', description: '안개로 인한 차량 운행 주의 안내', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '작업 구역 표시 강화', description: '시인성 확보를 위한 작업 구역 표시 강화', is_required: false, display_order: 3 },
|
||||||
|
|
||||||
|
// 미세먼지 (dust)
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '보호 마스크 착용 확인', description: 'KF94 이상 마스크 착용 여부 확인', is_required: true, display_order: 1 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '실외 작업 시간 조정', description: '미세먼지 농도에 따른 실외 작업 시간 조정', is_required: true, display_order: 2 },
|
||||||
|
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '호흡기 질환자 실내 배치', description: '호흡기 질환 작업자 실내 작업 배치', is_required: false, display_order: 3 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('tbm_weather_records')
|
||||||
|
.dropTableIfExists('weather_conditions')
|
||||||
|
.then(function() {
|
||||||
|
return knex.schema.alterTable('tbm_safety_checks', function(table) {
|
||||||
|
table.dropIndex('check_type');
|
||||||
|
table.dropIndex('weather_condition');
|
||||||
|
table.dropIndex('task_id');
|
||||||
|
table.dropColumn('check_type');
|
||||||
|
table.dropColumn('weather_condition');
|
||||||
|
table.dropColumn('task_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal file
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 페이지 구조 재구성 마이그레이션
|
||||||
|
* - 페이지 경로 업데이트 (safety/, attendance/ 폴더로 이동)
|
||||||
|
* - 카테고리 재분류
|
||||||
|
* - 역할별 기본 페이지 권한 테이블 생성
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 1. 페이지 경로 업데이트 - safety 폴더로 이동된 페이지들
|
||||||
|
const safetyPageUpdates = [
|
||||||
|
{
|
||||||
|
old_key: 'issue-report',
|
||||||
|
new_key: 'safety.issue_report',
|
||||||
|
new_path: '/pages/safety/issue-report.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '이슈 신고'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'issue-list',
|
||||||
|
new_key: 'safety.issue_list',
|
||||||
|
new_path: '/pages/safety/issue-list.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '이슈 목록'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'issue-detail',
|
||||||
|
new_key: 'safety.issue_detail',
|
||||||
|
new_path: '/pages/safety/issue-detail.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '이슈 상세'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'visit-request',
|
||||||
|
new_key: 'safety.visit_request',
|
||||||
|
new_path: '/pages/safety/visit-request.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '방문 요청'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'safety-management',
|
||||||
|
new_key: 'safety.management',
|
||||||
|
new_path: '/pages/safety/management.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '안전 관리'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'safety-training-conduct',
|
||||||
|
new_key: 'safety.training_conduct',
|
||||||
|
new_path: '/pages/safety/training-conduct.html',
|
||||||
|
new_category: 'safety',
|
||||||
|
new_name: '안전교육 진행'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. 페이지 경로 업데이트 - attendance 폴더로 이동된 페이지들
|
||||||
|
const attendancePageUpdates = [
|
||||||
|
{
|
||||||
|
old_key: 'daily-attendance',
|
||||||
|
new_key: 'attendance.daily',
|
||||||
|
new_path: '/pages/attendance/daily.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '일일 출퇴근'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'monthly-attendance',
|
||||||
|
new_key: 'attendance.monthly',
|
||||||
|
new_path: '/pages/attendance/monthly.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '월간 근태'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'annual-vacation-overview',
|
||||||
|
new_key: 'attendance.annual_overview',
|
||||||
|
new_path: '/pages/attendance/annual-overview.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '연간 휴가 현황'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'vacation-request',
|
||||||
|
new_key: 'attendance.vacation_request',
|
||||||
|
new_path: '/pages/attendance/vacation-request.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '휴가 신청'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'vacation-management',
|
||||||
|
new_key: 'attendance.vacation_management',
|
||||||
|
new_path: '/pages/attendance/vacation-management.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '휴가 관리'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_key: 'vacation-allocation',
|
||||||
|
new_key: 'attendance.vacation_allocation',
|
||||||
|
new_path: '/pages/attendance/vacation-allocation.html',
|
||||||
|
new_category: 'attendance',
|
||||||
|
new_name: '휴가 발생 입력'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. admin 폴더 내 파일명 변경
|
||||||
|
const adminPageUpdates = [
|
||||||
|
{
|
||||||
|
old_key: 'attendance-report-comparison',
|
||||||
|
new_key: 'admin.attendance_report',
|
||||||
|
new_path: '/pages/admin/attendance-report.html',
|
||||||
|
new_category: 'admin',
|
||||||
|
new_name: '출퇴근-보고서 대조'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모든 업데이트 실행
|
||||||
|
const allUpdates = [...safetyPageUpdates, ...attendancePageUpdates, ...adminPageUpdates];
|
||||||
|
|
||||||
|
for (const update of allUpdates) {
|
||||||
|
await knex('pages')
|
||||||
|
.where('page_key', update.old_key)
|
||||||
|
.update({
|
||||||
|
page_key: update.new_key,
|
||||||
|
page_path: update.new_path,
|
||||||
|
category: update.new_category,
|
||||||
|
page_name: update.new_name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 안전 체크리스트 관리 페이지 추가 (새로 생성된 페이지)
|
||||||
|
const existingChecklistPage = await knex('pages')
|
||||||
|
.where('page_key', 'safety.checklist_manage')
|
||||||
|
.orWhere('page_key', 'safety-checklist-manage')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existingChecklistPage) {
|
||||||
|
await knex('pages').insert({
|
||||||
|
page_key: 'safety.checklist_manage',
|
||||||
|
page_name: '안전 체크리스트 관리',
|
||||||
|
page_path: '/pages/safety/checklist-manage.html',
|
||||||
|
category: 'safety',
|
||||||
|
description: '안전 체크리스트 항목 관리',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 50
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 휴가 승인/직접입력 페이지 추가 (새로 생성된 페이지인 경우)
|
||||||
|
const vacationPages = [
|
||||||
|
{
|
||||||
|
page_key: 'attendance.vacation_approval',
|
||||||
|
page_name: '휴가 승인 관리',
|
||||||
|
page_path: '/pages/attendance/vacation-approval.html',
|
||||||
|
category: 'attendance',
|
||||||
|
description: '휴가 신청 승인/거부',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_key: 'attendance.vacation_input',
|
||||||
|
page_name: '휴가 직접 입력',
|
||||||
|
page_path: '/pages/attendance/vacation-input.html',
|
||||||
|
category: 'attendance',
|
||||||
|
description: '관리자 휴가 직접 입력',
|
||||||
|
is_admin_only: 1,
|
||||||
|
display_order: 66
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const page of vacationPages) {
|
||||||
|
const existing = await knex('pages').where('page_key', page.page_key).first();
|
||||||
|
if (!existing) {
|
||||||
|
await knex('pages').insert(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. role_default_pages 테이블 생성 (역할별 기본 페이지 권한)
|
||||||
|
const tableExists = await knex.schema.hasTable('role_default_pages');
|
||||||
|
if (!tableExists) {
|
||||||
|
await knex.schema.createTable('role_default_pages', (table) => {
|
||||||
|
table.integer('role_id').unsigned().notNullable()
|
||||||
|
.references('id').inTable('roles').onDelete('CASCADE');
|
||||||
|
table.integer('page_id').unsigned().notNullable()
|
||||||
|
.references('id').inTable('pages').onDelete('CASCADE');
|
||||||
|
table.primary(['role_id', 'page_id']);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 기본 역할-페이지 매핑 데이터 삽입
|
||||||
|
// 역할 조회
|
||||||
|
const roles = await knex('roles').select('id', 'name');
|
||||||
|
const pages = await knex('pages').select('id', 'page_key', 'category');
|
||||||
|
|
||||||
|
const roleMap = {};
|
||||||
|
roles.forEach(r => { roleMap[r.name] = r.id; });
|
||||||
|
|
||||||
|
const pageMap = {};
|
||||||
|
pages.forEach(p => { pageMap[p.page_key] = p.id; });
|
||||||
|
|
||||||
|
// Worker 역할 기본 페이지 (대시보드, 작업보고서, 휴가신청)
|
||||||
|
const workerPages = [
|
||||||
|
'dashboard',
|
||||||
|
'work.report_create',
|
||||||
|
'work.report_view',
|
||||||
|
'attendance.vacation_request'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Leader 역할 기본 페이지 (Worker + TBM, 안전, 근태 일부)
|
||||||
|
const leaderPages = [
|
||||||
|
...workerPages,
|
||||||
|
'work.tbm',
|
||||||
|
'work.analysis',
|
||||||
|
'safety.issue_report',
|
||||||
|
'safety.issue_list',
|
||||||
|
'attendance.daily',
|
||||||
|
'attendance.monthly'
|
||||||
|
];
|
||||||
|
|
||||||
|
// SafetyManager 역할 기본 페이지 (Leader + 안전 전체)
|
||||||
|
const safetyManagerPages = [
|
||||||
|
...leaderPages,
|
||||||
|
'safety.issue_detail',
|
||||||
|
'safety.visit_request',
|
||||||
|
'safety.management',
|
||||||
|
'safety.training_conduct',
|
||||||
|
'safety.checklist_manage'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 역할별 페이지 매핑 삽입
|
||||||
|
const rolePageMappings = [];
|
||||||
|
|
||||||
|
if (roleMap['Worker']) {
|
||||||
|
workerPages.forEach(pageKey => {
|
||||||
|
if (pageMap[pageKey]) {
|
||||||
|
rolePageMappings.push({ role_id: roleMap['Worker'], page_id: pageMap[pageKey] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleMap['Leader']) {
|
||||||
|
leaderPages.forEach(pageKey => {
|
||||||
|
if (pageMap[pageKey]) {
|
||||||
|
rolePageMappings.push({ role_id: roleMap['Leader'], page_id: pageMap[pageKey] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleMap['SafetyManager']) {
|
||||||
|
safetyManagerPages.forEach(pageKey => {
|
||||||
|
if (pageMap[pageKey]) {
|
||||||
|
rolePageMappings.push({ role_id: roleMap['SafetyManager'], page_id: pageMap[pageKey] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거 후 삽입
|
||||||
|
for (const mapping of rolePageMappings) {
|
||||||
|
const existing = await knex('role_default_pages')
|
||||||
|
.where('role_id', mapping.role_id)
|
||||||
|
.where('page_id', mapping.page_id)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await knex('role_default_pages').insert(mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('페이지 구조 재구성 완료');
|
||||||
|
console.log(`- 업데이트된 페이지: ${allUpdates.length}개`);
|
||||||
|
console.log(`- 역할별 기본 페이지 매핑: ${rolePageMappings.length}개`);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// 1. role_default_pages 테이블 삭제
|
||||||
|
await knex.schema.dropTableIfExists('role_default_pages');
|
||||||
|
|
||||||
|
// 2. 페이지 경로 원복 - safety → work/admin
|
||||||
|
const safetyRevert = [
|
||||||
|
{ new_key: 'safety.issue_report', old_key: 'issue-report', old_path: '/pages/work/issue-report.html', old_category: 'work' },
|
||||||
|
{ new_key: 'safety.issue_list', old_key: 'issue-list', old_path: '/pages/work/issue-list.html', old_category: 'work' },
|
||||||
|
{ new_key: 'safety.issue_detail', old_key: 'issue-detail', old_path: '/pages/work/issue-detail.html', old_category: 'work' },
|
||||||
|
{ new_key: 'safety.visit_request', old_key: 'visit-request', old_path: '/pages/work/visit-request.html', old_category: 'work' },
|
||||||
|
{ new_key: 'safety.management', old_key: 'safety-management', old_path: '/pages/admin/safety-management.html', old_category: 'admin' },
|
||||||
|
{ new_key: 'safety.training_conduct', old_key: 'safety-training-conduct', old_path: '/pages/admin/safety-training-conduct.html', old_category: 'admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. 페이지 경로 원복 - attendance → common
|
||||||
|
const attendanceRevert = [
|
||||||
|
{ new_key: 'attendance.daily', old_key: 'daily-attendance', old_path: '/pages/common/daily-attendance.html', old_category: 'common' },
|
||||||
|
{ new_key: 'attendance.monthly', old_key: 'monthly-attendance', old_path: '/pages/common/monthly-attendance.html', old_category: 'common' },
|
||||||
|
{ new_key: 'attendance.annual_overview', old_key: 'annual-vacation-overview', old_path: '/pages/common/annual-vacation-overview.html', old_category: 'common' },
|
||||||
|
{ new_key: 'attendance.vacation_request', old_key: 'vacation-request', old_path: '/pages/common/vacation-request.html', old_category: 'common' },
|
||||||
|
{ new_key: 'attendance.vacation_management', old_key: 'vacation-management', old_path: '/pages/common/vacation-management.html', old_category: 'common' },
|
||||||
|
{ new_key: 'attendance.vacation_allocation', old_key: 'vacation-allocation', old_path: '/pages/common/vacation-allocation.html', old_category: 'common' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. admin 파일명 원복
|
||||||
|
const adminRevert = [
|
||||||
|
{ new_key: 'admin.attendance_report', old_key: 'attendance-report-comparison', old_path: '/pages/admin/attendance-report-comparison.html', old_category: 'admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const allReverts = [...safetyRevert, ...attendanceRevert, ...adminRevert];
|
||||||
|
|
||||||
|
for (const revert of allReverts) {
|
||||||
|
await knex('pages')
|
||||||
|
.where('page_key', revert.new_key)
|
||||||
|
.update({
|
||||||
|
page_key: revert.old_key,
|
||||||
|
page_path: revert.old_path,
|
||||||
|
category: revert.old_category
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 새로 추가된 페이지 삭제
|
||||||
|
await knex('pages').whereIn('page_key', [
|
||||||
|
'safety.checklist_manage',
|
||||||
|
'attendance.vacation_approval',
|
||||||
|
'attendance.vacation_input'
|
||||||
|
]).del();
|
||||||
|
|
||||||
|
console.log('페이지 구조 재구성 롤백 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 작업보고서 부적합에 카테고리/아이템 컬럼 추가
|
||||||
|
*
|
||||||
|
* 변경사항:
|
||||||
|
* 1. work_report_defects 테이블에 category_id, item_id 컬럼 추가
|
||||||
|
* 2. issue_report_categories, issue_report_items 테이블 참조
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('work_report_defects', function(table) {
|
||||||
|
// 카테고리 ID 추가
|
||||||
|
table.integer('category_id').unsigned().nullable()
|
||||||
|
.comment('issue_report_categories의 category_id (직접 입력 시)')
|
||||||
|
.after('issue_report_id');
|
||||||
|
|
||||||
|
// 아이템 ID 추가
|
||||||
|
table.integer('item_id').unsigned().nullable()
|
||||||
|
.comment('issue_report_items의 item_id (직접 입력 시)')
|
||||||
|
.after('category_id');
|
||||||
|
|
||||||
|
// 외래키 추가
|
||||||
|
table.foreign('category_id')
|
||||||
|
.references('category_id')
|
||||||
|
.inTable('issue_report_categories')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
|
||||||
|
table.foreign('item_id')
|
||||||
|
.references('item_id')
|
||||||
|
.inTable('issue_report_items')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('work_report_defects', function(table) {
|
||||||
|
table.dropForeign('category_id');
|
||||||
|
table.dropForeign('item_id');
|
||||||
|
table.dropColumn('category_id');
|
||||||
|
table.dropColumn('item_id');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 작업보고서 부적합을 신고 시스템과 연동
|
||||||
|
*
|
||||||
|
* 변경사항:
|
||||||
|
* 1. work_report_defects 테이블에 issue_report_id 컬럼 추가
|
||||||
|
* 2. error_type_id를 NULL 허용으로 변경 (신고 연동 시 불필요)
|
||||||
|
* 3. work_issue_reports.report_id (unsigned int)와 타입 일치 필요
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('work_report_defects', function(table) {
|
||||||
|
// 1. issue_report_id 컬럼 추가 (unsigned int로 work_issue_reports.report_id와 타입 일치)
|
||||||
|
table.integer('issue_report_id').unsigned().nullable()
|
||||||
|
.comment('work_issue_reports의 report_id (신고된 이슈 연결)')
|
||||||
|
.after('error_type_id');
|
||||||
|
|
||||||
|
// 2. 외래키 추가 (work_issue_reports.report_id 참조)
|
||||||
|
table.foreign('issue_report_id')
|
||||||
|
.references('report_id')
|
||||||
|
.inTable('work_issue_reports')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
|
||||||
|
// 3. 인덱스 추가
|
||||||
|
table.index('issue_report_id');
|
||||||
|
})
|
||||||
|
// 4. error_type_id를 NULL 허용으로 변경
|
||||||
|
.then(function() {
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
MODIFY COLUMN error_type_id INT NULL
|
||||||
|
COMMENT 'error_types의 id (부적합 원인) - 레거시, issue_report_id 사용 권장'
|
||||||
|
`);
|
||||||
|
})
|
||||||
|
// 5. 유니크 제약 수정 (issue_report_id도 고려)
|
||||||
|
.then(function() {
|
||||||
|
// 기존 유니크 제약 삭제
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
DROP INDEX work_report_defects_report_id_error_type_id_unique
|
||||||
|
`).catch(() => {
|
||||||
|
// 인덱스가 없을 수 있음 - 무시
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
// 새 유니크 제약 추가 (report_id + issue_report_id 조합)
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
ADD UNIQUE INDEX work_report_defects_report_issue_unique (report_id, issue_report_id)
|
||||||
|
`).catch(() => {
|
||||||
|
// 이미 존재할 수 있음 - 무시
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('work_report_defects', function(table) {
|
||||||
|
// 외래키 및 인덱스 삭제
|
||||||
|
table.dropForeign('issue_report_id');
|
||||||
|
table.dropIndex('issue_report_id');
|
||||||
|
table.dropColumn('issue_report_id');
|
||||||
|
})
|
||||||
|
// error_type_id를 다시 NOT NULL로 변경
|
||||||
|
.then(function() {
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
MODIFY COLUMN error_type_id INT NOT NULL
|
||||||
|
COMMENT 'error_types의 id (부적합 원인)'
|
||||||
|
`);
|
||||||
|
})
|
||||||
|
// 기존 유니크 제약 복원
|
||||||
|
.then(function() {
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
DROP INDEX IF EXISTS work_report_defects_report_issue_unique
|
||||||
|
`).catch(() => {});
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
return knex.raw(`
|
||||||
|
ALTER TABLE work_report_defects
|
||||||
|
ADD UNIQUE INDEX work_report_defects_report_id_error_type_id_unique (report_id, error_type_id)
|
||||||
|
`).catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 알림 수신자 설정 테이블
|
||||||
|
-- 알림 유형별로 지정된 사용자에게만 알림이 전송됨
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_recipients (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
notification_type ENUM('repair', 'safety', 'nonconformity', 'equipment', 'maintenance', 'system') NOT NULL COMMENT '알림 유형',
|
||||||
|
user_id INT NOT NULL COMMENT '수신자 ID',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 여부',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL COMMENT '등록자',
|
||||||
|
UNIQUE KEY unique_type_user (notification_type, user_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_nr_type (notification_type),
|
||||||
|
INDEX idx_nr_active (is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 수신자 설정';
|
||||||
|
|
||||||
|
-- 알림 유형 설명:
|
||||||
|
-- repair: 설비 수리 신청
|
||||||
|
-- safety: 안전 신고
|
||||||
|
-- nonconformity: 부적합 신고
|
||||||
|
-- equipment: 설비 관련
|
||||||
|
-- maintenance: 정기점검
|
||||||
|
-- system: 시스템 알림
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 설비 테이블에 구입처 및 구입가격 컬럼 추가
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-02-04
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// 컬럼 존재 여부 확인
|
||||||
|
const hasSupplier = await knex.schema.hasColumn('equipments', 'supplier');
|
||||||
|
const hasPurchasePrice = await knex.schema.hasColumn('equipments', 'purchase_price');
|
||||||
|
|
||||||
|
if (!hasSupplier || !hasPurchasePrice) {
|
||||||
|
await knex.schema.alterTable('equipments', (table) => {
|
||||||
|
if (!hasSupplier) {
|
||||||
|
table.string('supplier', 100).nullable().after('manufacturer').comment('구입처');
|
||||||
|
}
|
||||||
|
if (!hasPurchasePrice) {
|
||||||
|
table.decimal('purchase_price', 15, 0).nullable().after('supplier').comment('구입가격');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('✅ equipments 테이블에 supplier, purchase_price 컬럼 추가 완료');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ supplier, purchase_price 컬럼이 이미 존재합니다. 스킵합니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.alterTable('equipments', (table) => {
|
||||||
|
table.dropColumn('supplier');
|
||||||
|
table.dropColumn('purchase_price');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ equipments 테이블에서 supplier, purchase_price 컬럼 삭제 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* 마이그레이션: 일일순회점검 시스템
|
||||||
|
* 작성일: 2026-02-04
|
||||||
|
*
|
||||||
|
* 생성 테이블:
|
||||||
|
* - patrol_checklist_items: 순회점검 체크리스트 마스터
|
||||||
|
* - daily_patrol_sessions: 순회점검 세션 기록
|
||||||
|
* - patrol_check_records: 순회점검 체크 결과
|
||||||
|
* - workplace_items: 작업장 물품 현황 (용기, 플레이트 등)
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('⏳ 일일순회점검 시스템 테이블 생성 중...');
|
||||||
|
|
||||||
|
// 1. 순회점검 체크리스트 마스터 테이블
|
||||||
|
await knex.schema.createTable('patrol_checklist_items', (table) => {
|
||||||
|
table.increments('item_id').primary();
|
||||||
|
table.integer('workplace_id').unsigned().nullable().comment('특정 작업장 전용 (NULL=공통)');
|
||||||
|
table.integer('category_id').unsigned().nullable().comment('특정 공장 전용 (NULL=공통)');
|
||||||
|
table.string('check_category', 50).notNullable().comment('분류 (안전, 정리정돈, 설비 등)');
|
||||||
|
table.string('check_item', 200).notNullable().comment('점검 항목');
|
||||||
|
table.text('description').nullable().comment('설명');
|
||||||
|
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||||
|
table.boolean('is_required').defaultTo(true).comment('필수 체크 여부');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index('workplace_id');
|
||||||
|
table.index('category_id');
|
||||||
|
table.index('check_category');
|
||||||
|
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
|
||||||
|
table.foreign('category_id').references('workplace_categories.category_id').onDelete('CASCADE');
|
||||||
|
});
|
||||||
|
console.log('✅ patrol_checklist_items 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 초기 순회점검 체크리스트 데이터
|
||||||
|
await knex('patrol_checklist_items').insert([
|
||||||
|
// 안전 관련
|
||||||
|
{ check_category: 'SAFETY', check_item: '소화기 상태 확인', display_order: 1, is_required: true },
|
||||||
|
{ check_category: 'SAFETY', check_item: '비상구 통로 확보 확인', display_order: 2, is_required: true },
|
||||||
|
{ check_category: 'SAFETY', check_item: '안전표지판 부착 상태', display_order: 3, is_required: true },
|
||||||
|
{ check_category: 'SAFETY', check_item: '위험물 관리 상태', display_order: 4, is_required: true },
|
||||||
|
|
||||||
|
// 정리정돈
|
||||||
|
{ check_category: 'ORGANIZATION', check_item: '작업장 정리정돈 상태', display_order: 10, is_required: true },
|
||||||
|
{ check_category: 'ORGANIZATION', check_item: '통로 장애물 여부', display_order: 11, is_required: true },
|
||||||
|
{ check_category: 'ORGANIZATION', check_item: '폐기물 처리 상태', display_order: 12, is_required: true },
|
||||||
|
{ check_category: 'ORGANIZATION', check_item: '자재 적재 상태', display_order: 13, is_required: true },
|
||||||
|
|
||||||
|
// 설비
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '설비 외관 이상 여부', display_order: 20, is_required: false },
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '설비 작동 상태', display_order: 21, is_required: false },
|
||||||
|
{ check_category: 'EQUIPMENT', check_item: '설비 청결 상태', display_order: 22, is_required: false },
|
||||||
|
|
||||||
|
// 환경
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '조명 상태', display_order: 30, is_required: true },
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '환기 상태', display_order: 31, is_required: true },
|
||||||
|
{ check_category: 'ENVIRONMENT', check_item: '누수/누유 여부', display_order: 32, is_required: true },
|
||||||
|
]);
|
||||||
|
console.log('✅ patrol_checklist_items 초기 데이터 입력 완료');
|
||||||
|
|
||||||
|
// 2. 순회점검 세션 테이블
|
||||||
|
await knex.schema.createTable('daily_patrol_sessions', (table) => {
|
||||||
|
table.increments('session_id').primary();
|
||||||
|
table.date('patrol_date').notNullable().comment('점검 날짜');
|
||||||
|
table.enum('patrol_time', ['morning', 'afternoon']).notNullable().comment('점검 시간대');
|
||||||
|
table.integer('inspector_id').notNullable().comment('순찰자 user_id'); // signed (users.user_id)
|
||||||
|
table.integer('category_id').unsigned().nullable().comment('공장 ID');
|
||||||
|
table.enum('status', ['in_progress', 'completed']).defaultTo('in_progress').comment('상태');
|
||||||
|
table.text('notes').nullable().comment('특이사항');
|
||||||
|
table.time('started_at').nullable().comment('점검 시작 시간');
|
||||||
|
table.time('completed_at').nullable().comment('점검 완료 시간');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.unique(['patrol_date', 'patrol_time', 'category_id']);
|
||||||
|
table.index(['patrol_date', 'patrol_time']);
|
||||||
|
table.index('inspector_id');
|
||||||
|
table.foreign('inspector_id').references('users.user_id');
|
||||||
|
table.foreign('category_id').references('workplace_categories.category_id').onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
console.log('✅ daily_patrol_sessions 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 3. 순회점검 체크 기록 테이블
|
||||||
|
await knex.schema.createTable('patrol_check_records', (table) => {
|
||||||
|
table.increments('record_id').primary();
|
||||||
|
table.integer('session_id').unsigned().notNullable().comment('순회점검 세션 ID');
|
||||||
|
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
|
||||||
|
table.integer('check_item_id').unsigned().notNullable().comment('체크항목 ID');
|
||||||
|
table.boolean('is_checked').defaultTo(false).comment('체크 여부');
|
||||||
|
table.enum('check_result', ['good', 'warning', 'bad']).nullable().comment('점검 결과');
|
||||||
|
table.text('note').nullable().comment('비고');
|
||||||
|
table.timestamp('checked_at').nullable().comment('체크 시간');
|
||||||
|
|
||||||
|
// 인덱스명 길이 제한으로 인해 수동으로 지정
|
||||||
|
table.unique(['session_id', 'workplace_id', 'check_item_id'], 'pcr_session_wp_item_unique');
|
||||||
|
table.index(['session_id', 'workplace_id'], 'pcr_session_wp_idx');
|
||||||
|
table.foreign('session_id').references('daily_patrol_sessions.session_id').onDelete('CASCADE');
|
||||||
|
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
|
||||||
|
table.foreign('check_item_id').references('patrol_checklist_items.item_id').onDelete('CASCADE');
|
||||||
|
});
|
||||||
|
console.log('✅ patrol_check_records 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 4. 작업장 물품 현황 테이블
|
||||||
|
await knex.schema.createTable('workplace_items', (table) => {
|
||||||
|
table.increments('item_id').primary();
|
||||||
|
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
|
||||||
|
table.integer('patrol_session_id').unsigned().nullable().comment('등록한 순회점검 세션');
|
||||||
|
table.integer('project_id').nullable().comment('관련 프로젝트'); // signed (projects.project_id)
|
||||||
|
table.enum('item_type', ['container', 'plate', 'material', 'tool', 'other']).notNullable().comment('물품 유형');
|
||||||
|
table.string('item_name', 100).nullable().comment('물품명/설명');
|
||||||
|
table.integer('quantity').defaultTo(1).comment('수량');
|
||||||
|
table.decimal('x_percent', 5, 2).nullable().comment('지도상 X 위치 (%)');
|
||||||
|
table.decimal('y_percent', 5, 2).nullable().comment('지도상 Y 위치 (%)');
|
||||||
|
table.decimal('width_percent', 5, 2).nullable().comment('지도상 너비 (%)');
|
||||||
|
table.decimal('height_percent', 5, 2).nullable().comment('지도상 높이 (%)');
|
||||||
|
table.boolean('is_active').defaultTo(true).comment('현재 존재 여부');
|
||||||
|
table.integer('created_by').notNullable().comment('등록자 user_id'); // signed (users.user_id)
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.integer('updated_by').nullable().comment('최종 수정자 user_id'); // signed (users.user_id)
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['workplace_id', 'is_active']);
|
||||||
|
table.index('project_id');
|
||||||
|
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
|
||||||
|
table.foreign('patrol_session_id').references('daily_patrol_sessions.session_id').onDelete('SET NULL');
|
||||||
|
table.foreign('project_id').references('projects.project_id').onDelete('SET NULL');
|
||||||
|
table.foreign('created_by').references('users.user_id');
|
||||||
|
table.foreign('updated_by').references('users.user_id');
|
||||||
|
});
|
||||||
|
console.log('✅ workplace_items 테이블 생성 완료');
|
||||||
|
|
||||||
|
// 물품 유형 코드 테이블 (선택적 확장용)
|
||||||
|
await knex.schema.createTable('item_types', (table) => {
|
||||||
|
table.string('type_code', 20).primary();
|
||||||
|
table.string('type_name', 50).notNullable().comment('유형명');
|
||||||
|
table.string('icon', 10).nullable().comment('아이콘 이모지');
|
||||||
|
table.string('color', 20).nullable().comment('표시 색상');
|
||||||
|
table.integer('display_order').defaultTo(0);
|
||||||
|
table.boolean('is_active').defaultTo(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('item_types').insert([
|
||||||
|
{ type_code: 'container', type_name: '용기', icon: '📦', color: '#3b82f6', display_order: 1 },
|
||||||
|
{ type_code: 'plate', type_name: '플레이트', icon: '🔲', color: '#10b981', display_order: 2 },
|
||||||
|
{ type_code: 'material', type_name: '자재', icon: '🧱', color: '#f59e0b', display_order: 3 },
|
||||||
|
{ type_code: 'tool', type_name: '공구/장비', icon: '🔧', color: '#8b5cf6', display_order: 4 },
|
||||||
|
{ type_code: 'other', type_name: '기타', icon: '📍', color: '#6b7280', display_order: 5 },
|
||||||
|
]);
|
||||||
|
console.log('✅ item_types 테이블 생성 및 초기 데이터 완료');
|
||||||
|
|
||||||
|
console.log('✅ 모든 일일순회점검 시스템 테이블 생성 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('⏳ 일일순회점검 시스템 테이블 제거 중...');
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists('item_types');
|
||||||
|
await knex.schema.dropTableIfExists('workplace_items');
|
||||||
|
await knex.schema.dropTableIfExists('patrol_check_records');
|
||||||
|
await knex.schema.dropTableIfExists('daily_patrol_sessions');
|
||||||
|
await knex.schema.dropTableIfExists('patrol_checklist_items');
|
||||||
|
|
||||||
|
console.log('✅ 모든 일일순회점검 시스템 테이블 제거 완료');
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 설비 테이블 컬럼 추가 (phpMyAdmin용)
|
||||||
|
-- 현재 구조: equipment_id, factory_id, equipment_name, model, status, purchase_date, description, created_at, updated_at
|
||||||
|
|
||||||
|
-- 필요한 컬럼 추가
|
||||||
|
ALTER TABLE equipments ADD COLUMN equipment_code VARCHAR(50) NULL COMMENT '관리번호' AFTER equipment_id;
|
||||||
|
ALTER TABLE equipments ADD COLUMN specifications TEXT NULL COMMENT '규격' AFTER model;
|
||||||
|
ALTER TABLE equipments ADD COLUMN serial_number VARCHAR(100) NULL COMMENT '시리얼번호(S/N)' AFTER specifications;
|
||||||
|
ALTER TABLE equipments ADD COLUMN supplier VARCHAR(100) NULL COMMENT '구입처' AFTER purchase_date;
|
||||||
|
ALTER TABLE equipments ADD COLUMN purchase_price DECIMAL(15, 0) NULL COMMENT '구입가격' AFTER supplier;
|
||||||
|
ALTER TABLE equipments ADD COLUMN manufacturer VARCHAR(100) NULL COMMENT '제조사(메이커)' AFTER purchase_price;
|
||||||
|
|
||||||
|
-- equipment_code에 유니크 인덱스 추가
|
||||||
|
ALTER TABLE equipments ADD UNIQUE INDEX idx_equipment_code (equipment_code);
|
||||||
138
api.hyungi.net/db/migrations/20260204_equipment_full_setup.sql
Normal file
138
api.hyungi.net/db/migrations/20260204_equipment_full_setup.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
-- 설비 관리 전체 설정 스크립트
|
||||||
|
-- 1. 새 컬럼 추가 (supplier, purchase_price)
|
||||||
|
-- 2. 65개 설비 데이터 입력
|
||||||
|
--
|
||||||
|
-- 실행: mysql -u [user] -p [database] < 20260204_equipment_full_setup.sql
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 1: 새 컬럼 추가
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 컬럼이 이미 존재하는지 확인 후 추가
|
||||||
|
SET @dbname = DATABASE();
|
||||||
|
SET @tablename = 'equipments';
|
||||||
|
|
||||||
|
-- supplier 컬럼 추가
|
||||||
|
SELECT COUNT(*) INTO @col_exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = @dbname
|
||||||
|
AND table_name = @tablename
|
||||||
|
AND column_name = 'supplier';
|
||||||
|
|
||||||
|
SET @sql = IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE equipments ADD COLUMN supplier VARCHAR(100) NULL COMMENT ''구입처'' AFTER manufacturer',
|
||||||
|
'SELECT ''supplier column already exists''');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- purchase_price 컬럼 추가
|
||||||
|
SELECT COUNT(*) INTO @col_exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = @dbname
|
||||||
|
AND table_name = @tablename
|
||||||
|
AND column_name = 'purchase_price';
|
||||||
|
|
||||||
|
SET @sql = IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE equipments ADD COLUMN purchase_price DECIMAL(15, 0) NULL COMMENT ''구입가격'' AFTER supplier',
|
||||||
|
'SELECT ''purchase_price column already exists''');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SELECT '컬럼 추가 완료' AS status;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 2: 기존 데이터 삭제 (선택사항)
|
||||||
|
-- ============================================
|
||||||
|
-- 주의: 기존 데이터가 있으면 삭제됩니다
|
||||||
|
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 3: 65개 설비 데이터 입력
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
INSERT INTO equipments (equipment_code, equipment_name, model_name, specifications, serial_number, installation_date, supplier, purchase_price, manufacturer, status) VALUES
|
||||||
|
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
|
||||||
|
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
|
||||||
|
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
|
||||||
|
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
|
||||||
|
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
|
||||||
|
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
|
||||||
|
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
|
||||||
|
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
|
||||||
|
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
|
||||||
|
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
|
||||||
|
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
|
||||||
|
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
|
||||||
|
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
|
||||||
|
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
|
||||||
|
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
|
||||||
|
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
|
||||||
|
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
|
||||||
|
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
|
||||||
|
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
|
||||||
|
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
|
||||||
|
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
|
||||||
|
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
|
||||||
|
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
|
||||||
|
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
|
||||||
|
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
|
||||||
|
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 4: 결과 확인
|
||||||
|
-- ============================================
|
||||||
|
SELECT '===== 설비 데이터 입력 완료 =====' AS status;
|
||||||
|
SELECT COUNT(*) AS total_equipments FROM equipments;
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN purchase_price IS NOT NULL THEN purchase_price ELSE 0 END) AS total_purchase_value,
|
||||||
|
COUNT(CASE WHEN purchase_price IS NOT NULL THEN 1 END) AS equipments_with_price
|
||||||
|
FROM equipments;
|
||||||
|
|
||||||
|
-- 최신 10개 설비 확인
|
||||||
|
SELECT equipment_code, equipment_name, supplier,
|
||||||
|
FORMAT(purchase_price, 0) AS purchase_price_formatted,
|
||||||
|
manufacturer
|
||||||
|
FROM equipments
|
||||||
|
ORDER BY equipment_code DESC
|
||||||
|
LIMIT 10;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- 설비 데이터 입력 (실제 테이블 구조에 맞춤)
|
||||||
|
-- 먼저 20260204_equipment_add_columns.sql 실행 후 이 파일 실행
|
||||||
|
|
||||||
|
-- 기존 TKP 데이터 삭제
|
||||||
|
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
|
||||||
|
|
||||||
|
-- 65개 설비 데이터 입력
|
||||||
|
INSERT INTO equipments (equipment_code, equipment_name, model, specifications, serial_number, purchase_date, supplier, purchase_price, manufacturer, status) VALUES
|
||||||
|
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
|
||||||
|
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
|
||||||
|
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
|
||||||
|
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
|
||||||
|
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
|
||||||
|
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
|
||||||
|
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
|
||||||
|
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
|
||||||
|
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
|
||||||
|
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
|
||||||
|
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
|
||||||
|
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
|
||||||
|
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
|
||||||
|
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
|
||||||
|
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
|
||||||
|
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
|
||||||
|
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
|
||||||
|
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
|
||||||
|
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
|
||||||
|
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
|
||||||
|
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
|
||||||
|
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
|
||||||
|
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
|
||||||
|
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
|
||||||
|
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
|
||||||
|
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');
|
||||||
78
api.hyungi.net/db/migrations/20260204_equipment_simple.sql
Normal file
78
api.hyungi.net/db/migrations/20260204_equipment_simple.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- 설비 관리 설정 (phpMyAdmin용 단순 버전)
|
||||||
|
-- phpMyAdmin에서 가져오기로 실행
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 2: 기존 TKP 데이터 삭제
|
||||||
|
-- ============================================
|
||||||
|
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 3: 65개 설비 데이터 입력
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
INSERT INTO equipments (equipment_code, equipment_name, model_name, specifications, serial_number, installation_date, supplier, purchase_price, manufacturer, status) VALUES
|
||||||
|
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
|
||||||
|
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
|
||||||
|
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
|
||||||
|
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
|
||||||
|
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
|
||||||
|
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
|
||||||
|
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
|
||||||
|
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
|
||||||
|
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
|
||||||
|
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
|
||||||
|
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
|
||||||
|
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
|
||||||
|
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
|
||||||
|
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
|
||||||
|
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
|
||||||
|
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
|
||||||
|
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
|
||||||
|
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
|
||||||
|
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
|
||||||
|
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
|
||||||
|
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
|
||||||
|
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
|
||||||
|
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
|
||||||
|
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
|
||||||
|
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
|
||||||
|
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
|
||||||
|
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
|
||||||
|
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
|
||||||
|
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
|
||||||
|
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
|
||||||
|
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
|
||||||
|
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
|
||||||
|
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
|
||||||
|
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
|
||||||
|
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
|
||||||
|
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user