Compare commits
158 Commits
7abf62620b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312369d9ac | ||
|
|
9d2179e47a | ||
|
|
ba9ef32808 | ||
|
|
bbffa47a9d | ||
|
|
bf0d7fd87a | ||
|
|
56f626911a | ||
|
|
178155df6b | ||
|
|
d49aa01bd5 | ||
|
|
f28922a3ae | ||
|
|
7db072ed14 | ||
|
|
0de9d5bb48 | ||
|
|
de6d918d42 | ||
|
|
1cfd4da8ba | ||
|
|
46a1f8310d | ||
|
|
28a5924e76 | ||
|
|
d3487fd8fd | ||
|
|
48e3b58865 | ||
|
|
697af50963 | ||
|
|
b70904a4de | ||
|
|
cc69b452ab | ||
|
|
05d9e90c39 | ||
|
|
72e4a8b277 | ||
|
|
3f870b247d | ||
|
|
4063eba5bb | ||
|
|
118dc29c95 | ||
|
|
52e6ec16f8 | ||
|
|
cbddf5a7a4 | ||
|
|
6e3c5d6748 | ||
|
|
35b140aa38 | ||
|
|
7cf0614e3b | ||
|
|
ca86dc316c | ||
|
|
02e9f8c0ab | ||
|
|
30222df0ef | ||
|
|
708beb7fe5 | ||
|
|
bf11ccebf5 | ||
|
|
59242177d6 | ||
|
|
7e78f66838 | ||
|
|
9f181644f9 | ||
|
|
6cd613c071 | ||
|
|
ba2e3481e9 | ||
|
|
58b756d973 | ||
|
|
e9ece8c6f1 | ||
|
|
39c333bb39 | ||
|
|
bc92b0d5b0 | ||
|
|
9efb8c881a | ||
|
|
661523e963 | ||
|
|
7c1369a1be | ||
|
|
2c032bd9ea | ||
|
|
2308499668 | ||
|
|
f09c86ee01 | ||
|
|
766cb90e8f | ||
|
|
5832755475 | ||
|
|
41bb755181 | ||
|
|
5e22ff75e7 | ||
|
|
dcd40e692f | ||
|
|
798cc38945 | ||
|
|
cf75462380 | ||
|
|
0cc37d7773 | ||
|
|
a5f96dfe17 | ||
|
|
fdd28d63b2 | ||
|
|
c9249da944 | ||
|
|
80eb018caa | ||
|
|
4309d308bc | ||
|
|
4bb4fbd225 | ||
|
|
10fd65ba9e | ||
|
|
65e5530a6a | ||
|
|
1340918f8e | ||
|
|
798ccc62ad | ||
|
|
0ebe6e5a31 | ||
|
|
f7adbabb0f | ||
|
|
617b6f5c6f | ||
|
|
ca09f89cda | ||
|
|
c37ca24788 | ||
|
|
242dca83b5 | ||
|
|
b855ac973a | ||
|
|
c71286b52b | ||
|
|
7ccec81615 | ||
|
|
f68c66e696 | ||
|
|
77b66f49ae | ||
|
|
3cc38791c8 | ||
|
|
b8d3a516e1 | ||
|
|
972fc07f8d | ||
|
|
ab9e5a46cc | ||
|
|
1c47505b0d | ||
|
|
71132a1e8d | ||
|
|
f728f84117 | ||
|
|
5054398f4f | ||
|
|
755e4142e1 | ||
|
|
492843342a | ||
|
|
c9524d9958 | ||
|
|
b9e3b868bd | ||
|
|
df688879a4 | ||
|
|
76e4224b32 | ||
|
|
01f27948e4 | ||
|
|
d45466ad77 | ||
|
|
f3b7f1a34f | ||
|
|
1980c83377 | ||
|
|
f58dd115c9 | ||
|
|
408bf1af62 | ||
|
|
ec7699b270 | ||
|
|
0c8801849c | ||
|
|
d96a75adc2 | ||
|
|
9cbf4c98a5 | ||
|
|
8016237038 | ||
|
|
d16b2f68ba | ||
|
|
d7408ce603 | ||
|
|
9bd3888738 | ||
|
|
9528a544c6 | ||
|
|
c615d0f121 | ||
|
|
6a721258b8 | ||
|
|
53596ba540 | ||
|
|
b67e8f2c9f | ||
|
|
b2ce691ef9 | ||
|
|
a30482ec34 | ||
|
|
71ef40c26c | ||
|
|
5dee4fd600 | ||
|
|
3c611daa29 | ||
|
|
666f0f2df4 | ||
|
|
2357744b02 | ||
|
|
4dd39ceab7 | ||
|
|
d3cef659ce | ||
|
|
5ac7af7b04 | ||
|
|
f434b4d66f | ||
|
|
517fef46a9 | ||
|
|
31adc39d89 | ||
|
|
e3b7626e07 | ||
|
|
65b2bbe552 | ||
|
|
46cd98c6ea | ||
|
|
eb9266d83a | ||
|
|
6b584f9881 | ||
|
|
0afe864ba3 | ||
|
|
913ab2fcfd | ||
|
|
60b2fd1b8d | ||
|
|
1fd6253fbc | ||
|
|
295928c725 | ||
|
|
7aaac1e334 | ||
|
|
672a7039df | ||
|
|
8683787a01 | ||
|
|
658474af71 | ||
|
|
1040adee10 | ||
|
|
822c654ce5 | ||
|
|
eea99359b5 | ||
|
|
549e78ba61 | ||
|
|
c769fa040d | ||
|
|
afb63e4e94 | ||
|
|
4d783e47c9 | ||
|
|
07a6253692 | ||
|
|
b7771f8232 | ||
|
|
943ed63d77 | ||
|
|
6411eab210 | ||
|
|
66676ac923 | ||
|
|
02e39f1102 | ||
|
|
ac2a2e7eed | ||
|
|
ea6f7c3013 | ||
|
|
ce47865890 | ||
|
|
5cae2362cc | ||
|
|
1ceeef2a65 | ||
|
|
d6dd03a52f |
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB 접속 (NAS)
|
# DB 접속 (NAS)
|
||||||
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi"
|
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p\"\$MYSQL_PASSWORD\" hyungi"
|
||||||
|
|
||||||
# 로그 확인 (NAS)
|
# 로그 확인 (NAS)
|
||||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"
|
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"
|
||||||
|
|||||||
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Sprint 004: 월간 근무 비교·확인·정산
|
||||||
|
|
||||||
|
> 작성: Cowork | 초안: 2026-03-30 | 갱신: 2026-03-31 (출근부 양식 반영)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
1. **작업보고서 vs 근태관리 비교 페이지**: 그룹장이 입력한 작업보고서와 근태관리 데이터를 일별로 비교하여 불일치를 시각화
|
||||||
|
2. **월말 확인 프로세스**: 작업자가 자신의 월간 근무 내역을 확인(승인/반려)하는 워크플로우
|
||||||
|
3. **출근부 엑셀 다운로드**: 전원 확인 완료 시, 업로드된 양식에 맞는 출근부 엑셀 내보내기
|
||||||
|
4. **연차 잔액 연동**: sp_vacation_balances 기반 신규/사용/잔여 데이터 엑셀 포함
|
||||||
|
|
||||||
|
## 현재 구현 상태 (2026-03-31)
|
||||||
|
|
||||||
|
| 항목 | 파일 | 상태 | 비고 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| DB 마이그레이션 | `api/db/migrations/20260330_create_monthly_work_confirmations.sql` | ✅ 완료 | |
|
||||||
|
| 모델 | `api/models/monthlyComparisonModel.js` (232줄) | ✅ 완료 | getExportData 추가됨 |
|
||||||
|
| 컨트롤러 | `api/controllers/monthlyComparisonController.js` (560줄) | ✅ 완료 | exportExcel 출근부 양식 재작성 |
|
||||||
|
| 라우트 | `api/routes/monthlyComparisonRoutes.js` (33줄) | ✅ 완료 | |
|
||||||
|
| 라우트 등록 | `api/routes.js` 157줄 | ✅ 완료 | |
|
||||||
|
| 프론트엔드 HTML | `web/pages/attendance/monthly-comparison.html` | ✅ 완료 | |
|
||||||
|
| 프론트엔드 JS | `web/js/monthly-comparison.js` (558줄) | ✅ 완료 | |
|
||||||
|
| 프론트엔드 CSS | `web/css/monthly-comparison.css` | ✅ 완료 | |
|
||||||
|
| 대시보드 연동 | `web/js/production-dashboard.js` | ✅ 완료 | PAGE_ICONS 추가 |
|
||||||
|
|
||||||
|
> 모든 파일이 존재하고 라우트가 등록되어 있으나, **실 서버 배포 및 통합 테스트 미완료**.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- monthly.html에서 그룹장이 등록한 근태정보를 각 작업자가 개별 확인·승인
|
||||||
|
- 전원 confirmed 완료 시 지원팀이 출근부 엑셀 다운로드 가능
|
||||||
|
- 출근부 양식: 업로드된 `출근부_2026.02_TK 생산팀.xlsx` 기준
|
||||||
|
- attendanceService.js에서 연차 자동 차감/복원이 이미 구현되어 있으므로 sp_vacation_balances가 소스 오브 트루스
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly.html (그룹장 → 근태 입력) │
|
||||||
|
│ ↓ │
|
||||||
|
│ daily_attendance_records 저장 │
|
||||||
|
│ attendanceService.js → sp_vacation_balances 자동 반영 │
|
||||||
|
└─────────────────┬────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly-comparison.html │
|
||||||
|
│ │
|
||||||
|
│ [작업자 뷰] [관리자 뷰] │
|
||||||
|
│ ├ 작업보고서 vs 근태 일별 비교 ├ 전체 확인 현황 │
|
||||||
|
│ ├ 요약 카드 (근무일/시간/연장/휴가) ├ 개별 작업자 상세 │
|
||||||
|
│ └ 확인(승인) / 반려(사유 입력) └ 엑셀 다운로드 │
|
||||||
|
│ ↓ (반려 시) │
|
||||||
|
│ notifications → 지원팀에 알림 │
|
||||||
|
│ ↓ (전원 확인 완료 시) │
|
||||||
|
│ 출근부 엑셀 다운로드 │
|
||||||
|
│ (출근부_YYYY.MM_부서명.xlsx) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출근부 엑셀 양식 (신규)
|
||||||
|
|
||||||
|
기존 2-시트 구조(월간 근무 현황 + 일별 상세)를 **출근부 단일 시트**로 변경.
|
||||||
|
|
||||||
|
```
|
||||||
|
시트명: {YY}.{M}월 출근부
|
||||||
|
|
||||||
|
Row 2: 부서명 | [부서명 (병합)]
|
||||||
|
Row 3: 근로기간 | [YYYY년 M월 (병합)]
|
||||||
|
Row 4: [이름] [담당] 1 2 3 ... 31 [총시간] [M월(병합)] [비고(병합)]
|
||||||
|
Row 5: 일 월 화 ... [ ] 신규 사용 잔여
|
||||||
|
Row 6+: 김두수 1 0 0 0 ... 휴무 =SUM() 15 2 =AF-AG
|
||||||
|
|
||||||
|
셀 값 규칙:
|
||||||
|
정상출근 → 근무시간(숫자, 0.00)
|
||||||
|
주말(근무없음) → '휴무'
|
||||||
|
연차(ANNUAL) → '연차'
|
||||||
|
반차(HALF_ANNUAL) → '반차'
|
||||||
|
반반차(ANNUAL_QUARTER) → '반반차'
|
||||||
|
조퇴(EARLY_LEAVE) → '조퇴'
|
||||||
|
병가(SICK) → '병가'
|
||||||
|
경조사(SPECIAL) → '경조사'
|
||||||
|
|
||||||
|
스타일:
|
||||||
|
폰트: 맑은 고딕 12pt, 가운데 정렬
|
||||||
|
주말 헤더: 빨간 폰트 (FFFF0000)
|
||||||
|
테두리: thin 전체
|
||||||
|
헤더 행 높이: 40, 데이터 행 높이: 60
|
||||||
|
수식: 총시간=SUM(날짜열), 잔여=신규-사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
| Method | Path | 접근권한 | 설명 |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| GET | /api/monthly-comparison/my-records | 인증된 사용자 | 내 일별 비교 데이터 |
|
||||||
|
| GET | /api/monthly-comparison/records | support_team+ 또는 본인 | 특정 작업자 비교 조회 |
|
||||||
|
| POST | /api/monthly-comparison/confirm | 인증된 사용자 (본인) | 승인/반려 |
|
||||||
|
| GET | /api/monthly-comparison/all-status | support_team+ | 전체 확인 현황 |
|
||||||
|
| GET | /api/monthly-comparison/export | support_team+ | 출근부 엑셀 다운로드 |
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
| 데이터 | 테이블 | 비고 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 작업보고서 | daily_work_reports | report_date, work_hours, project_id |
|
||||||
|
| 근태 기록 | daily_attendance_records | record_date, total_work_hours, vacation_type_id |
|
||||||
|
| 확인 상태 | monthly_work_confirmations | 신규 테이블 (Sprint 004) |
|
||||||
|
| 연차 잔액 | sp_vacation_balances | year별 SUM(total_days, used_days) |
|
||||||
|
| 휴가 유형 | vacation_types | type_code → 출근부 텍스트 매핑 |
|
||||||
|
| 작업자 정보 | workers + departments | worker_id 순 정렬 |
|
||||||
|
|
||||||
|
## 작업 분할
|
||||||
|
|
||||||
|
| 섹션 | 핵심 작업 | 규모 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **A (Backend)** | 모델·컨트롤러 검증 + 출근부 엑셀 엣지케이스 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
| **B (Frontend)** | UI 최종 검증 + 모바일 반응형 + 캐시 버스팅 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
|
||||||
|
> 기존 코드가 모두 구현되어 있으므로 **검증·배포 중심**으로 진행.
|
||||||
|
|
||||||
|
## 섹션 간 의존성
|
||||||
|
|
||||||
|
```
|
||||||
|
A ──(API 제공)──→ B
|
||||||
|
|
||||||
|
GET /api/monthly-comparison/my-records
|
||||||
|
POST /api/monthly-comparison/confirm
|
||||||
|
GET /api/monthly-comparison/all-status
|
||||||
|
GET /api/monthly-comparison/records
|
||||||
|
GET /api/monthly-comparison/export
|
||||||
|
```
|
||||||
|
|
||||||
|
Section A 배포 완료 후 Section B 프론트엔드가 정상 동작 가능.
|
||||||
|
|
||||||
|
## 완료 조건
|
||||||
|
- [x] 마이그레이션 파일 생성 (20260330_create_monthly_work_confirmations.sql)
|
||||||
|
- [x] 모든 Backend 파일 생성 (model, controller, routes)
|
||||||
|
- [x] routes.js에 라우트 등록
|
||||||
|
- [x] 프론트엔드 페이지 생성 (HTML, JS, CSS)
|
||||||
|
- [x] 대시보드 아이콘 매핑 추가
|
||||||
|
- [x] 출근부 양식 엑셀 재작성 (getExportData + exportExcel)
|
||||||
|
- [ ] 실 서버 마이그레이션 실행
|
||||||
|
- [ ] Docker 빌드·배포
|
||||||
|
- [ ] 통합 테스트 (작업자 확인 → 지원팀 엑셀 다운로드 전체 플로우)
|
||||||
|
- [ ] 모바일 반응형 확인
|
||||||
|
- [ ] 엣지케이스 확인 (데이터 없는 월, 중도 입사자 등)
|
||||||
|
|
||||||
|
## 주요 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-03-30 | 초안 작성 + Section A/B 코드 구현 |
|
||||||
|
| 2026-03-31 | 출근부 양식 반영: getExcelData→getExportData, exportExcel 전면 재작성 |
|
||||||
|
| 2026-03-31 | 버그 수정: export 조건에 rejected 추가, admin detail 모드 버튼 숨김 |
|
||||||
|
| 2026-04-01 | 워크플로우 확장: pending→review_sent→confirmed/change_request/rejected 상태 추가 |
|
||||||
|
| 2026-04-01 | 정책 변경: 확인요청 발송 전 전원 admin_checked 필수 (선택적→필수) |
|
||||||
12
.env.example
12
.env.example
@@ -99,4 +99,16 @@ OLLAMA_TIMEOUT=120
|
|||||||
# tkfb.technicalkorea.net → http://tk-gateway:80
|
# tkfb.technicalkorea.net → http://tk-gateway:80
|
||||||
# tkreport.technicalkorea.net → http://tk-system2-web:80
|
# tkreport.technicalkorea.net → http://tk-system2-web:80
|
||||||
# tkqc.technicalkorea.net → http://tk-system3-web:80
|
# tkqc.technicalkorea.net → http://tk-system3-web:80
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# ntfy 푸시 알림 서버
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
NTFY_BASE_URL=http://ntfy:80
|
||||||
|
NTFY_PUBLISH_TOKEN=change_this_ntfy_publish_token
|
||||||
|
NTFY_EXTERNAL_URL=https://ntfy.technicalkorea.net
|
||||||
|
NTFY_SUB_PASSWORD=change_this_ntfy_subscriber_password
|
||||||
|
TKFB_BASE_URL=https://tkfb.technicalkorea.net
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Cloudflare Tunnel
|
||||||
|
# -------------------------------------------------------------------
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here
|
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here
|
||||||
|
|||||||
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# pre-commit hook — 로컬 빠른 피드백
|
||||||
|
# 역할: 커밋 전 보안 검사 (staged 파일만)
|
||||||
|
# 우회: git commit --no-verify (서버 pre-receive에서 최종 차단됨)
|
||||||
|
exec "$(git rev-parse --show-toplevel)/scripts/security-scan.sh" --staged
|
||||||
168
.githooks/pre-receive-server.sh
Executable file
168
.githooks/pre-receive-server.sh
Executable file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# pre-receive-server.sh — Gitea 서버용 보안 게이트
|
||||||
|
# =============================================================================
|
||||||
|
# 설치: Gitea 웹 관리자 → 저장소 → Settings → Git Hooks → pre-receive
|
||||||
|
# 또는: cp pre-receive-server.sh $REPO_PATH/custom/hooks/pre-receive
|
||||||
|
#
|
||||||
|
# 동작: push 시 변경 내용을 regex 검사, 위반 시 push 차단
|
||||||
|
# bypass: 커밋 메시지에 [SECURITY-BYPASS: 사유] 포함 시 통과 + 로그
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# --- 설정 ---
|
||||||
|
BYPASS_LOG="/data/gitea/security-bypass.log"
|
||||||
|
ALLOWED_BYPASS_EMAILS="ahn@hyungi.net hyungi@technicalkorea.net"
|
||||||
|
MEDIUM_THRESHOLD=5
|
||||||
|
ZERO_REV="0000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
# --- 검출 규칙 (security-scan.sh와 동일, 자체 내장) ---
|
||||||
|
RULES=(
|
||||||
|
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
|
||||||
|
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
|
||||||
|
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
|
||||||
|
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
|
||||||
|
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
|
||||||
|
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
|
||||||
|
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
|
||||||
|
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDE_PATTERNS="node_modules|\.git|__pycache__|package-lock\.json|\.min\.js|\.min\.css"
|
||||||
|
|
||||||
|
# --- 메인 ---
|
||||||
|
while read -r oldrev newrev refname; do
|
||||||
|
# 브랜치 삭제 시 스킵
|
||||||
|
if [[ "$newrev" == "$ZERO_REV" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 신규 브랜치
|
||||||
|
if [[ "$oldrev" == "$ZERO_REV" ]]; then
|
||||||
|
# 첫 push: 최근 커밋만 검사 (또는 스킵)
|
||||||
|
echo "[SECURITY] New branch detected — scanning latest commit only"
|
||||||
|
oldrev=$(git rev-parse "${newrev}~1" 2>/dev/null || echo "$ZERO_REV")
|
||||||
|
if [[ "$oldrev" == "$ZERO_REV" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- bypass 확인 ---
|
||||||
|
BYPASS_FOUND=false
|
||||||
|
BYPASS_REASON=""
|
||||||
|
while IFS= read -r msg; do
|
||||||
|
if echo "$msg" | grep -qP '\[SECURITY-BYPASS:\s*.+\]'; then
|
||||||
|
BYPASS_FOUND=true
|
||||||
|
BYPASS_REASON=$(echo "$msg" | grep -oP '\[SECURITY-BYPASS:\s*\K[^\]]+')
|
||||||
|
elif echo "$msg" | grep -q '\[SECURITY-BYPASS\]'; then
|
||||||
|
echo "[SECURITY] ERROR: Bypass requires reason: [SECURITY-BYPASS: hotfix 사유]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < <(git log --format='%s' "$oldrev".."$newrev" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ "$BYPASS_FOUND" == "true" ]]; then
|
||||||
|
AUTHOR=$(git log -1 --format='%ae' "$newrev" 2>/dev/null || echo "unknown")
|
||||||
|
# 사용자 제한
|
||||||
|
ALLOWED=false
|
||||||
|
for email in $ALLOWED_BYPASS_EMAILS; do
|
||||||
|
if [[ "$AUTHOR" == "$email" ]]; then
|
||||||
|
ALLOWED=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$ALLOWED" != "true" ]]; then
|
||||||
|
echo "[SECURITY] Bypass not allowed for: $AUTHOR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# 로그 기록
|
||||||
|
echo "$(date -Iseconds) | user=$AUTHOR | ref=$refname | commits=$oldrev..$newrev | reason=$BYPASS_REASON | TODO=24h내 수정 필수" \
|
||||||
|
>> "$BYPASS_LOG" 2>/dev/null || true
|
||||||
|
echo "[SECURITY] ⚠ Bypass accepted — reason: $BYPASS_REASON (logged, 24h 내 수정 필수)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- diff 기반 보안 검사 ---
|
||||||
|
VIOLATIONS=0
|
||||||
|
MEDIUM_COUNT=0
|
||||||
|
OUTPUT=""
|
||||||
|
|
||||||
|
DIFF_OUTPUT=$(git diff -U0 --diff-filter=ACMRT "$oldrev" "$newrev" 2>/dev/null || true)
|
||||||
|
if [[ -z "$DIFF_OUTPUT" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_FILE=""
|
||||||
|
CURRENT_LINE=0
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
|
||||||
|
CURRENT_FILE="${BASH_REMATCH[2]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
|
||||||
|
CURRENT_FILE="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
|
||||||
|
CURRENT_LINE="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
|
||||||
|
local_content="${line:1}"
|
||||||
|
|
||||||
|
# 제외 패턴
|
||||||
|
if echo "$CURRENT_FILE" | grep -qEi "$EXCLUDE_PATTERNS" 2>/dev/null; then
|
||||||
|
CURRENT_LINE=$((CURRENT_LINE + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 인라인 ignore 체크 + 규칙 검사
|
||||||
|
for i in "${!RULES[@]}"; do
|
||||||
|
IFS='|' read -r r_name r_sev r_desc r_pat <<< "${RULES[$i]}"
|
||||||
|
if echo "$local_content" | grep -qP "$r_pat" 2>/dev/null; then
|
||||||
|
# 라인 단위 ignore
|
||||||
|
if echo "$local_content" | grep -qP "security-ignore:\s*$r_name" 2>/dev/null; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
RNUM=$((i + 1))
|
||||||
|
TRIMMED=$(echo "$local_content" | sed 's/^[[:space:]]*//' | head -c 100)
|
||||||
|
if [[ "$r_sev" == "CRITICAL" || "$r_sev" == "HIGH" ]]; then
|
||||||
|
OUTPUT+="$(printf "\n ✗ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
|
||||||
|
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
else
|
||||||
|
OUTPUT+="$(printf "\n ⚠ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
|
||||||
|
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
|
||||||
|
MEDIUM_COUNT=$((MEDIUM_COUNT + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
CURRENT_LINE=$((CURRENT_LINE + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$DIFF_OUTPUT"
|
||||||
|
|
||||||
|
TOTAL=$((VIOLATIONS + MEDIUM_COUNT))
|
||||||
|
if [[ $TOTAL -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "[SECURITY] $TOTAL issue(s) found in push to $refname:"
|
||||||
|
echo "$OUTPUT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $MEDIUM_COUNT -gt $MEDIUM_THRESHOLD ]]; then
|
||||||
|
echo "[SECURITY] MEDIUM violations ($MEDIUM_COUNT) exceed threshold ($MEDIUM_THRESHOLD) — blocking"
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $VIOLATIONS -gt 0 ]]; then
|
||||||
|
echo "Push rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message."
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "Warnings only ($MEDIUM_COUNT MEDIUM) — push allowed."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
24
.securityignore
Normal file
24
.securityignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# .securityignore — 보안 스캔 제외 목록
|
||||||
|
# =============================================================================
|
||||||
|
# 규칙:
|
||||||
|
# - 모든 항목에 사유 주석 필수 (없으면 경고)
|
||||||
|
# - 월 1회 정기 검토 → 불필요 항목 제거
|
||||||
|
# - 날짜 표기 권장
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 스캔 스크립트 자체 (규칙 패턴 포함)
|
||||||
|
scripts/security-scan.sh # 규칙 정의 자체 (2026-04-10)
|
||||||
|
.githooks/pre-receive-server.sh # 규칙 정의 자체 (2026-04-10)
|
||||||
|
|
||||||
|
# 환경변수 템플릿 (placeholder만 포함)
|
||||||
|
.env.example # placeholder 값만 (2026-04-10)
|
||||||
|
|
||||||
|
# 보안 감사 보고서 (발견된 패턴 인용)
|
||||||
|
SECURITY-AUDIT-20260402.md # 감사 보고서 인용 (2026-04-10)
|
||||||
|
SECURITY-FINDINGS-SUMMARY.txt # 감사 요약 인용 (2026-04-10)
|
||||||
|
SECURITY-CODE-SNIPPETS.md # 코드 스니펫 인용 (2026-04-10)
|
||||||
|
|
||||||
|
# 보안 가이드/체크리스트 (규칙 예시 포함)
|
||||||
|
SECURITY-CHECKLIST.md # 규칙 참조 예시 (2026-04-10)
|
||||||
|
docs/SECURITY-GUIDE.md # 가이드 예시 코드 (2026-04-10)
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
[Cloudflare Tunnel] → tk-cloudflared
|
[Cloudflare Tunnel] → tk-cloudflared
|
||||||
├── tkds.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
|
├── tkfb.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
|
||||||
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
|
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
|
||||||
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
|
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
|
||||||
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
|
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
## SSO 인증 흐름
|
## SSO 인증 흐름
|
||||||
|
|
||||||
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
|
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
|
||||||
2. 토큰 없음/만료 → `tkds.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
|
2. 토큰 없음/만료 → `tkfb.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
|
||||||
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
|
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
|
||||||
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
|
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
|
||||||
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
|
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
|
||||||
@@ -76,7 +76,7 @@ Gateway(`/shared/`)에서 서빙:
|
|||||||
|
|
||||||
각 서비스의 core.js에서 동적 로딩:
|
각 서비스의 core.js에서 동적 로딩:
|
||||||
```
|
```
|
||||||
프로덕션: https://tkds.technicalkorea.net/shared/notification-bell.js
|
프로덕션: https://tkfb.technicalkorea.net/shared/notification-bell.js
|
||||||
로컬: http://localhost:30000/shared/notification-bell.js
|
로컬: http://localhost:30000/shared/notification-bell.js
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Gateway(`/shared/`)에서 서빙:
|
|||||||
5. `sso-auth-service/config/`에 새 origin 추가
|
5. `sso-auth-service/config/`에 새 origin 추가
|
||||||
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
|
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
|
||||||
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
|
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
|
||||||
8. 로그인 리다이렉트: `tkds.technicalkorea.net/dashboard?redirect=` 패턴 사용
|
8. 로그인 리다이렉트: `tkfb.technicalkorea.net/dashboard?redirect=` 패턴 사용
|
||||||
|
|
||||||
## 배포 절차
|
## 배포 절차
|
||||||
|
|
||||||
|
|||||||
41
CLAUDE.md
41
CLAUDE.md
@@ -39,3 +39,44 @@ System1에는 FastAPI bridge도 있음 (30008, AI 연동용).
|
|||||||
git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build <서비스명>"
|
git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build <서비스명>"
|
||||||
```
|
```
|
||||||
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
|
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
|
||||||
|
|
||||||
|
## 개발 주의사항 — 실수 기록
|
||||||
|
|
||||||
|
### admin 계정으로만 테스트하지 말 것
|
||||||
|
admin(role=admin/system)은 대부분의 권한 체크를 건너뜀. 반드시 **일반 사용자(role=user) 계정으로 테스트**할 것.
|
||||||
|
- `pagePermission.js`: admin은 L18에서 `return next()`로 미들웨어 전체 스킵
|
||||||
|
- `tkfb-core.js initAuth()`: admin은 `accessibleKeys` 조회 자체를 안 함
|
||||||
|
- **실제 사례 (2026-04-01)**: `getPool()` async 함수에 `await` 누락 → admin만 통과, 일반 사용자 전원 500 에러. admin 계정으로만 테스트하여 배포 전 미발견
|
||||||
|
|
||||||
|
### workers 테이블과 sso_users 테이블 불일치
|
||||||
|
모든 사용자가 `workers` 테이블에 있지 않음 (생산지원팀 등 사무직). department_id 조회 시 반드시 **sso_users fallback** 사용:
|
||||||
|
```sql
|
||||||
|
COALESCE(w.department_id, su.department_id) AS department_id
|
||||||
|
```
|
||||||
|
- `dashboardModel.js getUserInfo()` — 수정 완료
|
||||||
|
- `pageAccessRoutes.js` 부서 조회 — 수정 완료
|
||||||
|
- **신규 코드 작성 시**: workers JOIN 후 department_id 사용하면 동일 버그 재발
|
||||||
|
|
||||||
|
### 전역 스코프 const vs function 충돌
|
||||||
|
`const`로 선언한 변수명과 다른 스크립트 파일의 `function` 선언이 같은 이름이면 `SyntaxError: Identifier already declared` 발생. 해당 스크립트 **전체**가 실행 안 됨.
|
||||||
|
- **실제 사례 (2026-04-01)**: `tkfb-core.js`에 `const escHtml = escapeHtml;` 추가 → 4개 JS 파일에서 `function escHtml()` 재선언 → 모바일 전체 미작동
|
||||||
|
- **규칙**: 전역 alias 추가 시 `grep -r "function 함수명" --include="*.js"` 로 충돌 확인 필수
|
||||||
|
|
||||||
|
### 캐시 버스팅 누락
|
||||||
|
JS/CSS 수정 후 HTML의 `?v=YYYYMMDDNN` 갱신을 빠뜨리면 브라우저 캐시로 구버전 실행됨.
|
||||||
|
- 특히 `tkfb-core.js`는 **35개 HTML**에서 참조 — 일괄 갱신 필수
|
||||||
|
- `shared-bottom-nav.js`는 5개 HTML에서 참조
|
||||||
|
- **규칙**: JS/CSS 수정 시 해당 파일을 참조하는 모든 HTML의 버전 갱신. `grep -r "파일명" --include="*.html"` 로 대상 확인
|
||||||
|
|
||||||
|
### nginx 프록시 경로 우선순위
|
||||||
|
nginx location 블록은 **더 구체적인 경로가 먼저** 매칭됨. `/api/auth/`를 `/api/` 뒤에 넣으면 `/api/`가 먼저 잡힘.
|
||||||
|
- **실제 사례 (2026-04-01)**: `/api/auth/change-password` 요청이 system1-api로 라우팅되어 404. `/api/auth/` location을 `/api/` 앞에 추가하여 해결
|
||||||
|
|
||||||
|
## 멀티 에이전트 워크플로우
|
||||||
|
이 프로젝트는 Cowork(설계/검토) + Claude Code(코딩) 멀티 에이전트 방식을 지원한다.
|
||||||
|
- **워크플로우 가이드**: `.cowork/WORKFLOW-GUIDE.md`
|
||||||
|
- **스프린트 계획/스펙**: `.cowork/sprints/sprint-NNN/`
|
||||||
|
- **에러 기록**: `.cowork/errors/ERROR-LOG.md`
|
||||||
|
- **템플릿**: `.cowork/templates/`
|
||||||
|
|
||||||
|
Claude Code Worker는 자신의 섹션 스펙(section-*.md)만 읽고 작업할 것. 다른 섹션 파일 수정 금지.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ cat "/volume1/Technicalkorea Document/tkfb-package/.env" | grep MYSQL
|
|||||||
cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES
|
cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES
|
||||||
|
|
||||||
# Cloudflare Tunnel 토큰
|
# Cloudflare Tunnel 토큰
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -112,12 +112,12 @@ ssh hyungi@192.168.0.3
|
|||||||
mkdir -p /volume1/docker/backups/$(date +%Y%m%d)
|
mkdir -p /volume1/docker/backups/$(date +%Y%m%d)
|
||||||
|
|
||||||
# MariaDB 백업
|
# MariaDB 백업
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkfb_db \
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkfb_db \
|
||||||
mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \
|
mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \
|
||||||
/volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql
|
/volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql
|
||||||
|
|
||||||
# PostgreSQL 백업
|
# PostgreSQL 백업
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkqc-db \
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkqc-db \
|
||||||
pg_dumpall -U mproject > \
|
pg_dumpall -U mproject > \
|
||||||
/volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql
|
/volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql
|
||||||
|
|
||||||
@@ -167,11 +167,11 @@ rm -rf ../tk-factory-services.bak
|
|||||||
# NAS SSH
|
# NAS SSH
|
||||||
# TK-FB 중지
|
# TK-FB 중지
|
||||||
cd "/volume1/Technicalkorea Document/tkfb-package"
|
cd "/volume1/Technicalkorea Document/tkfb-package"
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
|
||||||
|
|
||||||
# TKQC 중지
|
# TKQC 중지
|
||||||
cd /volume1/docker/tkqc/tkqc-package
|
cd /volume1/docker/tkqc/tkqc-package
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: 통합 서비스 기동
|
### Step 4: 통합 서비스 기동
|
||||||
@@ -180,10 +180,10 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
|
|||||||
cd /volume1/docker/tk-factory-services
|
cd /volume1/docker/tk-factory-services
|
||||||
|
|
||||||
# Docker 이미지 빌드 + 서비스 기동
|
# Docker 이미지 빌드 + 서비스 기동
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up --build -d
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up --build -d
|
||||||
|
|
||||||
# 로그 확인
|
# 로그 확인
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --tail=50
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose logs -f --tail=50
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5: DB 마이그레이션
|
### Step 5: DB 마이그레이션
|
||||||
@@ -196,7 +196,7 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --ta
|
|||||||
# (system3-nonconformance/api/migrations/ → PostgreSQL init)
|
# (system3-nonconformance/api/migrations/ → PostgreSQL init)
|
||||||
|
|
||||||
# 헬스체크 확인
|
# 헬스체크 확인
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose ps
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 6: Cloudflare Tunnel 설정
|
### Step 6: Cloudflare Tunnel 설정
|
||||||
@@ -291,15 +291,15 @@ git log --oneline -10
|
|||||||
```bash
|
```bash
|
||||||
# 통합 서비스 중지
|
# 통합 서비스 중지
|
||||||
cd /volume1/docker_1/tk-factory-services
|
cd /volume1/docker_1/tk-factory-services
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
|
||||||
|
|
||||||
# TK-FB 복원
|
# TK-FB 복원
|
||||||
cd "/volume1/Technicalkorea Document/tkfb-package"
|
cd "/volume1/Technicalkorea Document/tkfb-package"
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
|
||||||
|
|
||||||
# TKQC 복원
|
# TKQC 복원
|
||||||
cd /volume1/docker/tkqc/tkqc-package
|
cd /volume1/docker/tkqc/tkqc-package
|
||||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d
|
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독
|
|||||||
### NAS (192.168.0.3)
|
### NAS (192.168.0.3)
|
||||||
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
|
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
|
||||||
- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
|
- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
|
||||||
- SSH: `hyungi` / `fukdon-riwbaq-fiQfy2`
|
- SSH: `hyungi` / `${SSH_PASSWORD}` (비밀번호는 비밀관리 시스템 참조)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
39
SECURITY-CHECKLIST.md
Normal file
39
SECURITY-CHECKLIST.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 보안 PR 체크리스트 — TK Factory Services
|
||||||
|
|
||||||
|
> 공통 원칙: `claude-config/memory/feedback_security_pr_checklist.md`
|
||||||
|
> 자동 검증: `scripts/security-scan.sh` (pre-commit + pre-receive)
|
||||||
|
|
||||||
|
## 체크리스트
|
||||||
|
|
||||||
|
| # | 카테고리 | 검증 | 확인 항목 | 참조 파일 |
|
||||||
|
|---|---------|------|----------|----------|
|
||||||
|
| 1 | 비밀 정보 | **자동** #1,#2 | 코드/문서에 비밀번호·토큰·API키 하드코딩 없음 | `.env.example` |
|
||||||
|
| 2 | 인증 | 수동 | 모든 라우트에 `requireAuth` 적용 | `shared/middleware/auth.js` |
|
||||||
|
| 3 | 권한 RBAC | 수동 | 쓰기(POST/PUT/DELETE)에 `requirePage()` 또는 `requireRole()` | `shared/middleware/pagePermission.js` |
|
||||||
|
| 4 | 입력 검증 | 수동 | path traversal(`../`), 타입, 길이 검증 | `system1-factory/api/utils/validator.js` |
|
||||||
|
| 5 | 파일 업로드 | 수동 | magic number + 확장자 + MIME + 크기 제한 | `system1-factory/api/utils/fileUploadSecurity.js` |
|
||||||
|
| 6 | 네트워크 | **자동** #5 | CORS 와일드카드 없음, rate limiting 적용 | `system1-factory/api/config/cors.js` |
|
||||||
|
| 7 | DB 쿼리 | **자동** #6 | 파라미터화(`?`), `await`, `COALESCE` 패턴 | CLAUDE.md 주의사항 |
|
||||||
|
| 8 | 에러/로그 | **자동** #7 | 로그에 비밀정보 없음, 스택트레이스 prod 비노출 | `shared/utils/errors.js` |
|
||||||
|
| 9 | 보안 헤더 | 수동 | CSP, HSTS, X-Frame-Options | `system1-factory/api/config/security.js` |
|
||||||
|
| 10 | 자동 검증 | **자동** | pre-commit + pre-receive 통과 | `scripts/security-scan.sh` |
|
||||||
|
|
||||||
|
## 자동 검출 규칙
|
||||||
|
|
||||||
|
| 규칙# | 이름 | 심각도 | 동작 |
|
||||||
|
|-------|------|--------|------|
|
||||||
|
| 1 | SECRET_HARDCODE | CRITICAL | 차단 |
|
||||||
|
| 2 | SECRET_KNOWN | CRITICAL | 차단 |
|
||||||
|
| 3 | LOCALSTORAGE_AUTH | HIGH | 차단 |
|
||||||
|
| 4 | INNERHTML_XSS | HIGH | 차단 |
|
||||||
|
| 5 | CORS_WILDCARD | HIGH | 차단 |
|
||||||
|
| 6 | SQL_INTERPOLATION | HIGH | 차단 |
|
||||||
|
| 7 | LOG_SECRET | MEDIUM | 경고 (5개 초과 시 차단) |
|
||||||
|
| 8 | ENV_HARDCODE | MEDIUM | 경고 (5개 초과 시 차단) |
|
||||||
|
|
||||||
|
## 수동 확인 필요 항목 (자동화 한계)
|
||||||
|
|
||||||
|
- RBAC 설계 오류 / 인증 흐름
|
||||||
|
- 비즈니스 로직 / race condition
|
||||||
|
- third-party dependency 취약점 (`npm audit`)
|
||||||
|
- 환경변수 값 강도
|
||||||
@@ -3,7 +3,9 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
RUN mkdir -p /app/data
|
COPY --chown=appuser:appuser . .
|
||||||
|
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import json
|
|||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_input(text: str, max_length: int = 500) -> str:
|
||||||
|
"""사용자 입력 길이 제한 및 정리"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return str(text)[:max_length].strip()
|
||||||
|
|
||||||
|
|
||||||
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
||||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||||
|
|
||||||
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
||||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||||
|
|
||||||
|
safe_text = sanitize_user_input(user_text)
|
||||||
prompt = f"""카테고리 목록:
|
prompt = f"""카테고리 목록:
|
||||||
{category_context}
|
{category_context}
|
||||||
|
|
||||||
사용자 입력: "{user_text}"
|
사용자 입력:
|
||||||
|
<user_input>{safe_text}</user_input>
|
||||||
|
|
||||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
async def summarize_report(data: dict) -> dict:
|
async def summarize_report(data: dict) -> dict:
|
||||||
"""최종 신고 내용을 요약"""
|
"""최종 신고 내용을 요약"""
|
||||||
prompt = f"""신고 정보:
|
prompt = f"""신고 정보:
|
||||||
- 설명: {data.get('description', '')}
|
<user_input>
|
||||||
- 유형: {data.get('type', '')}
|
- 설명: {sanitize_user_input(data.get('description', ''))}
|
||||||
- 카테고리: {data.get('category', '')}
|
- 유형: {sanitize_user_input(data.get('type', ''))}
|
||||||
- 항목: {data.get('item', '')}
|
- 카테고리: {sanitize_user_input(data.get('category', ''))}
|
||||||
- 위치: {data.get('location', '')}
|
- 항목: {sanitize_user_input(data.get('item', ''))}
|
||||||
- 프로젝트: {data.get('project', '')}
|
- 위치: {sanitize_user_input(data.get('location', ''))}
|
||||||
|
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
|
||||||
|
</user_input>
|
||||||
|
|
||||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- mariadb_data:/var/lib/mysql
|
- mariadb_data:/var/lib/mysql
|
||||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||||
ports:
|
ports:
|
||||||
- "30306:3306"
|
- "127.0.0.1:30306:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||||
timeout: 20s
|
timeout: 20s
|
||||||
@@ -136,7 +136,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "30080:80"
|
- "30080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./system1-factory/web:/usr/share/nginx/html:ro
|
- ./system1-factory/web/public:/usr/share/nginx/html:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
system1-api:
|
system1-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -309,6 +309,7 @@ services:
|
|||||||
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
|
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
|
||||||
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
|
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
|
||||||
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
|
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
|
||||||
|
- NTFY_SUB_PASSWORD=${NTFY_SUB_PASSWORD}
|
||||||
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
|
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||||
@@ -608,7 +609,7 @@ services:
|
|||||||
container_name: tk-phpmyadmin
|
container_name: tk-phpmyadmin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "30880:80"
|
- "127.0.0.1:30880:80"
|
||||||
environment:
|
environment:
|
||||||
- PMA_HOST=mariadb
|
- PMA_HOST=mariadb
|
||||||
- PMA_USER=${PMA_USER:-root}
|
- PMA_USER=${PMA_USER:-root}
|
||||||
|
|||||||
157
docs/SECURITY-GUIDE.md
Normal file
157
docs/SECURITY-GUIDE.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 보안 시스템 운영 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
TK Factory Services에는 2계층 보안 검사 시스템이 적용되어 있습니다.
|
||||||
|
|
||||||
|
| 계층 | 위치 | 역할 | 우회 가능 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| pre-commit | 로컬 (개발자 PC) | 빠른 피드백 | `--no-verify` |
|
||||||
|
| pre-receive | Gitea 서버 | 최종 차단 | `[SECURITY-BYPASS: 사유]`만 |
|
||||||
|
|
||||||
|
## 개발 워크플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
코드 작성 → git add → git commit
|
||||||
|
↓
|
||||||
|
pre-commit hook
|
||||||
|
(security-scan.sh --staged)
|
||||||
|
↓
|
||||||
|
위반 있으면 → 커밋 차단 + 상세 출력
|
||||||
|
위반 없으면 → 커밋 성공
|
||||||
|
↓
|
||||||
|
git push
|
||||||
|
↓
|
||||||
|
pre-receive hook (서버)
|
||||||
|
(diff 기반 검사)
|
||||||
|
↓
|
||||||
|
위반 있으면 → push 차단
|
||||||
|
위반 없으면 → push 성공
|
||||||
|
```
|
||||||
|
|
||||||
|
## 위반 발생 시 대처
|
||||||
|
|
||||||
|
### 에러 메시지 읽기
|
||||||
|
|
||||||
|
```
|
||||||
|
[SECURITY] 2 issue(s) found:
|
||||||
|
|
||||||
|
✗ [CRITICAL] #1 SECRET_HARDCODE — 비밀정보 하드코딩
|
||||||
|
→ src/controllers/auth.js:64
|
||||||
|
password: 'my-secret-123'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[CRITICAL]` / `[HIGH]` → 차단됨, 반드시 수정
|
||||||
|
- `[MEDIUM]` → 경고, 5개 초과 시 차단
|
||||||
|
- `→ 파일:라인번호` → 수정할 위치
|
||||||
|
- 아래 줄 → 문제가 된 코드
|
||||||
|
|
||||||
|
### 수정 방법 (규칙별)
|
||||||
|
|
||||||
|
| 규칙 | 수정 방법 |
|
||||||
|
|------|----------|
|
||||||
|
| SECRET_HARDCODE | `process.env.변수명`으로 이동, `.env`에 추가 |
|
||||||
|
| LOCALSTORAGE_AUTH | HttpOnly 쿠키 또는 Authorization 헤더 사용 |
|
||||||
|
| INNERHTML_XSS | `textContent` 사용 또는 DOMPurify 적용 |
|
||||||
|
| CORS_WILDCARD | 허용 도메인을 명시적으로 나열 |
|
||||||
|
| SQL_INTERPOLATION | 파라미터화 쿼리(`?` placeholder) 사용 |
|
||||||
|
| LOG_SECRET | 로그에서 비밀정보 제거 |
|
||||||
|
|
||||||
|
## bypass 사용법 (긴급 시)
|
||||||
|
|
||||||
|
### 형식
|
||||||
|
```
|
||||||
|
git commit -m "fix: 긴급 장애 대응 [SECURITY-BYPASS: prod 서비스 다운 긴급 핫픽스]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 규칙
|
||||||
|
- **사유 필수**: `[SECURITY-BYPASS]`만으로는 거부됨
|
||||||
|
- **허용 사용자만**: 운영담당자(ahn@hyungi.net)만 bypass 가능
|
||||||
|
- **24시간 내 수정**: bypass 후 반드시 보안 이슈 수정 PR 제출
|
||||||
|
- **로그 기록**: 모든 bypass는 서버에 자동 기록됨
|
||||||
|
|
||||||
|
### bypass 후 조치
|
||||||
|
1. bypass한 코드의 보안 이슈 파악
|
||||||
|
2. 24시간 내 수정 커밋
|
||||||
|
3. `security-scan.sh --all`로 전체 검증
|
||||||
|
|
||||||
|
## 규칙 추가/수정 방법
|
||||||
|
|
||||||
|
### 새 규칙 추가
|
||||||
|
`scripts/security-scan.sh`의 RULES 배열에 추가:
|
||||||
|
```bash
|
||||||
|
'RULE_NAME|SEVERITY|설명|REGEX_PATTERN'
|
||||||
|
```
|
||||||
|
|
||||||
|
예시:
|
||||||
|
```bash
|
||||||
|
'EVAL_USAGE|HIGH|eval 사용 위험|eval\s*\('
|
||||||
|
```
|
||||||
|
|
||||||
|
### 같은 규칙을 서버에도 반영
|
||||||
|
`.githooks/pre-receive-server.sh`의 RULES 배열에도 동일하게 추가.
|
||||||
|
Gitea 서버의 hook 파일도 업데이트 필요.
|
||||||
|
|
||||||
|
## false positive 등록
|
||||||
|
|
||||||
|
### 파일 단위 제외
|
||||||
|
`.securityignore`에 추가 (주석 필수):
|
||||||
|
```
|
||||||
|
path/to/file.js # 사유 설명 (날짜)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 라인 단위 제외
|
||||||
|
소스 코드에 인라인 주석:
|
||||||
|
```javascript
|
||||||
|
const pattern = /password/; // security-ignore: SECRET_HARDCODE — regex 패턴 정의
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의
|
||||||
|
- 주석 없는 항목은 스캔 시 경고
|
||||||
|
- 월 1회 `.securityignore` 검토하여 불필요 항목 제거
|
||||||
|
|
||||||
|
## 수동 검사
|
||||||
|
|
||||||
|
### 전체 프로젝트 스캔
|
||||||
|
```bash
|
||||||
|
./scripts/security-scan.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 엄격 모드 (MEDIUM도 차단)
|
||||||
|
```bash
|
||||||
|
./scripts/security-scan.sh --all --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
### 두 커밋 간 비교
|
||||||
|
```bash
|
||||||
|
./scripts/security-scan.sh --diff HEAD~5 HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
## 초기 설정 (새 머신)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. git hooks 경로 설정
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
|
||||||
|
# 2. 전체 스캔 확인
|
||||||
|
./scripts/security-scan.sh --all
|
||||||
|
|
||||||
|
# 3. 테스트 (선택)
|
||||||
|
echo "password: 'test'" >> /tmp/test.js
|
||||||
|
git add /tmp/test.js
|
||||||
|
git commit -m "test" # → 차단되어야 함
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: pre-commit이 너무 느리다**
|
||||||
|
A: staged 파일만 검사하므로 보통 1초 이내. 파일이 많으면 `--no-verify`로 우회 후 push 시 서버에서 검사.
|
||||||
|
|
||||||
|
**Q: false positive가 계속 뜬다**
|
||||||
|
A: `.securityignore`에 등록하거나 라인에 `// security-ignore: RULE_NAME` 추가.
|
||||||
|
|
||||||
|
**Q: 규칙을 비활성화하고 싶다**
|
||||||
|
A: RULES 배열에서 해당 규칙을 주석 처리. 단, CRITICAL 규칙 비활성화는 비권장.
|
||||||
|
|
||||||
|
**Q: 새 서비스 추가 시**
|
||||||
|
A: 추가 설정 불필요. `.securityignore`에 제외할 파일이 있으면 등록.
|
||||||
@@ -489,7 +489,7 @@
|
|||||||
|
|
||||||
// ===== Card Definitions =====
|
// ===== Card Definitions =====
|
||||||
var SYSTEM_CARDS = [
|
var SYSTEM_CARDS = [
|
||||||
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 'dashboard', color: '#1a56db' },
|
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard-new.html', pageKey: 'dashboard', color: '#1a56db' },
|
||||||
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
||||||
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
|
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
|
||||||
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
|
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
|
||||||
@@ -781,13 +781,14 @@
|
|||||||
|
|
||||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||||
if (redirect && isSafeRedirect(redirect)) {
|
if (redirect && isSafeRedirect(redirect)) {
|
||||||
window.location.href = redirect;
|
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||||
|
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(data.access_token);
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
errEl.style.display = '';
|
errEl.style.display = 'block';
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '\uB85C\uADF8\uC778';
|
btn.textContent = '\uB85C\uADF8\uC778';
|
||||||
@@ -840,7 +841,8 @@
|
|||||||
// Already logged in + redirect param
|
// Already logged in + redirect param
|
||||||
var redirect = params.get('redirect');
|
var redirect = params.get('redirect');
|
||||||
if (redirect && isSafeRedirect(redirect)) {
|
if (redirect && isSafeRedirect(redirect)) {
|
||||||
window.location.href = redirect;
|
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||||
|
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
var loginUrl;
|
var loginUrl;
|
||||||
|
|
||||||
if (hostname.includes('technicalkorea.net')) {
|
if (hostname.includes('technicalkorea.net')) {
|
||||||
loginUrl = window.location.protocol + '//tkds.technicalkorea.net/dashboard';
|
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
|
||||||
} else {
|
} else {
|
||||||
// 개발 환경: tkds 포트 (30780)
|
// 개발 환경: tkds 포트 (30780)
|
||||||
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
||||||
|
|||||||
94
scripts/check-webroot-security.sh
Executable file
94
scripts/check-webroot-security.sh
Executable file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 웹루트 보안 검증 스크립트
|
||||||
|
# 배포 후 실행: docker 이미지 내 /usr/share/nginx/html에 허용된 파일만 있는지 확인
|
||||||
|
# 화이트리스트 방식 — 허용되지 않은 파일이 있으면 FAIL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SERVICES=("system1-web" "system2-web" "system3-web")
|
||||||
|
|
||||||
|
# 허용 목록 (줄바꿈 구분 — 공백 파일명 안전)
|
||||||
|
ALLOWED_system1_web="index.html
|
||||||
|
manifest.json
|
||||||
|
sw.js
|
||||||
|
logo.png
|
||||||
|
components
|
||||||
|
css
|
||||||
|
img
|
||||||
|
js
|
||||||
|
pages
|
||||||
|
static"
|
||||||
|
|
||||||
|
ALLOWED_system2_web="push-sw.js
|
||||||
|
css
|
||||||
|
img
|
||||||
|
js
|
||||||
|
pages"
|
||||||
|
|
||||||
|
ALLOWED_system3_web="ai-assistant.html
|
||||||
|
app.html
|
||||||
|
favicon.ico
|
||||||
|
issue-view.html
|
||||||
|
issues-archive.html
|
||||||
|
issues-dashboard.html
|
||||||
|
issues-inbox.html
|
||||||
|
issues-management.html
|
||||||
|
m
|
||||||
|
push-sw.js
|
||||||
|
reports-daily.html
|
||||||
|
reports-monthly.html
|
||||||
|
reports-weekly.html
|
||||||
|
reports.html
|
||||||
|
static
|
||||||
|
sw.js
|
||||||
|
uploads"
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
varname="ALLOWED_${service//-/_}"
|
||||||
|
allowed="${!varname}"
|
||||||
|
|
||||||
|
echo "Checking $service..."
|
||||||
|
|
||||||
|
# 컨테이너 생성만 (실행 안 함) → exec으로 검사 → 제거
|
||||||
|
docker compose create --no-deps "$service" >/dev/null 2>&1
|
||||||
|
container=$(docker compose ps -q "$service" | head -n1)
|
||||||
|
if [ -z "$container" ]; then
|
||||||
|
echo " FAIL: container not found for $service"
|
||||||
|
FAIL=1; continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
entries=$(docker exec "$container" \
|
||||||
|
find /usr/share/nginx/html -maxdepth 1 -mindepth 1 -printf '%f\n' 2>/dev/null || true)
|
||||||
|
docker compose rm -f "$service" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# 빈 webroot 체크 (COPY public/ 실패 감지)
|
||||||
|
if [ -z "$entries" ]; then
|
||||||
|
echo " FAIL: $service webroot is empty"
|
||||||
|
FAIL=1; continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
# -xF: 정확히 일치하는 줄만 (substring 매칭 방지)
|
||||||
|
if ! echo "$allowed" | grep -qxF "$f"; then
|
||||||
|
echo " FAIL: unexpected file in webroot → $f"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done <<< "$entries"
|
||||||
|
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo " OK"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo "✓ All web roots clean"
|
||||||
|
else
|
||||||
|
echo "✗ Security check FAILED — fix before deploying"
|
||||||
|
fi
|
||||||
|
exit $FAIL
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ===================================================================
|
|
||||||
# TK Factory Services - 원격 배포 스크립트 (맥북에서 실행)
|
|
||||||
# ===================================================================
|
|
||||||
# 사용법: ./scripts/deploy-remote.sh
|
|
||||||
# 설정: ~/.tk-deploy-config
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
CONFIG_FILE="$HOME/.tk-deploy-config"
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# === 설정 로드 ===
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
|
|
||||||
cat <<'EXAMPLE'
|
|
||||||
|
|
||||||
다음 내용으로 생성하세요:
|
|
||||||
|
|
||||||
NAS_HOST=100.71.132.52
|
|
||||||
NAS_USER=hyungi
|
|
||||||
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
|
|
||||||
NAS_SUDO_PASS=<sudo 비밀번호>
|
|
||||||
EXAMPLE
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
source "$CONFIG_FILE"
|
|
||||||
|
|
||||||
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
DOCKER="/usr/local/bin/docker"
|
|
||||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
|
||||||
|
|
||||||
# === 헬퍼 함수 ===
|
|
||||||
ssh_cmd() {
|
|
||||||
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
nas_docker() {
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# === Phase 1: Pre-flight 체크 ===
|
|
||||||
echo "=== TK Factory Services - 원격 배포 ==="
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[1/5] Pre-flight 체크${NC}"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
# Working tree clean 확인
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬에 커밋되지 않은 변경사항이 있습니다${NC}"
|
|
||||||
echo ""
|
|
||||||
git status --short
|
|
||||||
echo ""
|
|
||||||
echo "먼저 커밋하거나 stash하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 커밋 정보
|
|
||||||
LOCAL_HASH=$(git rev-parse HEAD)
|
|
||||||
LOCAL_SHORT=$(git rev-parse --short HEAD)
|
|
||||||
LOCAL_MSG=$(git log -1 --format='%s')
|
|
||||||
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
|
|
||||||
# origin 동기화 확인
|
|
||||||
git fetch origin --quiet
|
|
||||||
|
|
||||||
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
|
|
||||||
if [ "$LOCAL_HASH" != "$ORIGIN_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬 커밋이 origin에 push되지 않았습니다${NC}"
|
|
||||||
echo " 로컬: ${LOCAL_SHORT} (${LOCAL_MSG})"
|
|
||||||
echo " 원격: $(git rev-parse --short "origin/${LOCAL_BRANCH}" 2>/dev/null || echo 'N/A')"
|
|
||||||
echo ""
|
|
||||||
echo "먼저 push하세요: git push origin ${LOCAL_BRANCH}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " 로컬 HEAD: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
echo -e " 브랜치: ${LOCAL_BRANCH}"
|
|
||||||
|
|
||||||
# === Phase 2: NAS 상태 비교 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[2/5] NAS 배포 상태 확인${NC}"
|
|
||||||
|
|
||||||
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$NAS_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: NAS에서 git 정보를 가져올 수 없습니다${NC}"
|
|
||||||
echo " 경로: ${NAS_DEPLOY_PATH}"
|
|
||||||
echo " NAS에 git clone이 완료되었는지 확인하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NAS_SHORT="${NAS_HASH:0:7}"
|
|
||||||
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$LOCAL_HASH" = "$NAS_HASH" ]; then
|
|
||||||
echo -e " ${GREEN}이미 최신 버전입니다!${NC} (${NAS_SHORT} - ${NAS_MSG})"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
|
|
||||||
echo -e " 배포 대상: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
|
|
||||||
# 배포될 커밋 목록
|
|
||||||
COMMIT_COUNT=$(git log "${NAS_HASH}..${LOCAL_HASH}" --oneline | wc -l | tr -d ' ')
|
|
||||||
echo ""
|
|
||||||
echo "=== 배포될 커밋 (${COMMIT_COUNT}개) ==="
|
|
||||||
git log "${NAS_HASH}..${LOCAL_HASH}" --oneline --no-decorate
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -p "배포를 진행하시겠습니까? [y/N] " confirm
|
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
||||||
echo "배포가 취소되었습니다."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# === Phase 3: 배포 실행 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[3/5] NAS 코드 업데이트${NC}"
|
|
||||||
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git reset --hard origin/${DEPLOY_BRANCH}"
|
|
||||||
|
|
||||||
UPDATED_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H %s'" 2>/dev/null)
|
|
||||||
echo -e " ${GREEN}완료${NC}: ${UPDATED_HASH}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[4/5] Docker 컨테이너 빌드 및 재시작${NC}"
|
|
||||||
echo " (빌드에 시간이 걸릴 수 있습니다...)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
nas_docker "compose up -d --build"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " nginx 프록시 컨테이너 재시작 (IP 캐시 갱신)..."
|
|
||||||
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
|
|
||||||
|
|
||||||
# === Phase 4: 배포 후 검증 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[5/5] 배포 검증${NC} (15초 대기 후 health check)"
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Container Status ==="
|
|
||||||
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== HTTP Health Check ==="
|
|
||||||
HEALTH_PASS=0
|
|
||||||
HEALTH_FAIL=0
|
|
||||||
|
|
||||||
check_remote() {
|
|
||||||
local name="$1"
|
|
||||||
local path="$2"
|
|
||||||
local status
|
|
||||||
status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://localhost:${path}" 2>/dev/null || echo "000")
|
|
||||||
if [ "$status" -ge 200 ] 2>/dev/null && [ "$status" -lt 400 ] 2>/dev/null; then
|
|
||||||
printf " %-25s ${GREEN}OK${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_PASS++))
|
|
||||||
else
|
|
||||||
printf " %-25s ${RED}FAIL${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_FAIL++))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_remote "Gateway" "30000/"
|
|
||||||
check_remote "SSO Auth" "30050/health"
|
|
||||||
check_remote "System 1 API" "30005/api/health"
|
|
||||||
check_remote "System 1 Web" "30080/"
|
|
||||||
check_remote "System 1 FastAPI" "30008/health"
|
|
||||||
check_remote "System 2 API" "30105/api/health"
|
|
||||||
check_remote "System 2 Web" "30180/"
|
|
||||||
check_remote "System 3 API" "30200/api/health"
|
|
||||||
check_remote "System 3 Web" "30280/"
|
|
||||||
check_remote "tkuser API" "30300/api/health"
|
|
||||||
check_remote "tkuser Web" "30380/"
|
|
||||||
check_remote "phpMyAdmin" "30880/"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Health: PASS=${HEALTH_PASS} FAIL=${HEALTH_FAIL}"
|
|
||||||
|
|
||||||
# === Phase 5: 배포 로그 기록 ===
|
|
||||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
ssh_cmd "echo '${TIMESTAMP} | ${LOCAL_SHORT} | ${LOCAL_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ "$HEALTH_FAIL" -gt 0 ]; then
|
|
||||||
echo -e "${YELLOW}배포 완료 (일부 서비스 health check 실패)${NC}"
|
|
||||||
echo " 로그 확인: ssh ${NAS_USER}@${NAS_HOST} \"cd ${NAS_DEPLOY_PATH} && echo '...' | sudo -S ${DOCKER} compose logs --tail=50\""
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}배포 완료!${NC}"
|
|
||||||
fi
|
|
||||||
echo " 버전: ${LOCAL_SHORT} - ${LOCAL_MSG}"
|
|
||||||
355
scripts/security-scan.sh
Executable file
355
scripts/security-scan.sh
Executable file
@@ -0,0 +1,355 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# security-scan.sh — TK Factory Services 보안 스캔 엔진
|
||||||
|
# =============================================================================
|
||||||
|
# 용도: pre-commit hook, pre-receive hook, 수동 전체 검사
|
||||||
|
# 모드:
|
||||||
|
# --staged staged 파일만 검사 (pre-commit 기본)
|
||||||
|
# --all 프로젝트 전체 파일 검사
|
||||||
|
# --diff OLD NEW 두 커밋 간 변경 검사 (pre-receive용)
|
||||||
|
# --strict MEDIUM도 차단
|
||||||
|
#
|
||||||
|
# 커버리지 한계 (PR 리뷰에서 수동):
|
||||||
|
# - RBAC 설계 오류 / 인증 흐름
|
||||||
|
# - 비즈니스 로직 / race condition
|
||||||
|
# - third-party dependency (npm audit 영역)
|
||||||
|
# - 환경변수 값 강도
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- 색상 ---
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# --- 설정 ---
|
||||||
|
MEDIUM_THRESHOLD=5
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
IGNORE_FILE="$PROJECT_ROOT/.securityignore"
|
||||||
|
|
||||||
|
# --- 검출 규칙: NAME|SEVERITY|설명|PATTERN ---
|
||||||
|
RULES=(
|
||||||
|
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
|
||||||
|
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
|
||||||
|
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
|
||||||
|
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
|
||||||
|
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
|
||||||
|
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
|
||||||
|
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
|
||||||
|
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 제외 패턴 ---
|
||||||
|
EXCLUDE_DIRS="node_modules|\.git|__pycache__|\.next|dist|build|coverage"
|
||||||
|
EXCLUDE_FILES="package-lock\.json|yarn\.lock|\.min\.js|\.min\.css|\.map"
|
||||||
|
|
||||||
|
# --- 파싱 함수 ---
|
||||||
|
parse_rule() {
|
||||||
|
local rule="$1"
|
||||||
|
RULE_NAME=$(echo "$rule" | cut -d'|' -f1)
|
||||||
|
RULE_SEVERITY=$(echo "$rule" | cut -d'|' -f2)
|
||||||
|
RULE_DESC=$(echo "$rule" | cut -d'|' -f3)
|
||||||
|
RULE_PATTERN=$(echo "$rule" | cut -d'|' -f4-)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- .securityignore 로드 ---
|
||||||
|
load_ignore_list() {
|
||||||
|
IGNORED_FILES=()
|
||||||
|
if [[ -f "$IGNORE_FILE" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# 빈 줄, 순수 주석 스킵
|
||||||
|
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
# 파일명 추출 (주석 앞부분)
|
||||||
|
local filepath
|
||||||
|
filepath=$(echo "$line" | sed 's/#.*$//' | xargs)
|
||||||
|
[[ -z "$filepath" ]] && continue
|
||||||
|
# 주석 없는 항목 경고
|
||||||
|
if ! echo "$line" | grep -q '#'; then
|
||||||
|
echo -e "${YELLOW}[WARN] .securityignore: '$filepath' 에 사유 주석이 없습니다${NC}" >&2
|
||||||
|
fi
|
||||||
|
IGNORED_FILES+=("$filepath")
|
||||||
|
done < "$IGNORE_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_ignored_file() {
|
||||||
|
local file="$1"
|
||||||
|
for ignored in "${IGNORED_FILES[@]}"; do
|
||||||
|
[[ "$file" == "$ignored" || "$file" == *"/$ignored" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
is_line_ignored() {
|
||||||
|
local line_content="$1"
|
||||||
|
local rule_name="$2"
|
||||||
|
echo "$line_content" | grep -qP "security-ignore:\s*$rule_name" && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- diff 파싱 + 검사 ---
|
||||||
|
scan_diff() {
|
||||||
|
local diff_input="$1"
|
||||||
|
local violations=0
|
||||||
|
local medium_count=0
|
||||||
|
local current_file=""
|
||||||
|
local current_line=0
|
||||||
|
local results=""
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# 파일명 추출
|
||||||
|
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
|
||||||
|
current_file="${BASH_REMATCH[2]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# +++ b/filename
|
||||||
|
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
|
||||||
|
current_file="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# hunk header → 라인 번호
|
||||||
|
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
|
||||||
|
current_line="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# 추가된 라인만 검사
|
||||||
|
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
|
||||||
|
local content="${line:1}" # + 제거
|
||||||
|
current_line=$((current_line))
|
||||||
|
|
||||||
|
# 제외 디렉토리/파일 체크
|
||||||
|
if echo "$current_file" | grep -qEi "($EXCLUDE_DIRS)" 2>/dev/null; then
|
||||||
|
current_line=$((current_line + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if echo "$current_file" | grep -qEi "($EXCLUDE_FILES)" 2>/dev/null; then
|
||||||
|
current_line=$((current_line + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# .securityignore 체크
|
||||||
|
if is_ignored_file "$current_file"; then
|
||||||
|
current_line=$((current_line + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 각 규칙 검사
|
||||||
|
for i in "${!RULES[@]}"; do
|
||||||
|
parse_rule "${RULES[$i]}"
|
||||||
|
if echo "$content" | grep -qP "$RULE_PATTERN" 2>/dev/null; then
|
||||||
|
# 라인 단위 ignore 체크
|
||||||
|
if is_line_ignored "$content" "$RULE_NAME"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local rule_num=$((i + 1))
|
||||||
|
local trimmed
|
||||||
|
trimmed=$(echo "$content" | sed 's/^[[:space:]]*//' | head -c 100)
|
||||||
|
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
|
||||||
|
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
|
||||||
|
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||||
|
"$current_file" "$current_line" "$trimmed")"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
else
|
||||||
|
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
|
||||||
|
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||||
|
"$current_file" "$current_line" "$trimmed")"
|
||||||
|
medium_count=$((medium_count + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
current_line=$((current_line + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$diff_input"
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
local total=$((violations + medium_count))
|
||||||
|
if [[ $total -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[SECURITY] ${total} issue(s) found:${NC}"
|
||||||
|
echo -e "$results"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# MEDIUM 임계값 체크
|
||||||
|
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
|
||||||
|
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD) — blocking${NC}"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# strict 모드
|
||||||
|
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
|
||||||
|
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also block${NC}"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $violations -gt 0 ]]; then
|
||||||
|
echo -e "${RED}Push/commit rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message.${NC}"
|
||||||
|
echo ""
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Warnings only — commit/push allowed.${NC}"
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 전체 파일 검사 (--all 모드) ---
|
||||||
|
scan_all() {
|
||||||
|
local violations=0
|
||||||
|
local medium_count=0
|
||||||
|
local results=""
|
||||||
|
|
||||||
|
load_ignore_list
|
||||||
|
|
||||||
|
local files
|
||||||
|
files=$(find "$PROJECT_ROOT" -type f \
|
||||||
|
\( -name "*.js" -o -name "*.py" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \
|
||||||
|
-o -name "*.md" -o -name "*.sql" -o -name "*.yml" -o -name "*.yaml" \
|
||||||
|
-o -name "*.json" -o -name "*.sh" -o -name "*.html" \) \
|
||||||
|
! -path "*/node_modules/*" \
|
||||||
|
! -path "*/.git/*" \
|
||||||
|
! -path "*/__pycache__/*" \
|
||||||
|
! -path "*/dist/*" \
|
||||||
|
! -path "*/build/*" \
|
||||||
|
! -path "*/coverage/*" \
|
||||||
|
! -path "*/.claude/worktrees/*" \
|
||||||
|
! -name "package-lock.json" \
|
||||||
|
! -name "*.min.js" \
|
||||||
|
! -name "*.min.css" \
|
||||||
|
2>/dev/null || true)
|
||||||
|
|
||||||
|
while IFS= read -r filepath; do
|
||||||
|
[[ -z "$filepath" ]] && continue
|
||||||
|
local relpath="${filepath#$PROJECT_ROOT/}"
|
||||||
|
|
||||||
|
# .securityignore 체크
|
||||||
|
if is_ignored_file "$relpath"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
for i in "${!RULES[@]}"; do
|
||||||
|
parse_rule "${RULES[$i]}"
|
||||||
|
|
||||||
|
local matches
|
||||||
|
matches=$(grep -nP "$RULE_PATTERN" "$filepath" 2>/dev/null || true)
|
||||||
|
[[ -z "$matches" ]] && continue
|
||||||
|
|
||||||
|
while IFS= read -r match; do
|
||||||
|
local linenum content
|
||||||
|
linenum=$(echo "$match" | cut -d: -f1)
|
||||||
|
content=$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 100)
|
||||||
|
|
||||||
|
# 라인 단위 ignore
|
||||||
|
local full_line
|
||||||
|
full_line=$(sed -n "${linenum}p" "$filepath" 2>/dev/null || true)
|
||||||
|
if is_line_ignored "$full_line" "$RULE_NAME"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local rule_num=$((i + 1))
|
||||||
|
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
|
||||||
|
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
|
||||||
|
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||||
|
"$relpath" "$linenum" "$content")"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
else
|
||||||
|
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
|
||||||
|
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||||
|
"$relpath" "$linenum" "$content")"
|
||||||
|
medium_count=$((medium_count + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$matches"
|
||||||
|
done
|
||||||
|
done <<< "$files"
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
local total=$((violations + medium_count))
|
||||||
|
if [[ $total -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[SECURITY] Full scan: ${total} issue(s) found:${NC}"
|
||||||
|
echo -e "$results"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
|
||||||
|
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD)${NC}"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
fi
|
||||||
|
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
|
||||||
|
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also count${NC}"
|
||||||
|
violations=$((violations + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $violations -gt 0 ]]; then
|
||||||
|
echo -e "${RED}${violations} blocking violation(s) found.${NC}"
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Warnings only (${medium_count} MEDIUM).${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[SECURITY] Full scan: 0 violations found.${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 메인 ---
|
||||||
|
main() {
|
||||||
|
local mode="staged"
|
||||||
|
local old_rev="" new_rev=""
|
||||||
|
STRICT_MODE="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--staged) mode="staged"; shift ;;
|
||||||
|
--all) mode="all"; shift ;;
|
||||||
|
--diff) mode="diff"; old_rev="$2"; new_rev="$3"; shift 3 ;;
|
||||||
|
--strict) STRICT_MODE="true"; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: security-scan.sh [--staged|--all|--diff OLD NEW] [--strict]"
|
||||||
|
echo " --staged Check staged files (default, for pre-commit)"
|
||||||
|
echo " --all Scan entire project"
|
||||||
|
echo " --diff Check changes between two commits (for pre-receive)"
|
||||||
|
echo " --strict Block MEDIUM violations too"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
load_ignore_list
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
staged)
|
||||||
|
local diff_output
|
||||||
|
diff_output=$(git diff --cached -U0 --diff-filter=ACMRT 2>/dev/null || true)
|
||||||
|
if [[ -z "$diff_output" ]]; then
|
||||||
|
echo -e "${GREEN}[SECURITY] No staged changes to scan.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
scan_diff "$diff_output"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
scan_all
|
||||||
|
;;
|
||||||
|
diff)
|
||||||
|
if [[ -z "$old_rev" || -z "$new_rev" ]]; then
|
||||||
|
echo "Error: --diff requires OLD and NEW revisions"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local diff_output
|
||||||
|
diff_output=$(git diff -U0 --diff-filter=ACMRT "$old_rev" "$new_rev" 2>/dev/null || true)
|
||||||
|
if [[ -z "$diff_output" ]]; then
|
||||||
|
echo -e "${GREEN}[SECURITY] No changes to scan.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
scan_diff "$diff_output"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
39
shared/frontend/sso-relay.js
Normal file
39
shared/frontend/sso-relay.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
|
||||||
|
*
|
||||||
|
* Canonical source: shared/frontend/sso-relay.js
|
||||||
|
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
|
||||||
|
* system1-factory/web/js/sso-relay.js
|
||||||
|
* system2-report/web/js/sso-relay.js
|
||||||
|
* system3-nonconformance/web/static/js/sso-relay.js
|
||||||
|
* user-management/web/static/js/sso-relay.js
|
||||||
|
* tkpurchase/web/static/js/sso-relay.js
|
||||||
|
* tksafety/web/static/js/sso-relay.js
|
||||||
|
* tksupport/web/static/js/sso-relay.js
|
||||||
|
*
|
||||||
|
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
|
||||||
|
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
var hash = location.hash;
|
||||||
|
if (!hash || hash.indexOf('_sso=') === -1) return;
|
||||||
|
|
||||||
|
var match = hash.match(/[#&]_sso=([^&]*)/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
var token = decodeURIComponent(match[1]);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// 로컬(1st-party) 쿠키 설정
|
||||||
|
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
|
||||||
|
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
|
||||||
|
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||||
|
}
|
||||||
|
document.cookie = cookie;
|
||||||
|
|
||||||
|
// localStorage 폴백
|
||||||
|
try { localStorage.setItem('sso_token', token); } catch (e) {}
|
||||||
|
|
||||||
|
// URL에서 hash 제거
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
})();
|
||||||
61
shared/middleware/pagePermission.js
Normal file
61
shared/middleware/pagePermission.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 페이지 권한 미들웨어 (shared)
|
||||||
|
* admin/system 역할은 자동 통과, 일반 사용자는 개인/부서 권한 체크
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* const { createRequirePage } = require('../../shared/middleware/pagePermission');
|
||||||
|
* const requirePage = createRequirePage(() => getPool());
|
||||||
|
* router.get('/some', requirePage('page_name'), controller.handler);
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createRequirePage(getPool) {
|
||||||
|
return function requirePage(pageName) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const role = (req.user.role || '').toLowerCase();
|
||||||
|
|
||||||
|
// admin/system 자동 통과
|
||||||
|
if (role === 'admin' || role === 'system') return next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = typeof getPool === 'function' ? await getPool() : getPool;
|
||||||
|
|
||||||
|
// 1. 개인 권한 체크
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
|
||||||
|
[userId, pageName]
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return rows[0].can_access
|
||||||
|
? next()
|
||||||
|
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 부서 권한 체크
|
||||||
|
const [userRows] = await db.query(
|
||||||
|
'SELECT department_id FROM sso_users WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (userRows.length > 0 && userRows[0].department_id) {
|
||||||
|
const [deptRows] = await db.query(
|
||||||
|
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
|
||||||
|
[userRows[0].department_id, pageName]
|
||||||
|
);
|
||||||
|
if (deptRows.length > 0) {
|
||||||
|
return deptRows[0].can_access
|
||||||
|
? next()
|
||||||
|
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 권한 레코드 없음 → 거부
|
||||||
|
return res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Permission check error:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '권한 확인 실패' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRequirePage };
|
||||||
@@ -9,13 +9,14 @@ const notifyHelper = {
|
|||||||
/**
|
/**
|
||||||
* 알림 전송
|
* 알림 전송
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system)
|
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system, purchase)
|
||||||
* @param {string} opts.title - 알림 제목
|
* @param {string} opts.title - 알림 제목
|
||||||
* @param {string} [opts.message] - 알림 내용
|
* @param {string} [opts.message] - 알림 내용
|
||||||
* @param {string} [opts.link_url] - 클릭 시 이동 URL
|
* @param {string} [opts.link_url] - 클릭 시 이동 URL
|
||||||
* @param {string} [opts.reference_type] - 연관 테이블명
|
* @param {string} [opts.reference_type] - 연관 테이블명
|
||||||
* @param {number} [opts.reference_id] - 연관 레코드 ID
|
* @param {number} [opts.reference_id] - 연관 레코드 ID
|
||||||
* @param {number} [opts.created_by] - 생성자 user_id
|
* @param {number} [opts.created_by] - 생성자 user_id
|
||||||
|
* @param {number[]} [opts.target_user_ids] - 특정 사용자 직접 알림 (생략 시 type 기반 브로드캐스트)
|
||||||
*/
|
*/
|
||||||
async send(opts) {
|
async send(opts) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ async function login(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const refresh_token = jwt.sign(
|
const refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
||||||
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
access_token,
|
access_token,
|
||||||
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
|
|||||||
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
// TODO: issuer/audience 클레임 검증 추가 검토
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
||||||
@@ -229,7 +230,7 @@ async function me(req, res, next) {
|
|||||||
return res.status(401).json({ detail: 'Not authenticated' });
|
return res.status(401).json({ detail: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ detail: 'User not found or inactive' });
|
return res.status(401).json({ detail: 'User not found or inactive' });
|
||||||
@@ -261,7 +262,7 @@ async function refresh(req, res, next) {
|
|||||||
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
|
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
||||||
}
|
}
|
||||||
@@ -272,11 +273,11 @@ async function refresh(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const new_refresh_token = jwt.sign(
|
const new_refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -365,6 +366,65 @@ async function deleteUser(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/change-password — 본인 비밀번호 변경
|
||||||
|
*/
|
||||||
|
async function changePassword(req, res, next) {
|
||||||
|
try {
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) return res.status(401).json({ success: false, message: '인증이 필요합니다' });
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
|
const userId = decoded.user_id || decoded.id;
|
||||||
|
const user = await userModel.findById(userId);
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return res.status(401).json({ success: false, message: '유효하지 않은 사용자입니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ success: false, message: '현재 비밀번호와 새 비밀번호를 모두 입력해주세요' });
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({ success: false, message: '새 비밀번호는 6자 이상이어야 합니다' });
|
||||||
|
}
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
return res.status(400).json({ success: false, message: '새 비밀번호는 현재 비밀번호와 달라야 합니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await userModel.verifyPassword(currentPassword, user.password_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(400).json({ success: false, message: '현재 비밀번호가 올바르지 않습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await userModel.update(userId, { password: newPassword });
|
||||||
|
res.json({ success: true, message: '비밀번호가 변경되었습니다. 다시 로그인해주세요.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ success: false, message: '인증이 만료되었습니다' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/check-password-strength — 비밀번호 강도 체크
|
||||||
|
*/
|
||||||
|
async function checkPasswordStrength(req, res) {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password) return res.json({ success: true, data: { score: 0, level: 'weak' } });
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 6) score++;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(password)) score++;
|
||||||
|
if (/[0-9]/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
const level = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong';
|
||||||
|
res.json({ success: true, data: { score, level } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
||||||
*/
|
*/
|
||||||
@@ -387,5 +447,7 @@ module.exports = {
|
|||||||
getUsers,
|
getUsers,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser
|
deleteUser,
|
||||||
|
changePassword,
|
||||||
|
checkPasswordStrength
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ const allowedOrigins = [
|
|||||||
'https://tkpurchase.technicalkorea.net',
|
'https://tkpurchase.technicalkorea.net',
|
||||||
'https://tksafety.technicalkorea.net',
|
'https://tksafety.technicalkorea.net',
|
||||||
'https://tksupport.technicalkorea.net',
|
'https://tksupport.technicalkorea.net',
|
||||||
'https://tkds.technicalkorea.net',
|
'https://tkfb.technicalkorea.net',
|
||||||
];
|
];
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
||||||
}
|
}
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: function(origin, cb) {
|
origin: function(origin, cb) {
|
||||||
if (!origin || allowedOrigins.includes(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
if (!origin || allowedOrigins.includes(origin) || /^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
||||||
cb(new Error('CORS blocked: ' + origin));
|
cb(null, false);
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ router.get('/me', authController.me);
|
|||||||
router.post('/refresh', authController.refresh);
|
router.post('/refresh', authController.refresh);
|
||||||
router.post('/logout', authController.logout);
|
router.post('/logout', authController.logout);
|
||||||
|
|
||||||
|
// 인증 사용자 엔드포인트
|
||||||
|
router.post('/change-password', authController.changePassword);
|
||||||
|
router.post('/check-password-strength', authController.checkPasswordStrength);
|
||||||
|
|
||||||
// 관리자 엔드포인트
|
// 관리자 엔드포인트
|
||||||
router.get('/users', requireAdmin, authController.getUsers);
|
router.get('/users', requireAdmin, authController.getUsers);
|
||||||
router.post('/users', requireAdmin, authController.createUser);
|
router.post('/users', requireAdmin, authController.createUser);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
|||||||
# 앱 소스 복사
|
# 앱 소스 복사
|
||||||
COPY system1-factory/api/ ./
|
COPY system1-factory/api/ ./
|
||||||
|
|
||||||
|
# shared 모듈 심링크 (routes에서 ../../../shared/ 경로 호환)
|
||||||
|
RUN ln -s /usr/src/app/shared /usr/src/shared && ln -s /usr/src/app/shared /usr/shared
|
||||||
|
|
||||||
# 로그/업로드 디렉토리 생성
|
# 로그/업로드 디렉토리 생성
|
||||||
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const logger = require('../utils/logger');
|
|||||||
*/
|
*/
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
||||||
'https://tkds.technicalkorea.net', // Gateway/Dashboard
|
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
|
||||||
'https://tkreport.technicalkorea.net', // System 2
|
'https://tkreport.technicalkorea.net', // System 2
|
||||||
'https://tkqc.technicalkorea.net', // System 3
|
'https://tkqc.technicalkorea.net', // System 3
|
||||||
'https://tkuser.technicalkorea.net', // User Management
|
'https://tkuser.technicalkorea.net', // User Management
|
||||||
@@ -50,6 +50,12 @@ const corsOptions = {
|
|||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *.technicalkorea.net 서브도메인 허용 (인앱 브라우저 대응)
|
||||||
|
if (/^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin)) {
|
||||||
|
logger.debug('CORS: technicalkorea.net 서브도메인 허용', { origin });
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
// 개발 환경에서는 모든 localhost 허용
|
// 개발 환경에서는 모든 localhost 허용
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||||
@@ -64,9 +70,9 @@ const corsOptions = {
|
|||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 차단
|
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
|
||||||
logger.warn('CORS: 차단된 Origin', { origin });
|
logger.warn('CORS: 차단된 Origin', { origin });
|
||||||
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
|
callback(null, false);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,8 +53,14 @@ function setupRoutes(app) {
|
|||||||
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
|
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
|
||||||
const purchaseRoutes = require('../routes/purchaseRoutes');
|
const purchaseRoutes = require('../routes/purchaseRoutes');
|
||||||
const settlementRoutes = require('../routes/settlementRoutes');
|
const settlementRoutes = require('../routes/settlementRoutes');
|
||||||
|
const itemAliasRoutes = require('../routes/itemAliasRoutes');
|
||||||
|
const purchaseBatchRoutes = require('../routes/purchaseBatchRoutes');
|
||||||
|
const consumableCategoryRoutes = require('../routes/consumableCategoryRoutes');
|
||||||
const scheduleRoutes = require('../routes/scheduleRoutes');
|
const scheduleRoutes = require('../routes/scheduleRoutes');
|
||||||
const meetingRoutes = require('../routes/meetingRoutes');
|
const meetingRoutes = require('../routes/meetingRoutes');
|
||||||
|
const proxyInputRoutes = require('../routes/proxyInputRoutes');
|
||||||
|
const monthlyComparisonRoutes = require('../routes/monthlyComparisonRoutes');
|
||||||
|
const dashboardRoutes = require('../routes/dashboardRoutes');
|
||||||
|
|
||||||
// Rate Limiters 설정
|
// Rate Limiters 설정
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
@@ -163,8 +169,14 @@ function setupRoutes(app) {
|
|||||||
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
|
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
|
||||||
app.use('/api/purchases', purchaseRoutes); // 구매 내역
|
app.use('/api/purchases', purchaseRoutes); // 구매 내역
|
||||||
app.use('/api/settlements', settlementRoutes); // 월간 정산
|
app.use('/api/settlements', settlementRoutes); // 월간 정산
|
||||||
|
app.use('/api/item-aliases', itemAliasRoutes); // 품목 별칭
|
||||||
|
app.use('/api/purchase-batches', purchaseBatchRoutes); // 구매 그룹
|
||||||
|
app.use('/api/consumable-categories', consumableCategoryRoutes); // 소모품 카테고리
|
||||||
app.use('/api/schedule', scheduleRoutes); // 공정표
|
app.use('/api/schedule', scheduleRoutes); // 공정표
|
||||||
app.use('/api/meetings', meetingRoutes); // 생산회의록
|
app.use('/api/meetings', meetingRoutes); // 생산회의록
|
||||||
|
app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황
|
||||||
|
app.use('/api/monthly-comparison', monthlyComparisonRoutes); // 월간 비교·확인·정산
|
||||||
|
app.use('/api/dashboard', dashboardRoutes); // 대시보드 개인 요약
|
||||||
app.use('/api', uploadBgRoutes);
|
app.use('/api', uploadBgRoutes);
|
||||||
|
|
||||||
// Swagger API 문서
|
// Swagger API 문서
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const ConsumableCategoryModel = require('../models/consumableCategoryModel');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ConsumableCategoryController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.all !== '1';
|
||||||
|
const rows = await ConsumableCategoryModel.getAll(activeOnly);
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_code, category_name, icon, color_bg, color_fg, sort_order } = req.body;
|
||||||
|
if (!category_code || !category_name) {
|
||||||
|
return res.status(400).json({ success: false, message: '코드와 이름을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const cat = await ConsumableCategoryModel.create({
|
||||||
|
categoryCode: category_code, categoryName: category_name,
|
||||||
|
icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
|
||||||
|
});
|
||||||
|
res.status(201).json({ success: true, data: cat, message: '카테고리가 추가되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(400).json({ success: false, message: '이미 존재하는 코드입니다.' });
|
||||||
|
}
|
||||||
|
logger.error('ConsumableCategory create error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_name, icon, color_bg, color_fg, sort_order } = req.body;
|
||||||
|
const cat = await ConsumableCategoryModel.update(req.params.id, {
|
||||||
|
categoryName: category_name, icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
|
||||||
|
});
|
||||||
|
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
|
||||||
|
res.json({ success: true, data: cat, message: '카테고리가 수정되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory update error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cat = await ConsumableCategoryModel.deactivate(req.params.id);
|
||||||
|
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
|
||||||
|
res.json({ success: true, data: cat, message: '카테고리가 비활성화되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory deactivate error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ConsumableCategoryController;
|
||||||
92
system1-factory/api/controllers/dashboardController.js
Normal file
92
system1-factory/api/controllers/dashboardController.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 컨트롤러
|
||||||
|
* Sprint 003 — 개인 요약 API
|
||||||
|
*/
|
||||||
|
const DashboardModel = require('../models/dashboardModel');
|
||||||
|
const logger = require('../../shared/utils/logger');
|
||||||
|
|
||||||
|
const DashboardController = {
|
||||||
|
/**
|
||||||
|
* GET /api/dashboard/my-summary
|
||||||
|
* 연차 잔여 + 월간 연장근로 + 접근 가능 페이지
|
||||||
|
*/
|
||||||
|
getMySummary: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// 1단계: 사용자 정보 먼저 조회 (worker_id 필요)
|
||||||
|
const userInfo = await DashboardModel.getUserInfo(userId);
|
||||||
|
if (!userInfo) {
|
||||||
|
return res.status(404).json({ success: false, message: '사용자 정보를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentId = userInfo.department_id;
|
||||||
|
const role = userInfo.role;
|
||||||
|
|
||||||
|
// 2단계: 나머지 3개 병렬 조회 (연차: sp_vacation_balances from user_id)
|
||||||
|
const [vacationRows, overtime, quickAccess] = await Promise.all([
|
||||||
|
DashboardModel.getVacationBalance(userId, year),
|
||||||
|
DashboardModel.getMonthlyOvertime(userId, year, month),
|
||||||
|
DashboardModel.getQuickAccess(userId, departmentId, role)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 연차 응답 가공
|
||||||
|
const details = vacationRows.map(v => ({
|
||||||
|
type_name: v.type_name,
|
||||||
|
type_code: v.type_code,
|
||||||
|
balance_type: v.balance_type || 'AUTO',
|
||||||
|
expires_at: v.expires_at || null,
|
||||||
|
total: parseFloat(v.total_days) || 0,
|
||||||
|
used: parseFloat(v.used_days) || 0,
|
||||||
|
remaining: parseFloat(v.remaining_days) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 만료되지 않은 balance만 합산 (만료된 이월연차 제외)
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
const activeRows = vacationRows.filter(v => !v.expires_at || v.expires_at >= today);
|
||||||
|
const totalDays = activeRows.reduce((s, v) => s + (parseFloat(v.total_days) || 0), 0);
|
||||||
|
const usedDays = activeRows.reduce((s, v) => s + (parseFloat(v.used_days) || 0), 0);
|
||||||
|
const remainingDays = totalDays - usedDays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
user_id: userInfo.user_id,
|
||||||
|
name: userInfo.name,
|
||||||
|
worker_name: userInfo.worker_name || userInfo.name,
|
||||||
|
job_type: userInfo.job_type || '',
|
||||||
|
department_name: userInfo.department_name,
|
||||||
|
department_id: userInfo.department_id,
|
||||||
|
role: userInfo.role
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
year,
|
||||||
|
total_days: totalDays,
|
||||||
|
used_days: usedDays,
|
||||||
|
remaining_days: remainingDays,
|
||||||
|
details
|
||||||
|
},
|
||||||
|
overtime: {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
total_overtime_hours: parseFloat(overtime.total_overtime_hours) || 0,
|
||||||
|
overtime_days: parseInt(overtime.overtime_days) || 0,
|
||||||
|
total_work_days: parseInt(overtime.total_work_days) || 0,
|
||||||
|
total_work_hours: parseFloat(overtime.total_work_hours) || 0,
|
||||||
|
avg_daily_hours: parseFloat(parseFloat(overtime.avg_daily_hours || 0).toFixed(1))
|
||||||
|
},
|
||||||
|
quick_access: quickAccess
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('대시보드 요약 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '대시보드 데이터 조회 중 오류가 발생했습니다.', error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = DashboardController;
|
||||||
47
system1-factory/api/controllers/itemAliasController.js
Normal file
47
system1-factory/api/controllers/itemAliasController.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const ItemAliasModel = require('../models/itemAliasModel');
|
||||||
|
const koreanSearch = require('../utils/koreanSearch');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ItemAliasController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await ItemAliasModel.getAll();
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ItemAlias getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { item_id, alias_name } = req.body;
|
||||||
|
if (!item_id || !alias_name || !alias_name.trim()) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목 ID와 별칭을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const id = await ItemAliasModel.create(item_id, alias_name);
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.status(201).json({ success: true, data: { alias_id: id }, message: '별칭이 등록되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(400).json({ success: false, message: '이미 등록된 별칭입니다.' });
|
||||||
|
}
|
||||||
|
logger.error('ItemAlias create error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = await ItemAliasModel.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(404).json({ success: false, message: '별칭을 찾을 수 없습니다.' });
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.json({ success: true, message: '별칭이 삭제되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ItemAlias delete error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ItemAliasController;
|
||||||
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
// controllers/monthlyComparisonController.js — 월간 비교·확인·정산
|
||||||
|
const Model = require('../models/monthlyComparisonModel');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
|
||||||
|
// 일별 비교 상태 판정
|
||||||
|
function determineStatus(report, attendance, isHoliday) {
|
||||||
|
const hasReport = report && report.total_hours > 0;
|
||||||
|
const hasAttendance = attendance && attendance.total_work_hours > 0;
|
||||||
|
const isVacation = attendance && attendance.vacation_type_id;
|
||||||
|
|
||||||
|
if (isHoliday && !hasReport && !hasAttendance) return 'holiday';
|
||||||
|
if (isVacation) return 'vacation';
|
||||||
|
if (!hasReport && !hasAttendance) return 'none';
|
||||||
|
if (hasReport && !hasAttendance) return 'report_only';
|
||||||
|
if (!hasReport && hasAttendance) return 'attend_only';
|
||||||
|
|
||||||
|
const diff = Math.abs(report.total_hours - attendance.total_work_hours);
|
||||||
|
return diff <= 0.5 ? 'match' : 'mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜별 비교 데이터 생성
|
||||||
|
async function buildComparisonData(userId, year, month) {
|
||||||
|
const [reports, attendances, confirmation, holidays] = await Promise.all([
|
||||||
|
Model.getWorkReports(userId, year, month),
|
||||||
|
Model.getAttendanceRecords(userId, year, month),
|
||||||
|
Model.getConfirmation(userId, year, month),
|
||||||
|
Model.getCompanyHolidays(year, month)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 날짜 맵 생성
|
||||||
|
const reportMap = {};
|
||||||
|
reports.forEach(r => {
|
||||||
|
const key = r.report_date instanceof Date
|
||||||
|
? r.report_date.toISOString().split('T')[0]
|
||||||
|
: String(r.report_date).split('T')[0];
|
||||||
|
reportMap[key] = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendMap = {};
|
||||||
|
attendances.forEach(a => {
|
||||||
|
const key = a.record_date instanceof Date
|
||||||
|
? a.record_date.toISOString().split('T')[0]
|
||||||
|
: String(a.record_date).split('T')[0];
|
||||||
|
attendMap[key] = a;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 해당 월의 모든 날짜 생성
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dailyRecords = [];
|
||||||
|
let totalWorkDays = 0, totalWorkHours = 0, totalOvertimeHours = 0;
|
||||||
|
let vacationDays = 0, mismatchCount = 0;
|
||||||
|
const mismatchDetails = { hours_diff: 0, missing_report: 0, missing_attendance: 0 };
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6 || holidays.dateSet.has(dateStr);
|
||||||
|
|
||||||
|
const report = reportMap[dateStr] || null;
|
||||||
|
const attend = attendMap[dateStr] || null;
|
||||||
|
const status = determineStatus(report, attend, isHoliday);
|
||||||
|
|
||||||
|
let hoursDiff = 0;
|
||||||
|
if (report && attend && report.total_hours && attend.total_work_hours) {
|
||||||
|
hoursDiff = parseFloat((report.total_hours - attend.total_work_hours).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
if (status === 'match' || status === 'mismatch') {
|
||||||
|
totalWorkDays++;
|
||||||
|
totalWorkHours += parseFloat(attend?.total_work_hours || report?.total_hours || 0);
|
||||||
|
}
|
||||||
|
if (status === 'report_only') { totalWorkDays++; totalWorkHours += parseFloat(report.total_hours || 0); }
|
||||||
|
if (status === 'attend_only') { totalWorkDays++; totalWorkHours += parseFloat(attend.total_work_hours || 0); }
|
||||||
|
if (status === 'vacation' && attend) { vacationDays += parseFloat(attend.vacation_days) || 1; }
|
||||||
|
if (status === 'mismatch') { mismatchCount++; mismatchDetails.hours_diff++; }
|
||||||
|
if (status === 'report_only') { mismatchCount++; mismatchDetails.missing_attendance++; }
|
||||||
|
if (status === 'attend_only') { mismatchCount++; mismatchDetails.missing_report++; }
|
||||||
|
|
||||||
|
// 연장근로: 8h 초과분
|
||||||
|
if (attend && attend.total_work_hours > 8) {
|
||||||
|
totalOvertimeHours += parseFloat(attend.total_work_hours) - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyRecords.push({
|
||||||
|
date: dateStr,
|
||||||
|
day_of_week: DAYS_KR[dayOfWeek],
|
||||||
|
is_holiday: isHoliday,
|
||||||
|
holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null),
|
||||||
|
work_report: report ? {
|
||||||
|
total_hours: parseFloat(report.total_hours),
|
||||||
|
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
|
||||||
|
} : null,
|
||||||
|
attendance: attend ? {
|
||||||
|
total_work_hours: parseFloat(attend.total_work_hours),
|
||||||
|
attendance_type: attend.attendance_type_name || '',
|
||||||
|
attendance_type_id: attend.attendance_type_id || null,
|
||||||
|
vacation_type: attend.vacation_type_name || null,
|
||||||
|
vacation_type_id: attend.vacation_type_id || null,
|
||||||
|
vacation_days: attend.vacation_days ? parseFloat(attend.vacation_days) : null
|
||||||
|
} : null,
|
||||||
|
status,
|
||||||
|
hours_diff: hoursDiff
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
total_work_days: totalWorkDays,
|
||||||
|
total_work_hours: parseFloat(totalWorkHours.toFixed(2)),
|
||||||
|
total_overtime_hours: parseFloat(totalOvertimeHours.toFixed(2)),
|
||||||
|
vacation_days: vacationDays,
|
||||||
|
mismatch_count: mismatchCount,
|
||||||
|
mismatch_details: mismatchDetails
|
||||||
|
},
|
||||||
|
confirmation: confirmation ? {
|
||||||
|
status: confirmation.status,
|
||||||
|
confirmed_at: confirmation.confirmed_at,
|
||||||
|
rejected_at: confirmation.rejected_at,
|
||||||
|
reject_reason: confirmation.reject_reason,
|
||||||
|
change_details: confirmation.change_details || null,
|
||||||
|
admin_checked: confirmation.admin_checked || 0
|
||||||
|
} : { status: 'pending', confirmed_at: null, reject_reason: null, change_details: null, admin_checked: 0 },
|
||||||
|
daily_records: dailyRecords
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MonthlyComparisonController = {
|
||||||
|
// GET /my-records
|
||||||
|
getMyRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const { year, month } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const worker = await Model.getWorkerInfo(userId);
|
||||||
|
const data = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: worker || { user_id: userId },
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getMyRecords error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /records (관리자용)
|
||||||
|
getRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, user_id } = req.query;
|
||||||
|
if (!year || !month || !user_id) return res.status(400).json({ success: false, message: 'year, month, user_id 필수' });
|
||||||
|
|
||||||
|
const reqUserId = req.user.user_id || req.user.id;
|
||||||
|
const targetUserId = parseInt(user_id);
|
||||||
|
|
||||||
|
// 본인 아니면 support_team 이상 필요
|
||||||
|
if (targetUserId !== reqUserId && !ADMIN_ROLES.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '접근 권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = await Model.getWorkerInfo(targetUserId);
|
||||||
|
const data = await buildComparisonData(targetUserId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: worker || { user_id: targetUserId },
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getRecords error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /confirm
|
||||||
|
confirm: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const { year, month, status, reject_reason } = req.body;
|
||||||
|
|
||||||
|
if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' });
|
||||||
|
if (!['confirmed', 'change_request'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'change_request'만 허용" });
|
||||||
|
const change_details = req.body.change_details || null;
|
||||||
|
if (status === 'change_request' && !change_details) {
|
||||||
|
return res.status(400).json({ success: false, message: '수정 내용을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요약 통계 계산
|
||||||
|
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
let notificationData = null;
|
||||||
|
if (status === 'change_request') {
|
||||||
|
const worker = await Model.getWorkerInfo(userId);
|
||||||
|
const recipients = await Model.getSupportTeamUsers();
|
||||||
|
notificationData = {
|
||||||
|
recipients,
|
||||||
|
title: '월간 근무 수정요청',
|
||||||
|
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}년 ${month}월 근무 내역 수정을 요청했습니다.`,
|
||||||
|
linkUrl: `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${year}&month=${month}`,
|
||||||
|
createdBy: userId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Model.upsertConfirmation({
|
||||||
|
user_id: userId, year: parseInt(year), month: parseInt(month), status,
|
||||||
|
reject_reason: null,
|
||||||
|
change_details: change_details ? JSON.stringify(change_details) : null,
|
||||||
|
...compData.summary
|
||||||
|
}, notificationData);
|
||||||
|
|
||||||
|
if (result.error) return res.status(409).json({ success: false, message: result.error });
|
||||||
|
|
||||||
|
const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '수정요청이 접수되었습니다. 관리자에게 알림이 전달됩니다.';
|
||||||
|
res.json({ success: true, data: result, message: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison confirm error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /review-send (관리자 → 확인요청 발송)
|
||||||
|
reviewSend: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, user_id } = req.body;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
const reviewedBy = req.user.user_id || req.user.id;
|
||||||
|
const userIds = user_id ? [parseInt(user_id)] : null;
|
||||||
|
const result = await Model.bulkReviewSend(parseInt(year), parseInt(month), userIds, reviewedBy);
|
||||||
|
if (result.error) return res.status(400).json({ success: false, message: result.error });
|
||||||
|
res.json({ success: true, data: result, message: `${result.count}명에게 확인요청을 발송했습니다.` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison reviewSend error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /review-respond (관리자 → 수정요청 응답)
|
||||||
|
reviewRespond: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, year, month, action, reject_reason } = req.body;
|
||||||
|
if (!user_id || !year || !month || !action) return res.status(400).json({ success: false, message: 'user_id, year, month, action 필수' });
|
||||||
|
if (!['approve', 'reject'].includes(action)) return res.status(400).json({ success: false, message: "action은 'approve' 또는 'reject'" });
|
||||||
|
if (action === 'reject' && (!reject_reason || !reject_reason.trim())) {
|
||||||
|
return res.status(400).json({ success: false, message: '거부 사유를 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const respondedBy = req.user.user_id || req.user.id;
|
||||||
|
const result = await Model.reviewRespond(parseInt(user_id), parseInt(year), parseInt(month), action, reject_reason, respondedBy);
|
||||||
|
if (result.error) return res.status(409).json({ success: false, message: result.error });
|
||||||
|
const msg = action === 'approve' ? '수정 승인 완료. 작업자에게 재확인 요청됩니다.' : '수정요청이 거부되었습니다.';
|
||||||
|
res.json({ success: true, data: result, message: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison reviewRespond error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /admin-check (관리자 개별 검토 태깅)
|
||||||
|
adminCheck: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, year, month, checked } = req.body;
|
||||||
|
if (!user_id || !year || !month) return res.status(400).json({ success: false, message: 'user_id, year, month 필수' });
|
||||||
|
const checkedBy = req.user.user_id || req.user.id;
|
||||||
|
const result = await Model.adminCheck(parseInt(user_id), parseInt(year), parseInt(month), !!checked, checkedBy);
|
||||||
|
res.json({ success: true, data: result, message: checked ? '검토완료 표시' : '검토 해제' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison adminCheck error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /all-status (support_team+)
|
||||||
|
getAllStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, department_id } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const workers = await Model.getAllStatus(parseInt(year), parseInt(month), department_id ? parseInt(department_id) : null);
|
||||||
|
|
||||||
|
let confirmed = 0, pending = 0, rejected = 0, review_sent = 0, change_request = 0;
|
||||||
|
workers.forEach(w => {
|
||||||
|
if (w.status === 'confirmed') confirmed++;
|
||||||
|
else if (w.status === 'rejected') rejected++;
|
||||||
|
else if (w.status === 'review_sent') review_sent++;
|
||||||
|
else if (w.status === 'change_request') change_request++;
|
||||||
|
else pending++;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
summary: { total_workers: workers.length, confirmed, pending, rejected, review_sent, change_request },
|
||||||
|
workers: workers.map(w => ({
|
||||||
|
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
|
||||||
|
department_name: w.department_name, status: w.status || 'pending',
|
||||||
|
confirmed_at: w.confirmed_at, reject_reason: w.reject_reason,
|
||||||
|
change_details: w.change_details || null,
|
||||||
|
admin_checked: w.admin_checked || 0,
|
||||||
|
total_work_days: w.total_work_days || 0,
|
||||||
|
total_work_hours: parseFloat(w.total_work_hours || 0),
|
||||||
|
total_overtime_hours: parseFloat(w.total_overtime_hours || 0),
|
||||||
|
vacation_days: parseFloat(w.vacation_days || 0),
|
||||||
|
mismatch_count: w.mismatch_count || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getAllStatus error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /export (support_team+, 전원 confirmed일 때만)
|
||||||
|
// 출근부 양식 — 업로드된 템플릿 매칭
|
||||||
|
exportExcel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const y = parseInt(year), m = parseInt(month);
|
||||||
|
|
||||||
|
// 전원 confirmed 체크
|
||||||
|
const statusList = await Model.getAllStatus(y, m);
|
||||||
|
const notConfirmed = statusList.filter(w => !w.status || w.status !== 'confirmed');
|
||||||
|
if (notConfirmed.length > 0) {
|
||||||
|
const pendingCount = notConfirmed.filter(w => !w.status || w.status === 'pending').length;
|
||||||
|
const rejectedCount = notConfirmed.filter(w => w.status === 'rejected').length;
|
||||||
|
const parts = [];
|
||||||
|
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
|
||||||
|
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
|
||||||
|
return res.status(403).json({ success: false, message: `${parts.join(', ')} 상태입니다. 전원 확인 완료 후 다운로드 가능합니다.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const { workers, attendance, vacations } = await Model.getExportData(y, m);
|
||||||
|
if (workers.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 근태 맵 구성: { user_id -> { day -> record } }
|
||||||
|
const attendMap = {};
|
||||||
|
attendance.forEach(a => {
|
||||||
|
const uid = a.user_id;
|
||||||
|
if (!attendMap[uid]) attendMap[uid] = {};
|
||||||
|
const d = a.record_date instanceof Date ? a.record_date : new Date(a.record_date);
|
||||||
|
const day = d.getDate();
|
||||||
|
attendMap[uid][day] = a;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연차 맵: { user_id -> { total, used, remaining } }
|
||||||
|
const vacMap = {};
|
||||||
|
vacations.forEach(v => { vacMap[v.user_id] = v; });
|
||||||
|
|
||||||
|
// 월 정보
|
||||||
|
const daysInMonth = new Date(y, m, 0).getDate();
|
||||||
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const yy = String(y).slice(-2);
|
||||||
|
const deptName = workers[0]?.department_name || '생산팀';
|
||||||
|
|
||||||
|
// 휴가 유형 → 출근부 텍스트 매핑
|
||||||
|
const VAC_TEXT = {
|
||||||
|
'ANNUAL': '연차', 'HALF_ANNUAL': '반차', 'ANNUAL_QUARTER': '반반차',
|
||||||
|
'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SPECIAL': '경조사'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
const ws = wb.addWorksheet(`${yy}.${m}월 출근부`);
|
||||||
|
|
||||||
|
// ── 기본 스타일 ──
|
||||||
|
const FONT = { name: '맑은 고딕', size: 12 };
|
||||||
|
const CENTER = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||||
|
const THIN_BORDER = {
|
||||||
|
top: { style: 'thin' }, bottom: { style: 'thin' },
|
||||||
|
left: { style: 'thin' }, right: { style: 'thin' }
|
||||||
|
};
|
||||||
|
const RED_FONT = { ...FONT, color: { argb: 'FFFF0000' } };
|
||||||
|
|
||||||
|
// ── 열 폭 설정 ──
|
||||||
|
// A=이름(10), B=담당(6), C~(daysInMonth)=날짜열(5), 총시간(8), 신규(8), 사용(8), 잔여(8), 비고(14)
|
||||||
|
const dayColStart = 3; // C열 = col 3
|
||||||
|
const dayColEnd = dayColStart + daysInMonth - 1;
|
||||||
|
const colTotal = dayColEnd + 1; // 총시간
|
||||||
|
const colNewVac = colTotal + 1; // N월 신규
|
||||||
|
const colUsedVac = colNewVac + 1; // N월 사용
|
||||||
|
const colRemVac = colUsedVac + 1; // N월 잔여
|
||||||
|
const colNote1 = colRemVac + 1; // 비고 (2열 병합)
|
||||||
|
const colNote2 = colNote1 + 1;
|
||||||
|
const lastCol = colNote2;
|
||||||
|
|
||||||
|
ws.getColumn(1).width = 10; // A 이름
|
||||||
|
ws.getColumn(2).width = 6; // B 담당
|
||||||
|
for (let c = dayColStart; c <= dayColEnd; c++) ws.getColumn(c).width = 5;
|
||||||
|
ws.getColumn(colTotal).width = 8;
|
||||||
|
ws.getColumn(colNewVac).width = 8;
|
||||||
|
ws.getColumn(colUsedVac).width = 8;
|
||||||
|
ws.getColumn(colRemVac).width = 8;
|
||||||
|
ws.getColumn(colNote1).width = 7;
|
||||||
|
ws.getColumn(colNote2).width = 7;
|
||||||
|
|
||||||
|
// ── Row 1: 빈 행 (여백) ──
|
||||||
|
ws.getRow(1).height = 10;
|
||||||
|
|
||||||
|
// ── Row 2: 부서명 ──
|
||||||
|
ws.getRow(2).height = 30;
|
||||||
|
ws.getCell(2, 1).value = '부서명';
|
||||||
|
ws.getCell(2, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(2, 1).alignment = CENTER;
|
||||||
|
ws.mergeCells(2, 2, 2, 5);
|
||||||
|
ws.getCell(2, 2).value = deptName;
|
||||||
|
ws.getCell(2, 2).font = FONT;
|
||||||
|
ws.getCell(2, 2).alignment = { ...CENTER, horizontal: 'left' };
|
||||||
|
|
||||||
|
// ── Row 3: 근로기간 ──
|
||||||
|
ws.getRow(3).height = 30;
|
||||||
|
ws.getCell(3, 1).value = '근로기간';
|
||||||
|
ws.getCell(3, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(3, 1).alignment = CENTER;
|
||||||
|
ws.mergeCells(3, 2, 3, 5);
|
||||||
|
ws.getCell(3, 2).value = `${y}년 ${m}월`;
|
||||||
|
ws.getCell(3, 2).font = FONT;
|
||||||
|
ws.getCell(3, 2).alignment = { ...CENTER, horizontal: 'left' };
|
||||||
|
|
||||||
|
// ── Row 4-5: 헤더 (병합) ──
|
||||||
|
ws.getRow(4).height = 40;
|
||||||
|
ws.getRow(5).height = 40;
|
||||||
|
|
||||||
|
// A4:A5 이름
|
||||||
|
ws.mergeCells(4, 1, 5, 1);
|
||||||
|
ws.getCell(4, 1).value = '이름';
|
||||||
|
ws.getCell(4, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, 1).alignment = CENTER;
|
||||||
|
ws.getCell(4, 1).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// B4:B5 담당
|
||||||
|
ws.mergeCells(4, 2, 5, 2);
|
||||||
|
ws.getCell(4, 2).value = '담당';
|
||||||
|
ws.getCell(4, 2).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, 2).alignment = CENTER;
|
||||||
|
ws.getCell(4, 2).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 날짜 헤더: Row4=일자, Row5=요일
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const col = dayColStart + day - 1;
|
||||||
|
const date = new Date(y, m - 1, day);
|
||||||
|
const dow = date.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
|
||||||
|
// Row 4: 날짜 숫자
|
||||||
|
const cell4 = ws.getCell(4, col);
|
||||||
|
cell4.value = day;
|
||||||
|
cell4.font = isWeekend ? { ...FONT, bold: true, color: { argb: 'FFFF0000' } } : { ...FONT, bold: true };
|
||||||
|
cell4.alignment = CENTER;
|
||||||
|
cell4.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// Row 5: 요일
|
||||||
|
const cell5 = ws.getCell(5, col);
|
||||||
|
cell5.value = DAYS_KR[dow];
|
||||||
|
cell5.font = isWeekend ? RED_FONT : FONT;
|
||||||
|
cell5.alignment = CENTER;
|
||||||
|
cell5.border = THIN_BORDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총시간 헤더 (4:5 병합)
|
||||||
|
ws.mergeCells(4, colTotal, 5, colTotal);
|
||||||
|
ws.getCell(4, colTotal).value = '총시간';
|
||||||
|
ws.getCell(4, colTotal).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colTotal).alignment = CENTER;
|
||||||
|
ws.getCell(4, colTotal).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 헤더: Row4 = "N월" 병합, Row5 = 신규/사용/잔여
|
||||||
|
ws.mergeCells(4, colNewVac, 4, colRemVac);
|
||||||
|
ws.getCell(4, colNewVac).value = `${m}월`;
|
||||||
|
ws.getCell(4, colNewVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colNewVac).alignment = CENTER;
|
||||||
|
ws.getCell(4, colNewVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colNewVac).value = '신규';
|
||||||
|
ws.getCell(5, colNewVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colNewVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colNewVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colUsedVac).value = '사용';
|
||||||
|
ws.getCell(5, colUsedVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colUsedVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colUsedVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colRemVac).value = '잔여';
|
||||||
|
ws.getCell(5, colRemVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colRemVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colRemVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 비고 헤더 (4:5, 2열 병합)
|
||||||
|
ws.mergeCells(4, colNote1, 5, colNote2);
|
||||||
|
ws.getCell(4, colNote1).value = '비고';
|
||||||
|
ws.getCell(4, colNote1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colNote1).alignment = CENTER;
|
||||||
|
ws.getCell(4, colNote1).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// ── 데이터 행 ──
|
||||||
|
const dataStartRow = 6;
|
||||||
|
workers.forEach((worker, idx) => {
|
||||||
|
const row = dataStartRow + idx;
|
||||||
|
ws.getRow(row).height = 60;
|
||||||
|
const userAttend = attendMap[worker.user_id] || {};
|
||||||
|
const userVac = vacMap[worker.user_id] || { total_days: 0, used_days: 0, remaining_days: 0 };
|
||||||
|
|
||||||
|
// A: 이름
|
||||||
|
const cellName = ws.getCell(row, 1);
|
||||||
|
cellName.value = worker.worker_name;
|
||||||
|
cellName.font = FONT;
|
||||||
|
cellName.alignment = CENTER;
|
||||||
|
cellName.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// B: 직종
|
||||||
|
const cellJob = ws.getCell(row, 2);
|
||||||
|
cellJob.value = worker.job_type || '';
|
||||||
|
cellJob.font = FONT;
|
||||||
|
cellJob.alignment = CENTER;
|
||||||
|
cellJob.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// C~: 일별 데이터
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const col = dayColStart + day - 1;
|
||||||
|
const cell = ws.getCell(row, col);
|
||||||
|
const date = new Date(y, m - 1, day);
|
||||||
|
const dow = date.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
const rec = userAttend[day];
|
||||||
|
|
||||||
|
if (rec && rec.vacation_type_code) {
|
||||||
|
// 휴가
|
||||||
|
cell.value = VAC_TEXT[rec.vacation_type_code] || rec.vacation_type_name || '휴가';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else if (isWeekend && (!rec || !rec.total_work_hours || parseFloat(rec.total_work_hours) === 0)) {
|
||||||
|
// 주말 + 근무 없음 → 휴무
|
||||||
|
cell.value = '휴무';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else if (rec && parseFloat(rec.total_work_hours) > 0) {
|
||||||
|
// 정상 출근 → 근무시간(숫자)
|
||||||
|
cell.value = parseFloat(rec.total_work_hours);
|
||||||
|
cell.numFmt = '0.00';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else {
|
||||||
|
// 데이터 없음 (평일 미출근 등)
|
||||||
|
cell.value = '';
|
||||||
|
cell.font = FONT;
|
||||||
|
}
|
||||||
|
cell.alignment = CENTER;
|
||||||
|
cell.border = THIN_BORDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총시간 = SUM 수식 (C열~마지막 날짜열)
|
||||||
|
const firstDayCol = ws.getColumn(dayColStart).letter;
|
||||||
|
const lastDayCol = ws.getColumn(dayColEnd).letter;
|
||||||
|
const cellTotal = ws.getCell(row, colTotal);
|
||||||
|
cellTotal.value = { formula: `SUM(${firstDayCol}${row}:${lastDayCol}${row})` };
|
||||||
|
cellTotal.numFmt = '0.00';
|
||||||
|
cellTotal.font = { ...FONT, bold: true };
|
||||||
|
cellTotal.alignment = CENTER;
|
||||||
|
cellTotal.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 신규
|
||||||
|
const cellNew = ws.getCell(row, colNewVac);
|
||||||
|
cellNew.value = parseFloat(userVac.total_days) || 0;
|
||||||
|
cellNew.numFmt = '0.0';
|
||||||
|
cellNew.font = FONT;
|
||||||
|
cellNew.alignment = CENTER;
|
||||||
|
cellNew.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 사용
|
||||||
|
const cellUsed = ws.getCell(row, colUsedVac);
|
||||||
|
cellUsed.value = parseFloat(userVac.used_days) || 0;
|
||||||
|
cellUsed.numFmt = '0.0';
|
||||||
|
cellUsed.font = FONT;
|
||||||
|
cellUsed.alignment = CENTER;
|
||||||
|
cellUsed.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 잔여 = 수식(신규 - 사용)
|
||||||
|
const newCol = ws.getColumn(colNewVac).letter;
|
||||||
|
const usedCol = ws.getColumn(colUsedVac).letter;
|
||||||
|
const cellRem = ws.getCell(row, colRemVac);
|
||||||
|
cellRem.value = { formula: `${newCol}${row}-${usedCol}${row}` };
|
||||||
|
cellRem.numFmt = '0.0';
|
||||||
|
cellRem.font = { ...FONT, bold: true };
|
||||||
|
cellRem.alignment = CENTER;
|
||||||
|
cellRem.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 비고 (병합)
|
||||||
|
ws.mergeCells(row, colNote1, row, colNote2);
|
||||||
|
const cellNote = ws.getCell(row, colNote1);
|
||||||
|
cellNote.value = '';
|
||||||
|
cellNote.font = FONT;
|
||||||
|
cellNote.alignment = CENTER;
|
||||||
|
cellNote.border = THIN_BORDER;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 응답 ──
|
||||||
|
const filename = encodeURIComponent(`출근부_${y}.${String(m).padStart(2, '0')}_${deptName}.xlsx`);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
await wb.xlsx.write(res);
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison exportExcel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '엑셀 생성 실패' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = MonthlyComparisonController;
|
||||||
210
system1-factory/api/controllers/proxyInputController.js
Normal file
210
system1-factory/api/controllers/proxyInputController.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 컨트롤러
|
||||||
|
*/
|
||||||
|
const ProxyInputModel = require('../models/proxyInputModel');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const logger = require('../../shared/utils/logger');
|
||||||
|
|
||||||
|
const ProxyInputController = {
|
||||||
|
/**
|
||||||
|
* POST /api/proxy-input — 대리입력 (단일 트랜잭션)
|
||||||
|
*/
|
||||||
|
proxyInput: async (req, res) => {
|
||||||
|
const { session_date, leader_id, entries, safety_notes, work_location } = req.body;
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!session_date) {
|
||||||
|
return res.status(400).json({ success: false, message: '날짜는 필수입니다.' });
|
||||||
|
}
|
||||||
|
if (!entries || !Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '작업자 정보는 최소 1명 필요합니다.' });
|
||||||
|
}
|
||||||
|
if (entries.length > 30) {
|
||||||
|
return res.status(400).json({ success: false, message: '한 번에 30명까지 입력 가능합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 유효성 (과거 30일 ~ 오늘)
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const inputDate = new Date(session_date);
|
||||||
|
const diffDays = Math.floor((today - inputDate) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '미래 날짜는 입력할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
if (diffDays > 30) {
|
||||||
|
return res.status(400).json({ success: false, message: '30일 이내 날짜만 입력 가능합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// entries 필수 필드 검사
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.user_id || !entry.project_id || !entry.work_type_id || !entry.work_hours) {
|
||||||
|
return res.status(400).json({ success: false, message: '각 작업자의 user_id, project_id, work_type_id, work_hours는 필수입니다.' });
|
||||||
|
}
|
||||||
|
if (entry.work_hours <= 0 || entry.work_hours > 24) {
|
||||||
|
return res.status(400).json({ success: false, message: '근무 시간은 0 초과 24 이하여야 합니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const userIds = entries.map(e => e.user_id);
|
||||||
|
|
||||||
|
// 1. 기존 보고서 확인 (UPSERT 분기)
|
||||||
|
const [existingReports] = await conn.query(
|
||||||
|
`SELECT id, user_id FROM daily_work_reports WHERE report_date = ? AND user_id IN (${userIds.map(() => '?').join(',')})`,
|
||||||
|
[session_date, ...userIds]
|
||||||
|
);
|
||||||
|
const existingMap = {};
|
||||||
|
existingReports.forEach(r => { existingMap[r.user_id] = r.id; });
|
||||||
|
|
||||||
|
// 2. 신규 작업자용 TBM 세션 생성 (기존 있으면 재사용)
|
||||||
|
let sessionId = null;
|
||||||
|
const newUserIds = userIds.filter(id => !existingMap[id]);
|
||||||
|
if (newUserIds.length > 0) {
|
||||||
|
const sessionResult = await ProxyInputModel.createProxySession(conn, {
|
||||||
|
session_date,
|
||||||
|
leader_id: leader_id || userId,
|
||||||
|
proxy_input_by: userId,
|
||||||
|
created_by: userId,
|
||||||
|
safety_notes: safety_notes || '',
|
||||||
|
work_location: work_location || ''
|
||||||
|
});
|
||||||
|
sessionId = sessionResult.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 각 entry 처리 (UPSERT)
|
||||||
|
const createdWorkers = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const existingReportId = existingMap[entry.user_id];
|
||||||
|
|
||||||
|
if (existingReportId) {
|
||||||
|
// UPDATE 기존 보고서
|
||||||
|
await conn.query(`
|
||||||
|
UPDATE daily_work_reports SET
|
||||||
|
project_id = ?, work_type_id = ?, work_hours = ?,
|
||||||
|
work_status_id = ?, start_time = ?, end_time = ?, note = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`, [entry.project_id, entry.work_type_id, entry.work_hours,
|
||||||
|
entry.work_status_id || 1, entry.start_time || null, entry.end_time || null,
|
||||||
|
entry.note || '', existingReportId]);
|
||||||
|
|
||||||
|
createdWorkers.push({ user_id: entry.user_id, report_id: existingReportId, action: 'updated' });
|
||||||
|
} else {
|
||||||
|
// INSERT 신규 — TBM 배정 + 작업보고서
|
||||||
|
const assignResult = await ProxyInputModel.createTeamAssignment(conn, {
|
||||||
|
session_id: sessionId,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
work_type_id: entry.work_type_id,
|
||||||
|
task_id: entry.task_id || null,
|
||||||
|
workplace_id: entry.workplace_id || null,
|
||||||
|
work_hours: entry.work_hours
|
||||||
|
});
|
||||||
|
const assignmentId = assignResult.insertId;
|
||||||
|
|
||||||
|
const reportResult = await ProxyInputModel.createWorkReport(conn, {
|
||||||
|
report_date: session_date,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
work_type_id: entry.work_type_id,
|
||||||
|
task_id: entry.task_id || null,
|
||||||
|
work_status_id: entry.work_status_id || 1,
|
||||||
|
work_hours: entry.work_hours,
|
||||||
|
start_time: entry.start_time || null,
|
||||||
|
end_time: entry.end_time || null,
|
||||||
|
note: entry.note || '',
|
||||||
|
tbm_session_id: sessionId,
|
||||||
|
tbm_assignment_id: assignmentId,
|
||||||
|
created_by: userId,
|
||||||
|
created_by_name: req.user.name || req.user.username || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
createdWorkers.push({ user_id: entry.user_id, report_id: reportResult.insertId, action: 'created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 처리 (defect_hours > 0 && 기존 defect 없을 때만)
|
||||||
|
const defectHours = parseFloat(entry.defect_hours) || 0;
|
||||||
|
const reportId = existingReportId || createdWorkers[createdWorkers.length - 1].report_id;
|
||||||
|
if (defectHours > 0) {
|
||||||
|
const [existingDefects] = await conn.query(
|
||||||
|
'SELECT defect_id FROM work_report_defects WHERE report_id = ?', [reportId]
|
||||||
|
);
|
||||||
|
if (existingDefects.length === 0) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO work_report_defects (report_id, defect_hours, category_id, item_id, note) VALUES (?, ?, ?, ?, '대리입력')`,
|
||||||
|
[reportId, defectHours, entry.defect_category_id || null, entry.defect_item_id || null]
|
||||||
|
);
|
||||||
|
await conn.query(
|
||||||
|
'UPDATE daily_work_reports SET error_hours = ?, work_status_id = 2 WHERE id = ?',
|
||||||
|
[defectHours, reportId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: `${entries.length}명의 대리입력이 완료되었습니다.`,
|
||||||
|
data: {
|
||||||
|
session_id: sessionId,
|
||||||
|
is_proxy_input: true,
|
||||||
|
created_reports: entries.length,
|
||||||
|
workers: createdWorkers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try { await conn.rollback(); } catch (e) {}
|
||||||
|
logger.error('대리입력 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/proxy-input/daily-status — 일별 현황
|
||||||
|
*/
|
||||||
|
getDailyStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.query;
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ success: false, message: '날짜(date) 파라미터는 필수입니다.' });
|
||||||
|
}
|
||||||
|
const data = await ProxyInputModel.getDailyStatus(date);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('일별 현황 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/proxy-input/daily-status/detail — 작업자별 상세
|
||||||
|
*/
|
||||||
|
getDailyStatusDetail: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, user_id } = req.query;
|
||||||
|
if (!date || !user_id) {
|
||||||
|
return res.status(400).json({ success: false, message: 'date와 user_id 파라미터는 필수입니다.' });
|
||||||
|
}
|
||||||
|
const data = await ProxyInputModel.getDailyStatusDetail(date, parseInt(user_id));
|
||||||
|
if (!data.worker) {
|
||||||
|
return res.status(404).json({ success: false, message: '작업자를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('일별 상세 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ProxyInputController;
|
||||||
175
system1-factory/api/controllers/purchaseBatchController.js
Normal file
175
system1-factory/api/controllers/purchaseBatchController.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const PurchaseBatchModel = require('../models/purchaseBatchModel');
|
||||||
|
const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
||||||
|
const { saveBase64Image } = require('../services/imageUploadService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const notifyHelper = require('../../../shared/utils/notifyHelper');
|
||||||
|
|
||||||
|
const PurchaseBatchController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.query;
|
||||||
|
const rows = await PurchaseBatchModel.getAll({ status });
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
// 포함된 요청 목록도 함께 반환
|
||||||
|
const requests = await PurchaseRequestModel.getAll({ batch_id: req.params.id });
|
||||||
|
res.json({ success: true, data: { ...batch, requests } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch getById error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 생성 + 요청 포함
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { batch_name, category, vendor_id, notes, request_ids } = req.body;
|
||||||
|
if (!request_ids || !request_ids.length) {
|
||||||
|
return res.status(400).json({ success: false, message: '그룹에 포함할 신청 건을 선택해주세요.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchId = await PurchaseBatchModel.create({
|
||||||
|
batchName: batch_name,
|
||||||
|
category,
|
||||||
|
vendorId: vendor_id,
|
||||||
|
notes,
|
||||||
|
createdBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await PurchaseBatchModel.addRequests(batchId, request_ids);
|
||||||
|
|
||||||
|
// 신청자들에게 알림
|
||||||
|
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(batchId);
|
||||||
|
if (requesterIds.length > 0) {
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '구매 진행 안내',
|
||||||
|
message: '신청하신 소모품 구매가 진행됩니다.',
|
||||||
|
link_url: '/pages/purchase/request-mobile.html',
|
||||||
|
target_user_ids: requesterIds,
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = await PurchaseBatchModel.getById(batchId);
|
||||||
|
res.status(201).json({ success: true, data: batch, message: '그룹이 생성되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch create error:', err);
|
||||||
|
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { batch_name, category, vendor_id, notes, add_request_ids, remove_request_ids } = req.body;
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
|
||||||
|
if (batch_name !== undefined || category !== undefined || vendor_id !== undefined || notes !== undefined) {
|
||||||
|
await PurchaseBatchModel.update(req.params.id, {
|
||||||
|
batchName: batch_name !== undefined ? batch_name : batch.batch_name,
|
||||||
|
category: category !== undefined ? category : batch.category,
|
||||||
|
vendorId: vendor_id !== undefined ? vendor_id : batch.vendor_id,
|
||||||
|
notes: notes !== undefined ? notes : batch.notes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add_request_ids && add_request_ids.length) {
|
||||||
|
await PurchaseBatchModel.addRequests(req.params.id, add_request_ids);
|
||||||
|
}
|
||||||
|
if (remove_request_ids && remove_request_ids.length) {
|
||||||
|
await PurchaseBatchModel.removeRequests(req.params.id, remove_request_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
res.json({ success: true, data: updated, message: '그룹이 수정되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch update error:', err);
|
||||||
|
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = await PurchaseBatchModel.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 그룹만 삭제할 수 있습니다.' });
|
||||||
|
res.json({ success: true, message: '그룹이 삭제되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch delete error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 일괄 구매 처리
|
||||||
|
purchase: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
if (batch.status !== 'pending') {
|
||||||
|
return res.status(400).json({ success: false, message: '대기 상태의 그룹만 구매 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// batch 내 모든 요청 purchased 전환
|
||||||
|
await PurchaseRequestModel.markBatchPurchased(req.params.id);
|
||||||
|
await PurchaseBatchModel.markPurchased(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true, message: '일괄 구매 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch purchase error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 일괄 입고 처리
|
||||||
|
receive: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
if (batch.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 그룹만 입고 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { received_location, photo } = req.body;
|
||||||
|
let receivedPhotoPath = null;
|
||||||
|
if (photo) {
|
||||||
|
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
|
||||||
|
}
|
||||||
|
|
||||||
|
await PurchaseRequestModel.receiveBatch(req.params.id, {
|
||||||
|
receivedPhotoPath,
|
||||||
|
receivedLocation: received_location,
|
||||||
|
receivedBy: req.user.id
|
||||||
|
});
|
||||||
|
await PurchaseBatchModel.markReceived(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
// 신청자들에게 입고 알림
|
||||||
|
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(req.params.id);
|
||||||
|
if (requesterIds.length > 0) {
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 입고 완료',
|
||||||
|
message: `소모품이 입고되었습니다.${received_location ? ' 보관위치: ' + received_location : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html',
|
||||||
|
target_user_ids: requesterIds,
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '일괄 입고 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch receive error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PurchaseBatchController;
|
||||||
@@ -2,14 +2,16 @@ const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
|||||||
const PurchaseModel = require('../models/purchaseModel');
|
const PurchaseModel = require('../models/purchaseModel');
|
||||||
const { saveBase64Image } = require('../services/imageUploadService');
|
const { saveBase64Image } = require('../services/imageUploadService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const notifyHelper = require('../../../shared/utils/notifyHelper');
|
||||||
|
const koreanSearch = require('../utils/koreanSearch');
|
||||||
|
|
||||||
const PurchaseRequestController = {
|
const PurchaseRequestController = {
|
||||||
// 구매신청 목록
|
// 구매신청 목록
|
||||||
getAll: async (req, res) => {
|
getAll: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, category, from_date, to_date } = req.query;
|
const { status, category, from_date, to_date, batch_id } = req.query;
|
||||||
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
||||||
const filters = { status, category, from_date, to_date };
|
const filters = { status, category, from_date, to_date, batch_id };
|
||||||
if (!isAdmin) filters.requester_id = req.user.id;
|
if (!isAdmin) filters.requester_id = req.user.id;
|
||||||
const rows = await PurchaseRequestModel.getAll(filters);
|
const rows = await PurchaseRequestModel.getAll(filters);
|
||||||
res.json({ success: true, data: rows });
|
res.json({ success: true, data: rows });
|
||||||
@@ -113,6 +115,188 @@ const PurchaseRequestController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
|
||||||
|
registerAndRequest: async (req, res) => {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
let conn;
|
||||||
|
try {
|
||||||
|
const { item_name, spec, maker, category, quantity, notes, photo } = req.body;
|
||||||
|
if (!item_name || !item_name.trim()) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
if (!quantity || quantity < 1) {
|
||||||
|
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
conn = await db.getConnection();
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 1. 소모품 마스터 등록 (중복 확인)
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
`SELECT item_id FROM consumable_items
|
||||||
|
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||||
|
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||||
|
[item_name.trim(), spec || null, spec || null, maker || null, maker || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
let itemId;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
itemId = existing[0].item_id;
|
||||||
|
} else {
|
||||||
|
const [insertResult] = await conn.query(
|
||||||
|
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
|
||||||
|
[item_name.trim(), spec || null, maker || null, category || 'consumable']
|
||||||
|
);
|
||||||
|
itemId = insertResult.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에)
|
||||||
|
let photo_path = null;
|
||||||
|
if (photo) {
|
||||||
|
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 구매 신청 생성
|
||||||
|
const [reqResult] = await conn.query(
|
||||||
|
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path)
|
||||||
|
VALUES (?, ?, ?, CURDATE(), ?, ?)`,
|
||||||
|
[itemId, quantity, req.user.id, notes || null, photo_path]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
// 검색 캐시 무효화
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
|
||||||
|
const request = await PurchaseRequestModel.getById(reqResult.insertId);
|
||||||
|
res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (conn) await conn.rollback().catch(() => {});
|
||||||
|
logger.error('registerAndRequest error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
if (conn) conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 일괄 신청 (장바구니, 트랜잭션)
|
||||||
|
bulkCreate: async (req, res) => {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
let conn;
|
||||||
|
try {
|
||||||
|
const { items, photo } = req.body;
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.item_id && !item.item_name) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
if (!item.quantity || item.quantity < 1) {
|
||||||
|
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
|
||||||
|
let photo_path = null;
|
||||||
|
if (photo) {
|
||||||
|
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
conn = await db.getConnection();
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const createdIds = [];
|
||||||
|
let newItemRegistered = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let itemId = item.item_id || null;
|
||||||
|
|
||||||
|
// 신규 품목 등록 (is_new)
|
||||||
|
if (item.is_new && item.item_name) {
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
`SELECT item_id FROM consumable_items
|
||||||
|
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||||
|
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||||
|
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
itemId = existing[0].item_id;
|
||||||
|
} else {
|
||||||
|
// 신규 품목 사진 저장 (마스터에)
|
||||||
|
let itemPhotoPath = null;
|
||||||
|
if (item.item_photo) {
|
||||||
|
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
|
||||||
|
}
|
||||||
|
const [ins] = await conn.query(
|
||||||
|
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
|
||||||
|
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
|
||||||
|
);
|
||||||
|
itemId = ins.insertId;
|
||||||
|
newItemRegistered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purchase_request 생성
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
|
||||||
|
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
|
||||||
|
item.quantity, req.user.id, item.notes || null, photo_path]
|
||||||
|
);
|
||||||
|
createdIds.push(result.insertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
if (newItemRegistered) koreanSearch.clearCache();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { request_ids: createdIds, count: createdIds.length },
|
||||||
|
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (conn) await conn.rollback().catch(() => {});
|
||||||
|
logger.error('bulkCreate error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
if (conn) conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 품목 마스터 사진 등록/업데이트
|
||||||
|
updateItemPhoto: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photo } = req.body;
|
||||||
|
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
|
||||||
|
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
|
||||||
|
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('updateItemPhoto error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 스마트 검색 (초성 + 별칭 + substring)
|
||||||
|
search: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
const results = await koreanSearch.search(q || '');
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest search error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 소모품 목록 (select용)
|
// 소모품 목록 (select용)
|
||||||
getConsumableItems: async (req, res) => {
|
getConsumableItems: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +317,138 @@ const PurchaseRequestController = {
|
|||||||
logger.error('Vendors get error:', err);
|
logger.error('Vendors get error:', err);
|
||||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 내 신청 목록 (모바일용, 페이지네이션)
|
||||||
|
getMyRequests: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
const { status } = req.query;
|
||||||
|
const result = await PurchaseRequestModel.getMyRequests(req.user.id, { page, limit, status });
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest getMyRequests error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 개별 입고 처리 (admin)
|
||||||
|
receive: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 입고 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { received_location, photo } = req.body;
|
||||||
|
let receivedPhotoPath = null;
|
||||||
|
if (photo) {
|
||||||
|
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await PurchaseRequestModel.receive(req.params.id, {
|
||||||
|
receivedPhotoPath,
|
||||||
|
receivedLocation: received_location || null,
|
||||||
|
receivedBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// batch 내 전체 입고 완료 시 batch.status 자동 전환
|
||||||
|
if (existing.batch_id) {
|
||||||
|
const allReceived = await PurchaseRequestModel.checkBatchAllReceived(existing.batch_id);
|
||||||
|
if (allReceived) {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ? WHERE batch_id = ?`,
|
||||||
|
[req.user.id, existing.batch_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신청자에게 입고 알림
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 입고 완료',
|
||||||
|
message: `${existing.item_name || existing.custom_item_name} 입고 완료${received_location ? '. 보관위치: ' + received_location : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||||
|
target_user_ids: [existing.requester_id],
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated, message: '입고 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest receive error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구매 취소 (purchased → cancelled)
|
||||||
|
cancel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 취소할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const { cancel_reason } = req.body;
|
||||||
|
const updated = await PurchaseRequestModel.cancelPurchase(req.params.id, {
|
||||||
|
cancelledBy: req.user.id,
|
||||||
|
cancelReason: cancel_reason
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: updated, message: '구매가 취소되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest cancel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 반품 (received → returned)
|
||||||
|
returnItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'received') {
|
||||||
|
return res.status(400).json({ success: false, message: '입고완료 상태의 신청만 반품할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const { cancel_reason } = req.body;
|
||||||
|
const updated = await PurchaseRequestModel.returnItem(req.params.id, {
|
||||||
|
cancelledBy: req.user.id,
|
||||||
|
cancelReason: cancel_reason
|
||||||
|
});
|
||||||
|
|
||||||
|
// 신청자에게 반품 알림
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 반품 처리',
|
||||||
|
message: `${existing.item_name || existing.custom_item_name} 반품 처리되었습니다.${cancel_reason ? ' 사유: ' + cancel_reason : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||||
|
target_user_ids: [existing.requester_id],
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated, message: '반품 처리되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest return error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 취소 → 대기로 되돌리기
|
||||||
|
revertCancel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'cancelled') {
|
||||||
|
return res.status(400).json({ success: false, message: '취소 상태의 신청만 되돌릴 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const updated = await PurchaseRequestModel.revertCancel(req.params.id);
|
||||||
|
res.json({ success: true, data: updated, message: '대기 상태로 되돌렸습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest revertCancel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,37 @@ const ScheduleController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === 제품유형 ===
|
||||||
|
getProductTypes: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await ScheduleModel.getProductTypes();
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Schedule getProductTypes error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 표준공정 자동 생성 ===
|
||||||
|
generateFromTemplate: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { project_id, product_type_code } = req.body;
|
||||||
|
if (!project_id || !product_type_code) {
|
||||||
|
return res.status(400).json({ success: false, message: '프로젝트와 제품유형을 선택해주세요.' });
|
||||||
|
}
|
||||||
|
const result = await ScheduleModel.generateFromTemplate(
|
||||||
|
project_id, product_type_code, req.user.user_id || req.user.id
|
||||||
|
);
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(409).json({ success: false, message: result.error });
|
||||||
|
}
|
||||||
|
res.status(201).json({ success: true, data: result, message: `${result.created}개 표준공정이 생성되었습니다.` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Schedule generateFromTemplate error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// === 부적합 연동 ===
|
// === 부적합 연동 ===
|
||||||
getNonconformance: async (req, res) => {
|
getNonconformance: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ const SettlementController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 요약
|
||||||
|
getMonthlyReceivedSummary: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year_month } = req.query;
|
||||||
|
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||||
|
const categorySummary = await SettlementModel.getCategorySummaryByReceived(year_month);
|
||||||
|
res.json({ success: true, data: { categorySummary } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Settlement received summary error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 상세 목록
|
||||||
|
getMonthlyReceivedList: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year_month } = req.query;
|
||||||
|
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||||
|
const rows = await SettlementModel.getMonthlyReceived(year_month);
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Settlement received list error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 정산 완료
|
// 정산 완료
|
||||||
complete: async (req, res) => {
|
complete: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ const vacationBalanceController = {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const balance of balances) {
|
for (const balance of balances) {
|
||||||
const { user_id, vacation_type_id, year, total_days, notes } = balance;
|
const { user_id, vacation_type_id, year, total_days, notes, balance_type } = balance;
|
||||||
|
|
||||||
if (!user_id || !vacation_type_id || !year || total_days === undefined) {
|
if (!user_id || !vacation_type_id || !year || total_days === undefined) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
@@ -221,17 +221,17 @@ const vacationBalanceController = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const btype = balance_type || 'AUTO';
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO vacation_balance_details
|
INSERT INTO sp_vacation_balances
|
||||||
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, 0, ?, ?, ?, NULL)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
total_days = VALUES(total_days),
|
total_days = VALUES(total_days),
|
||||||
notes = VALUES(notes),
|
notes = VALUES(notes)
|
||||||
updated_at = NOW()
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by]);
|
await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by, btype]);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('휴가 잔액 저장 오류:', err);
|
logger.error('휴가 잔액 저장 오류:', err);
|
||||||
|
|||||||
@@ -201,6 +201,15 @@ const vacationRequestController = {
|
|||||||
return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' });
|
return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sp_vacation_balances 차감 (특별휴가 우선 → 이월 → 기본 순서)
|
||||||
|
const request = results[0];
|
||||||
|
const year = new Date(request.start_date).getFullYear();
|
||||||
|
const daysUsed = parseFloat(request.days_used) || 0;
|
||||||
|
if (daysUsed > 0) {
|
||||||
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
await vacationBalanceModel.deductByPriority(request.user_id, year, daysUsed);
|
||||||
|
}
|
||||||
|
|
||||||
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
|
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
|
||||||
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ exports.up = async function(knex) {
|
|||||||
table.increments('id').primary();
|
table.increments('id').primary();
|
||||||
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
|
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
|
||||||
table.string('type_name', 50).notNullable().comment('휴가 이름');
|
table.string('type_name', 50).notNullable().comment('휴가 이름');
|
||||||
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
|
table.decimal('deduct_days', 4, 2).defaultTo(1.00).comment('차감 일수');
|
||||||
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Push 구독 테이블 생성
|
-- Push 구독 테이블 생성
|
||||||
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
|
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p"$MYSQL_PASSWORD" hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- schedule_entries 확장: 작업보고서 매핑 + 위험성평가 연결 + 생성 출처
|
||||||
|
ALTER TABLE schedule_entries ADD COLUMN work_type_id INT NULL COMMENT 'work_types FK (작업보고서 매핑)';
|
||||||
|
|
||||||
|
ALTER TABLE schedule_entries ADD COLUMN risk_assessment_id INT NULL COMMENT 'risk_projects FK';
|
||||||
|
|
||||||
|
ALTER TABLE schedule_entries ADD COLUMN source VARCHAR(20) DEFAULT 'manual' COMMENT '생성 출처 (manual/template)';
|
||||||
|
|
||||||
|
-- schedule_phases 확장: 제품유형별 phase 구분
|
||||||
|
ALTER TABLE schedule_phases ADD COLUMN product_type_id INT NULL COMMENT 'NULL=범용, 값=해당 제품유형 전용';
|
||||||
|
|
||||||
|
-- FK는 product_types 테이블 존재 시에만 생성 (tkuser 마이그레이션 의존)
|
||||||
|
-- work_type_id FK
|
||||||
|
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_work_type
|
||||||
|
FOREIGN KEY (work_type_id) REFERENCES work_types(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- risk_assessment_id FK (같은 DB, 물리 FK)
|
||||||
|
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_risk_assessment
|
||||||
|
FOREIGN KEY (risk_assessment_id) REFERENCES risk_projects(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- schedule_phases.product_type_id FK
|
||||||
|
ALTER TABLE schedule_phases ADD CONSTRAINT fk_phase_product_type
|
||||||
|
FOREIGN KEY (product_type_id) REFERENCES product_types(id) ON DELETE SET NULL
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 대리입력 식별 컬럼 추가
|
||||||
|
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS is_proxy_input TINYINT(1) DEFAULT 0 COMMENT '대리입력 여부';
|
||||||
|
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS proxy_input_by INT NULL COMMENT '대리입력자 sso_users.user_id (앱 레벨 참조)';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS monthly_work_confirmations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL COMMENT '작업자 user_id (workers.user_id)',
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
status ENUM('pending', 'confirmed', 'rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
total_work_days INT DEFAULT 0 COMMENT '총 근무일수',
|
||||||
|
total_work_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 근무시간',
|
||||||
|
total_overtime_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 연장근로시간',
|
||||||
|
vacation_days DECIMAL(4,2) DEFAULT 0 COMMENT '휴가 일수',
|
||||||
|
mismatch_count INT DEFAULT 0 COMMENT '불일치 건수',
|
||||||
|
reject_reason TEXT NULL COMMENT '반려 사유',
|
||||||
|
confirmed_at TIMESTAMP NULL COMMENT '확인 일시',
|
||||||
|
rejected_at TIMESTAMP NULL COMMENT '반려 일시',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_year_month (user_id, year, month),
|
||||||
|
KEY idx_year_month (year, month),
|
||||||
|
KEY idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='월간 근무 확인 (승인/반려)'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- vacation_types.deduct_days 정밀도 수정: DECIMAL(3,1) → DECIMAL(4,2)
|
||||||
|
-- 0.25(반반차)가 0.3으로 반올림되는 문제 해결
|
||||||
|
ALTER TABLE vacation_types MODIFY deduct_days DECIMAL(4,2) DEFAULT 1.00;
|
||||||
|
UPDATE vacation_types SET deduct_days = 0.25 WHERE type_code = 'ANNUAL_QUARTER';
|
||||||
|
-- type_code가 표준 유형인데 balance_type이 COMPANY_GRANT인 잘못된 레코드 삭제
|
||||||
|
DELETE svb FROM sp_vacation_balances svb
|
||||||
|
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.balance_type = 'COMPANY_GRANT'
|
||||||
|
AND vt.type_code IN ('CARRYOVER', 'LONG_SERVICE', 'ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER');
|
||||||
|
-- 조퇴 휴가 유형 추가 (0.75일 = 반차+반반차)
|
||||||
|
INSERT IGNORE INTO vacation_types (type_code, type_name, deduct_days, is_active, priority)
|
||||||
|
VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10);
|
||||||
|
-- 작업자 월간 확인 페이지 등록
|
||||||
|
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order)
|
||||||
|
VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25);
|
||||||
|
-- 2026년 법정 공휴일 + 대체공휴일 일괄 등록
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-01-01', '신정', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-16', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-17', '설날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-18', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-01', '삼일절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-02', '대체공휴일(삼일절)', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-05', '어린이날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-24', '석가탄신일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-06-06', '현충일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-08-15', '광복절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-24', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-25', '추석', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-26', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-03', '개천절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-09', '한글날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-12-25', '크리스마스', 'PAID', 1);
|
||||||
|
-- NULL leader_user_id 복구 (created_by로 채움)
|
||||||
|
UPDATE tbm_sessions SET leader_user_id = created_by WHERE leader_user_id IS NULL;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 소모품 카테고리 테이블 분리 (ENUM → 마스터 테이블)
|
||||||
|
|
||||||
|
-- 1단계: 카테고리 마스터 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS consumable_categories (
|
||||||
|
category_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
category_code VARCHAR(30) NOT NULL UNIQUE COMMENT '코드 (consumable, safety 등)',
|
||||||
|
category_name VARCHAR(50) NOT NULL COMMENT '표시명',
|
||||||
|
icon VARCHAR(30) DEFAULT 'fa-box' COMMENT 'Font Awesome 아이콘',
|
||||||
|
color_bg VARCHAR(30) DEFAULT '#dbeafe' COMMENT '배경색',
|
||||||
|
color_fg VARCHAR(30) DEFAULT '#1e40af' COMMENT '글자색',
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2단계: 기존 4개 시드
|
||||||
|
INSERT IGNORE INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order) VALUES
|
||||||
|
('consumable', '소모품', 'fa-box', '#dbeafe', '#1e40af', 1),
|
||||||
|
('safety', '안전용품', 'fa-hard-hat', '#dcfce7', '#166534', 2),
|
||||||
|
('repair', '수선비', 'fa-wrench', '#fef3c7', '#92400e', 3),
|
||||||
|
('equipment', '설비', 'fa-cogs', '#f3e8ff', '#7e22ce', 4);
|
||||||
|
|
||||||
|
-- 3단계: ENUM → VARCHAR 변환
|
||||||
|
ALTER TABLE consumable_items MODIFY COLUMN category VARCHAR(30) DEFAULT 'consumable';
|
||||||
|
ALTER TABLE purchase_requests MODIFY COLUMN custom_category VARCHAR(30) NULL;
|
||||||
|
ALTER TABLE purchase_batches MODIFY COLUMN category VARCHAR(30) NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 월간 확인 워크플로우 확장: pending → review_sent → confirmed/change_request/rejected
|
||||||
|
ALTER TABLE monthly_work_confirmations
|
||||||
|
MODIFY status ENUM('pending','review_sent','confirmed','change_request','rejected') DEFAULT 'pending';
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_by INT NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS change_details TEXT NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS admin_checked TINYINT(1) DEFAULT 0;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 소모품 구매 취소/반품 지원 + 입고일 관리
|
||||||
|
|
||||||
|
-- 1. purchase_requests.status ENUM에 cancelled, returned 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','cancelled','returned','hold') DEFAULT 'pending'
|
||||||
|
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 취소, 반품, 보류';
|
||||||
|
|
||||||
|
-- 2. 취소/반품 관련 컬럼 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD COLUMN cancelled_at TIMESTAMP NULL COMMENT '취소 시각' AFTER received_by,
|
||||||
|
ADD COLUMN cancelled_by INT NULL COMMENT '취소 처리자' AFTER cancelled_at,
|
||||||
|
ADD COLUMN cancel_reason TEXT NULL COMMENT '취소/반품 사유' AFTER cancelled_by;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- 소모품 구매 관리 시스템 v2: 상태 확장 + 그룹화 + 별칭 + 입고
|
||||||
|
|
||||||
|
-- 1. purchase_requests.status ENUM 확장
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','hold') DEFAULT 'pending'
|
||||||
|
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 보류';
|
||||||
|
|
||||||
|
-- 2. 입고/그룹 관련 컬럼 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD COLUMN batch_id INT NULL COMMENT '구매 묶음 ID' AFTER photo_path,
|
||||||
|
ADD COLUMN received_photo_path VARCHAR(255) NULL COMMENT '입고 사진' AFTER batch_id,
|
||||||
|
ADD COLUMN received_location VARCHAR(200) NULL COMMENT '입고 보관 위치' AFTER received_photo_path,
|
||||||
|
ADD COLUMN received_at TIMESTAMP NULL COMMENT '입고 확인 시각' AFTER received_location,
|
||||||
|
ADD COLUMN received_by INT NULL COMMENT '입고 확인자' AFTER received_at,
|
||||||
|
ADD CONSTRAINT fk_pr_received_by FOREIGN KEY (received_by) REFERENCES sso_users(user_id);
|
||||||
|
|
||||||
|
-- 3. 구매 묶음(그룹) 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_batches (
|
||||||
|
batch_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
batch_name VARCHAR(100) COMMENT '묶음 이름',
|
||||||
|
category ENUM('consumable','safety','repair','equipment') NULL COMMENT '분류',
|
||||||
|
vendor_id INT NULL COMMENT '예정 업체',
|
||||||
|
status ENUM('pending','purchased','received') DEFAULT 'pending'
|
||||||
|
COMMENT '진행중, 구매완료, 입고완료',
|
||||||
|
notes TEXT,
|
||||||
|
created_by INT NOT NULL COMMENT '생성자',
|
||||||
|
purchased_at TIMESTAMP NULL COMMENT '구매 처리 시점',
|
||||||
|
purchased_by INT NULL COMMENT '구매 처리자',
|
||||||
|
received_at TIMESTAMP NULL COMMENT '입고 확인 시점',
|
||||||
|
received_by INT NULL COMMENT '입고 확인자',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
|
||||||
|
FOREIGN KEY (created_by) REFERENCES sso_users(user_id),
|
||||||
|
FOREIGN KEY (purchased_by) REFERENCES sso_users(user_id),
|
||||||
|
FOREIGN KEY (received_by) REFERENCES sso_users(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. batch FK
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD CONSTRAINT fk_pr_batch FOREIGN KEY (batch_id)
|
||||||
|
REFERENCES purchase_batches(batch_id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 5. 품목 별칭 테이블 (한국어 동의어/약어 매핑)
|
||||||
|
CREATE TABLE IF NOT EXISTS item_aliases (
|
||||||
|
alias_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL COMMENT 'FK → consumable_items',
|
||||||
|
alias_name VARCHAR(100) NOT NULL COMMENT '별칭/축약어',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_item_alias (item_id, alias_name),
|
||||||
|
INDEX idx_alias_name (alias_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. notification_recipients ENUM에 'purchase' 추가
|
||||||
|
ALTER TABLE notification_recipients
|
||||||
|
MODIFY COLUMN notification_type
|
||||||
|
ENUM('repair','safety','nonconformity','equipment','maintenance','system','purchase')
|
||||||
|
NOT NULL COMMENT '알림 유형';
|
||||||
|
|
||||||
|
-- 7. 페이지 키 등록
|
||||||
|
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, is_admin_only, display_order) VALUES
|
||||||
|
('purchase.request_mobile', '소모품 신청 (모바일)', '/pages/purchase/request-mobile.html', 'purchase', 0, 42);
|
||||||
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal file
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- 소모품 별칭 시드 데이터 (item_name LIKE 매칭, 데이터 없으면 무시)
|
||||||
|
|
||||||
|
-- 장갑류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '목장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
|
||||||
|
|
||||||
|
-- 테이프류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '전기테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
|
||||||
|
|
||||||
|
-- 연마류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '사포' FROM consumable_items WHERE item_name LIKE '%연마지%' OR item_name LIKE '%연마석%';
|
||||||
|
|
||||||
|
-- 마스크
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '마스크' FROM consumable_items WHERE item_name LIKE '%방진마스크%' OR item_name LIKE '%방독마스크%';
|
||||||
|
|
||||||
|
-- 안전화
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '작업화' FROM consumable_items WHERE item_name LIKE '%안전화%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '신발' FROM consumable_items WHERE item_name LIKE '%안전화%';
|
||||||
@@ -41,26 +41,59 @@ app.use((req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 서버 시작
|
// Startup: 마이그레이션 후 서버 시작
|
||||||
const server = app.listen(PORT, () => {
|
async function runStartupMigrations() {
|
||||||
logger.info(`서버 시작 완료`, {
|
try {
|
||||||
port: PORT,
|
const { getDb } = require('./dbPool');
|
||||||
env: process.env.NODE_ENV || 'development',
|
const fs = require('fs');
|
||||||
nodeVersion: process.version
|
const path = require('path');
|
||||||
|
const db = await getDb();
|
||||||
|
const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql', '20260401_monthly_confirm_workflow.sql'];
|
||||||
|
for (const file of migrationFiles) {
|
||||||
|
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
|
||||||
|
if (!fs.existsSync(sqlPath)) continue;
|
||||||
|
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||||
|
const stmts = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
for (const stmt of stmts) {
|
||||||
|
try { await db.query(stmt); } catch (err) {
|
||||||
|
if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME', 'ER_FK_DUP_NAME'].includes(err.code)) {
|
||||||
|
// 이미 적용됨 — 무시
|
||||||
|
} else if (err.code === 'ER_NO_REFERENCED_ROW_2' || err.message.includes('Cannot add foreign key')) {
|
||||||
|
// product_types 테이블 미존재 (tkuser 미시작) — skip, 재시작 시 retry
|
||||||
|
logger.warn(`Migration FK skip (dependency not ready): ${err.message}`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`[system1] Migration ${file} completed`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Migration error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let server;
|
||||||
|
|
||||||
|
runStartupMigrations().then(() => {
|
||||||
|
server = app.listen(PORT, () => {
|
||||||
|
logger.info(`서버 시작 완료`, {
|
||||||
|
port: PORT,
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
nodeVersion: process.version
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful Shutdown
|
// Graceful Shutdown
|
||||||
const gracefulShutdown = (signal) => {
|
const gracefulShutdown = (signal) => {
|
||||||
logger.info(`${signal} 신호 수신 - 서버 종료 시작`);
|
logger.info(`${signal} 신호 수신 - 서버 종료 시작`);
|
||||||
|
if (!server) return process.exit(0);
|
||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
logger.info('HTTP 서버 종료 완료');
|
logger.info('HTTP 서버 종료 완료');
|
||||||
|
|
||||||
// 리소스 정리
|
|
||||||
try {
|
try {
|
||||||
// DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요
|
|
||||||
// Redis 종료 (사용 중인 경우)
|
|
||||||
if (cache.redis) {
|
if (cache.redis) {
|
||||||
await cache.redis.quit();
|
await cache.redis.quit();
|
||||||
logger.info('캐시 시스템 종료 완료');
|
logger.info('캐시 시스템 종료 완료');
|
||||||
@@ -72,15 +105,12 @@ const gracefulShutdown = (signal) => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 30초 후 강제 종료
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
logger.error('강제 종료 - 정상 종료 시간 초과');
|
logger.error('강제 종료 - 정상 종료 시간 초과');
|
||||||
console.error(' 정상 종료 실패, 강제 종료합니다.');
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 시그널 핸들러 등록
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ class AttendanceModel {
|
|||||||
static async initializeDailyRecords(date, createdBy) {
|
static async initializeDailyRecords(date, createdBy) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 1. 활성 작업자 조회
|
// 1. 활성 작업자 조회 (입사일 이전 제외)
|
||||||
const [workers] = await db.execute(
|
const [workers] = await db.execute(
|
||||||
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL'
|
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL AND (hire_date IS NULL OR hire_date <= ?)',
|
||||||
|
[date]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (workers.length === 0) return { inserted: 0 };
|
if (workers.length === 0) return { inserted: 0 };
|
||||||
|
|||||||
47
system1-factory/api/models/consumableCategoryModel.js
Normal file
47
system1-factory/api/models/consumableCategoryModel.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// models/consumableCategoryModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ConsumableCategoryModel = {
|
||||||
|
async getAll(activeOnly = true) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = 'SELECT * FROM consumable_categories';
|
||||||
|
if (activeOnly) sql += ' WHERE is_active = 1';
|
||||||
|
sql += ' ORDER BY sort_order, category_name';
|
||||||
|
const [rows] = await db.query(sql);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query('SELECT * FROM consumable_categories WHERE category_id = ?', [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create({ categoryCode, categoryName, icon, colorBg, colorFg, sortOrder }) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[categoryCode, categoryName, icon || 'fa-box', colorBg || '#dbeafe', colorFg || '#1e40af', sortOrder || 0]
|
||||||
|
);
|
||||||
|
return this.getById(result.insertId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, { categoryName, icon, colorBg, colorFg, sortOrder }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE consumable_categories SET category_name = ?, icon = ?, color_bg = ?, color_fg = ?, sort_order = ?
|
||||||
|
WHERE category_id = ?`,
|
||||||
|
[categoryName, icon, colorBg, colorFg, sortOrder, id]
|
||||||
|
);
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deactivate(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query('UPDATE consumable_categories SET is_active = 0 WHERE category_id = ?', [id]);
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ConsumableCategoryModel;
|
||||||
148
system1-factory/api/models/dashboardModel.js
Normal file
148
system1-factory/api/models/dashboardModel.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 개인 요약 모델
|
||||||
|
* Sprint 003 — 연차/연장근로/접근 페이지 통합 조회
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../config/database');
|
||||||
|
|
||||||
|
const OVERTIME_THRESHOLD = 8; // 연장근로 기준 시간
|
||||||
|
|
||||||
|
const DashboardModel = {
|
||||||
|
/**
|
||||||
|
* 사용자 정보 조회 (쿼리 1 — 먼저 실행)
|
||||||
|
*/
|
||||||
|
getUserInfo: async (userId) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT u.user_id, u.name, u.role,
|
||||||
|
w.worker_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(w.department_id, u.department_id) AS department_id,
|
||||||
|
COALESCE(d.department_name, d2.department_name, '미배정') AS department_name
|
||||||
|
FROM sso_users u
|
||||||
|
LEFT JOIN workers w ON u.user_id = w.user_id
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
LEFT JOIN departments d2 ON u.department_id = d2.department_id
|
||||||
|
WHERE u.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연차 현황 조회 (쿼리 2)
|
||||||
|
*/
|
||||||
|
getVacationBalance: async (userId, year) => {
|
||||||
|
if (!userId) return [];
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT svb.vacation_type_id, svb.total_days, svb.used_days,
|
||||||
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
|
svb.balance_type, svb.expires_at,
|
||||||
|
vt.type_name, vt.type_code
|
||||||
|
FROM sp_vacation_balances svb
|
||||||
|
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
|
ORDER BY vt.priority
|
||||||
|
`, [userId, year]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월간 연장근로 조회 (쿼리 3)
|
||||||
|
*/
|
||||||
|
getMonthlyOvertime: async (userId, year, month) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN 1 END) AS overtime_days,
|
||||||
|
COALESCE(SUM(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN dar.total_work_hours - ${OVERTIME_THRESHOLD} ELSE 0 END), 0) AS total_overtime_hours,
|
||||||
|
COUNT(*) AS total_work_days,
|
||||||
|
COALESCE(SUM(dar.total_work_hours), 0) AS total_work_hours,
|
||||||
|
COALESCE(AVG(dar.total_work_hours), 0) AS avg_daily_hours
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
WHERE dar.user_id = ? AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
AND dar.total_work_hours > 0
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows[0] || { overtime_days: 0, total_overtime_hours: 0, total_work_days: 0, total_work_hours: 0, avg_daily_hours: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근 가능 페이지 조회 (쿼리 4)
|
||||||
|
*/
|
||||||
|
getQuickAccess: async (userId, departmentId, role) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const isAdmin = ['admin', 'system'].includes((role || '').toLowerCase());
|
||||||
|
|
||||||
|
// 모든 페이지 조회
|
||||||
|
const [allPages] = await db.execute(`
|
||||||
|
SELECT id, page_key, page_name, page_path, category, is_admin_only
|
||||||
|
FROM pages
|
||||||
|
ORDER BY display_order, page_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return {
|
||||||
|
department_pages: allPages.map(formatPage),
|
||||||
|
personal_pages: [],
|
||||||
|
admin_pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서 권한 페이지
|
||||||
|
// department_page_permissions.page_name은 's1.work.tbm' 형식 (시스템 접두사 포함)
|
||||||
|
// pages.page_key는 'work.tbm' 형식 (접두사 없음)
|
||||||
|
// → 's1.' 접두사를 제거하여 매칭
|
||||||
|
let deptPageKeys = new Set();
|
||||||
|
if (departmentId) {
|
||||||
|
const [deptRows] = await db.execute(`
|
||||||
|
SELECT dpp.page_name
|
||||||
|
FROM department_page_permissions dpp
|
||||||
|
WHERE dpp.department_id = ? AND dpp.can_access = 1
|
||||||
|
`, [departmentId]);
|
||||||
|
deptRows.forEach(r => {
|
||||||
|
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
|
||||||
|
deptPageKeys.add(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개인 권한 페이지 (user_page_permissions.page_name 기반)
|
||||||
|
const [personalRows] = await db.execute(`
|
||||||
|
SELECT upp.page_name
|
||||||
|
FROM user_page_permissions upp
|
||||||
|
WHERE upp.user_id = ? AND upp.can_access = 1
|
||||||
|
`, [userId]);
|
||||||
|
const personalPageKeys = new Set();
|
||||||
|
personalRows.forEach(r => {
|
||||||
|
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
|
||||||
|
personalPageKeys.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 분류 (부서 우선, 중복 없음 — 권한 있는 페이지만)
|
||||||
|
const departmentPages = [];
|
||||||
|
const personalPages = [];
|
||||||
|
|
||||||
|
for (const page of allPages) {
|
||||||
|
if (deptPageKeys.has(page.page_key)) {
|
||||||
|
departmentPages.push(formatPage(page));
|
||||||
|
} else if (personalPageKeys.has(page.page_key)) {
|
||||||
|
personalPages.push(formatPage(page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
department_pages: departmentPages,
|
||||||
|
personal_pages: personalPages,
|
||||||
|
admin_pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPage(page) {
|
||||||
|
return {
|
||||||
|
page_key: page.page_key,
|
||||||
|
page_name: page.page_name,
|
||||||
|
page_path: page.page_path,
|
||||||
|
icon: '',
|
||||||
|
category: page.category || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DashboardModel;
|
||||||
35
system1-factory/api/models/itemAliasModel.js
Normal file
35
system1-factory/api/models/itemAliasModel.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// models/itemAliasModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ItemAliasModel = {
|
||||||
|
async getAll() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT ia.*, ci.item_name, ci.spec, ci.maker, ci.category
|
||||||
|
FROM item_aliases ia
|
||||||
|
JOIN consumable_items ci ON ia.item_id = ci.item_id
|
||||||
|
ORDER BY ci.item_name, ia.alias_name`
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(itemId, aliasName) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO item_aliases (item_id, alias_name) VALUES (?, ?)`,
|
||||||
|
[itemId, aliasName.trim()]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(aliasId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`DELETE FROM item_aliases WHERE alias_id = ?`,
|
||||||
|
[aliasId]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ItemAliasModel;
|
||||||
387
system1-factory/api/models/monthlyComparisonModel.js
Normal file
387
system1-factory/api/models/monthlyComparisonModel.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// models/monthlyComparisonModel.js — 월간 비교·확인·정산
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const MonthlyComparisonModel = {
|
||||||
|
// 0. 해당 월의 회사 휴무일 조회
|
||||||
|
async getCompanyHolidays(year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays
|
||||||
|
WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?`,
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
const dateSet = new Set();
|
||||||
|
const nameMap = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
const d = r.holiday_date instanceof Date
|
||||||
|
? r.holiday_date.toISOString().split('T')[0]
|
||||||
|
: String(r.holiday_date).split('T')[0];
|
||||||
|
dateSet.add(d);
|
||||||
|
nameMap[d] = r.holiday_name;
|
||||||
|
});
|
||||||
|
return { dateSet, nameMap };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 1. 작업보고서 일별 합산
|
||||||
|
async getWorkReports(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT
|
||||||
|
dwr.report_date,
|
||||||
|
SUM(dwr.work_hours) AS total_hours,
|
||||||
|
GROUP_CONCAT(DISTINCT p.project_name SEPARATOR ', ') AS project_names,
|
||||||
|
GROUP_CONCAT(DISTINCT wt.name SEPARATOR ', ') AS work_type_names
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||||
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||||
|
WHERE dwr.user_id = ?
|
||||||
|
AND YEAR(dwr.report_date) = ?
|
||||||
|
AND MONTH(dwr.report_date) = ?
|
||||||
|
GROUP BY dwr.report_date
|
||||||
|
ORDER BY dwr.report_date
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 근태관리 일별 기록
|
||||||
|
async getAttendanceRecords(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT
|
||||||
|
dar.record_date,
|
||||||
|
dar.total_work_hours,
|
||||||
|
dar.attendance_type_id,
|
||||||
|
dar.vacation_type_id,
|
||||||
|
dar.status,
|
||||||
|
dar.is_present,
|
||||||
|
dar.notes,
|
||||||
|
wat.type_name AS attendance_type_name,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days AS vacation_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.user_id = ?
|
||||||
|
AND YEAR(dar.record_date) = ?
|
||||||
|
AND MONTH(dar.record_date) = ?
|
||||||
|
ORDER BY dar.record_date
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 확인 상태 조회
|
||||||
|
async getConfirmation(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||||
|
[userId, year, month]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 확인 UPSERT + 반려 시 알림 (단일 트랜잭션)
|
||||||
|
async upsertConfirmation(data, notificationData) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 기존 상태 체크 + 전환 검증
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ? FOR UPDATE',
|
||||||
|
[data.user_id, data.year, data.month]
|
||||||
|
);
|
||||||
|
const currentStatus = existing.length > 0 ? existing[0].status : null;
|
||||||
|
|
||||||
|
if (currentStatus === 'confirmed') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '이미 확인된 내역은 변경할 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자 확인: review_sent 또는 rejected 상태에서만 가능
|
||||||
|
if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '관리자 확인요청 후에 확인할 수 있습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자 수정요청: review_sent 상태에서만 가능
|
||||||
|
if (data.status === 'change_request' && currentStatus !== 'review_sent') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '확인요청 상태에서만 수정요청이 가능합니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO monthly_work_confirmations
|
||||||
|
(user_id, year, month, status, total_work_days, total_work_hours,
|
||||||
|
total_overtime_hours, vacation_days, mismatch_count, reject_reason,
|
||||||
|
confirmed_at, rejected_at, change_details)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status),
|
||||||
|
total_work_days = VALUES(total_work_days),
|
||||||
|
total_work_hours = VALUES(total_work_hours),
|
||||||
|
total_overtime_hours = VALUES(total_overtime_hours),
|
||||||
|
vacation_days = VALUES(vacation_days),
|
||||||
|
mismatch_count = VALUES(mismatch_count),
|
||||||
|
reject_reason = VALUES(reject_reason),
|
||||||
|
confirmed_at = VALUES(confirmed_at),
|
||||||
|
rejected_at = VALUES(rejected_at),
|
||||||
|
change_details = VALUES(change_details)
|
||||||
|
`, [
|
||||||
|
data.user_id, data.year, data.month, data.status,
|
||||||
|
data.total_work_days || 0, data.total_work_hours || 0,
|
||||||
|
data.total_overtime_hours || 0, data.vacation_days || 0,
|
||||||
|
data.mismatch_count || 0, data.reject_reason || null,
|
||||||
|
data.status === 'confirmed' ? new Date() : null,
|
||||||
|
data.status === 'rejected' ? new Date() : null,
|
||||||
|
data.change_details || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null);
|
||||||
|
|
||||||
|
// 알림 생성 (반려 또는 수정요청)
|
||||||
|
if (notificationData && confirmationId) {
|
||||||
|
const { recipients, title, message, linkUrl, createdBy } = notificationData;
|
||||||
|
for (const recipientId of recipients) {
|
||||||
|
await conn.query(`
|
||||||
|
INSERT INTO notifications
|
||||||
|
(user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
|
||||||
|
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)
|
||||||
|
`, [recipientId, title, message, linkUrl, confirmationId, createdBy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { id: confirmationId, status: data.status };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관리자: 확인요청 발송 (pending → review_sent)
|
||||||
|
async bulkReviewSend(year, month, userIds, reviewedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체)
|
||||||
|
let targetIds = userIds || [];
|
||||||
|
if (!targetIds.length) {
|
||||||
|
const [pendingRows] = await conn.query(
|
||||||
|
`SELECT DISTINCT w.user_id FROM workers w
|
||||||
|
LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
AND (mwc.status IS NULL OR mwc.status = 'pending')`,
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
targetIds = pendingRows.map(r => r.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetIds.length) {
|
||||||
|
await conn.rollback();
|
||||||
|
return { count: 0, message: '대상 작업자가 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 전환 + 알림 생성
|
||||||
|
for (const uid of targetIds) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at)
|
||||||
|
VALUES (?, ?, ?, 'review_sent', ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`,
|
||||||
|
[uid, year, month, reviewedBy, reviewedBy]
|
||||||
|
);
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by)
|
||||||
|
VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`,
|
||||||
|
[uid, `${year}년 ${month}월 근무 내역을 확인해주세요.`, reviewedBy]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { count: targetIds.length };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
|
||||||
|
async reviewRespond(userId, year, month, action, rejectReason, respondedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||||
|
[userId, year, month]
|
||||||
|
);
|
||||||
|
if (!existing.length || existing[0].status !== 'change_request') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '수정요청 상태가 아닙니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
var newStatus = action === 'approve' ? 'review_sent' : 'rejected';
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(),
|
||||||
|
reject_reason = ?, change_details = NULL WHERE id = ?`,
|
||||||
|
[newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업자에게 알림
|
||||||
|
var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부';
|
||||||
|
var message = action === 'approve'
|
||||||
|
? `${year}년 ${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.`
|
||||||
|
: `${year}년 ${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`;
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
|
||||||
|
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)`,
|
||||||
|
[userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { status: newStatus };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. 전체 작업자 확인 현황 (실제 근태 데이터 집계 포함)
|
||||||
|
async getAllStatus(year, month, departmentId) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name,
|
||||||
|
COALESCE(mwc.status, 'pending') AS status,
|
||||||
|
mwc.confirmed_at, mwc.rejected_at, mwc.reject_reason,
|
||||||
|
mwc.change_details, COALESCE(mwc.admin_checked, 0) AS admin_checked,
|
||||||
|
COALESCE(att.work_days, 0) AS total_work_days,
|
||||||
|
COALESCE(att.work_hours, 0) AS total_work_hours,
|
||||||
|
COALESCE(att.overtime_hours, 0) AS total_overtime_hours,
|
||||||
|
COALESCE(att.vac_days, 0) AS vacation_days
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
LEFT JOIN monthly_work_confirmations mwc
|
||||||
|
ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT dar.user_id,
|
||||||
|
COUNT(CASE WHEN dar.total_work_hours > 0 THEN 1 END) AS work_days,
|
||||||
|
COALESCE(SUM(dar.total_work_hours), 0) AS work_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN dar.total_work_hours > 8 THEN dar.total_work_hours - 8 ELSE 0 END), 0) AS overtime_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN vt.deduct_days IS NOT NULL THEN vt.deduct_days ELSE 0 END), 0) AS vac_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
GROUP BY dar.user_id
|
||||||
|
) att ON w.user_id = att.user_id
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
`;
|
||||||
|
const params = [year, month, year, month];
|
||||||
|
if (departmentId) {
|
||||||
|
sql += ' AND w.department_id = ?';
|
||||||
|
params.push(departmentId);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY d.department_name, w.worker_name';
|
||||||
|
|
||||||
|
const [rows] = await db.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5b. 관리자 개별 검토 태깅
|
||||||
|
async adminCheck(userId, year, month, checked, checkedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO monthly_work_confirmations (user_id, year, month, status, admin_checked)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE admin_checked = ?
|
||||||
|
`, [userId, year, month, checked ? 1 : 0, checked ? 1 : 0]);
|
||||||
|
return { admin_checked: checked };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. 지원팀 사용자 목록 (알림 수신자)
|
||||||
|
async getSupportTeamUsers() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
"SELECT user_id FROM sso_users WHERE role IN ('support_team', 'admin', 'system') AND is_active = 1"
|
||||||
|
);
|
||||||
|
return rows.map(r => r.user_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 7. 출근부 엑셀용 — 작업자 목록 + 일별 근태 + 연차잔액
|
||||||
|
async getExportData(year, month, departmentId) {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// (a) 해당 부서 활성 작업자 (worker_id 순)
|
||||||
|
let workerSql = `
|
||||||
|
SELECT w.user_id, w.worker_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.status = 'active'
|
||||||
|
`;
|
||||||
|
const workerParams = [];
|
||||||
|
if (departmentId) { workerSql += ' AND w.department_id = ?'; workerParams.push(departmentId); }
|
||||||
|
workerSql += ' ORDER BY w.worker_id';
|
||||||
|
const [workers] = await db.query(workerSql, workerParams);
|
||||||
|
|
||||||
|
if (workers.length === 0) return { workers: [], attendance: [], vacations: [] };
|
||||||
|
|
||||||
|
const userIds = workers.map(w => w.user_id);
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// (b) 일별 근태 기록
|
||||||
|
const [attendance] = await db.query(`
|
||||||
|
SELECT dar.user_id, dar.record_date,
|
||||||
|
dar.total_work_hours,
|
||||||
|
dar.attendance_type_id,
|
||||||
|
dar.vacation_type_id,
|
||||||
|
vt.type_code AS vacation_type_code,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.user_id IN (${placeholders})
|
||||||
|
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
ORDER BY dar.user_id, dar.record_date
|
||||||
|
`, [...userIds, year, month]);
|
||||||
|
|
||||||
|
// (c) 연차 잔액 (sp_vacation_balances)
|
||||||
|
const [vacations] = await db.query(`
|
||||||
|
SELECT svb.user_id,
|
||||||
|
SUM(svb.total_days) AS total_days,
|
||||||
|
SUM(svb.used_days) AS used_days,
|
||||||
|
SUM(svb.total_days - svb.used_days) AS remaining_days
|
||||||
|
FROM sp_vacation_balances svb
|
||||||
|
WHERE svb.user_id IN (${placeholders}) AND svb.year = ?
|
||||||
|
GROUP BY svb.user_id
|
||||||
|
`, [...userIds, year]);
|
||||||
|
|
||||||
|
return { workers, attendance, vacations };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 8. 작업자 정보
|
||||||
|
async getWorkerInfo(userId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = MonthlyComparisonModel;
|
||||||
@@ -19,7 +19,6 @@ const PageAccessModel = {
|
|||||||
FROM pages p
|
FROM pages p
|
||||||
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
||||||
LEFT JOIN users granter ON upa.granted_by = granter.user_id
|
LEFT JOIN users granter ON upa.granted_by = granter.user_id
|
||||||
WHERE p.is_admin_only = 0
|
|
||||||
ORDER BY p.category, p.display_order
|
ORDER BY p.category, p.display_order
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ const PageAccessModel = {
|
|||||||
is_admin_only,
|
is_admin_only,
|
||||||
display_order
|
display_order
|
||||||
FROM pages
|
FROM pages
|
||||||
WHERE is_admin_only = 0
|
|
||||||
ORDER BY category, display_order
|
ORDER BY category, display_order
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
242
system1-factory/api/models/proxyInputModel.js
Normal file
242
system1-factory/api/models/proxyInputModel.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 모델
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ProxyInputModel = {
|
||||||
|
/**
|
||||||
|
* 중복 배정 체크 (같은 날짜 + 같은 작업자)
|
||||||
|
*/
|
||||||
|
checkDuplicateAssignments: async (conn, sessionDate, userIds) => {
|
||||||
|
if (!userIds.length) return [];
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT ta.user_id, w.worker_name, ta.session_id
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
JOIN workers w ON ta.user_id = w.user_id
|
||||||
|
WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled'
|
||||||
|
`, [sessionDate, ...userIds]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 존재 여부 체크
|
||||||
|
*/
|
||||||
|
validateWorkers: async (conn, userIds) => {
|
||||||
|
if (!userIds.length) return [];
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT user_id FROM workers WHERE user_id IN (${placeholders}) AND status = 'active'
|
||||||
|
`, [...userIds]);
|
||||||
|
return rows.map(r => r.user_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 생성 (대리입력)
|
||||||
|
*/
|
||||||
|
createProxySession: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO tbm_sessions (session_date, leader_user_id, status, is_proxy_input, proxy_input_by, created_by, safety_notes, work_location)
|
||||||
|
VALUES (?, ?, 'completed', 1, ?, ?, ?, ?)
|
||||||
|
`, [data.session_date, data.leader_id, data.proxy_input_by, data.created_by, data.safety_notes || '', data.work_location || '']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팀 배정 생성
|
||||||
|
*/
|
||||||
|
createTeamAssignment: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO tbm_team_assignments (session_id, user_id, project_id, work_type_id, task_id, workplace_id, work_hours, is_present)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`, [data.session_id, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.workplace_id || null, data.work_hours]);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업보고서 생성 (accumulative)
|
||||||
|
*/
|
||||||
|
createWorkReport: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_by_name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
`, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by, data.created_by_name || '']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 현황 조회
|
||||||
|
*/
|
||||||
|
getDailyStatus: async (date) => {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 1. 활성 작업자
|
||||||
|
const [workers] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
ORDER BY w.worker_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. TBM 배정 현황
|
||||||
|
const [tbmAssignments] = await db.query(`
|
||||||
|
SELECT ta.user_id, ta.session_id, s.leader_user_id,
|
||||||
|
lu.worker_name AS leader_name, s.is_proxy_input
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||||
|
WHERE s.session_date = ? AND s.status != 'cancelled'
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 3. 작업보고서 현황
|
||||||
|
const [reports] = await db.query(`
|
||||||
|
SELECT dwr.user_id, SUM(dwr.work_hours) AS total_hours, COUNT(*) AS entry_count
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
WHERE dwr.report_date = ?
|
||||||
|
GROUP BY dwr.user_id
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 4. 해당 날짜의 연차 기록
|
||||||
|
const [vacationRecords] = await db.query(`
|
||||||
|
SELECT dar.user_id, dar.vacation_type_id,
|
||||||
|
vt.type_code AS vacation_type_code,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.record_date = ? AND dar.vacation_type_id IS NOT NULL
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 5. 해당 날짜가 회사 휴무일인지 확인
|
||||||
|
const [holidayRows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays WHERE holiday_date = ?`,
|
||||||
|
[date]
|
||||||
|
);
|
||||||
|
const isCompanyHoliday = holidayRows.length > 0;
|
||||||
|
const holidayName = isCompanyHoliday ? holidayRows[0].holiday_name : null;
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
|
||||||
|
|
||||||
|
// 메모리에서 조합
|
||||||
|
const tbmMap = {};
|
||||||
|
tbmAssignments.forEach(ta => {
|
||||||
|
if (!tbmMap[ta.user_id]) tbmMap[ta.user_id] = [];
|
||||||
|
tbmMap[ta.user_id].push(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportMap = {};
|
||||||
|
reports.forEach(r => { reportMap[r.user_id] = r; });
|
||||||
|
|
||||||
|
const vacMap = {};
|
||||||
|
vacationRecords.forEach(v => { vacMap[v.user_id] = v; });
|
||||||
|
|
||||||
|
let tbmCompleted = 0, reportCompleted = 0, bothCompleted = 0, bothMissing = 0;
|
||||||
|
|
||||||
|
const workerList = workers.map(w => {
|
||||||
|
const hasTbm = !!tbmMap[w.user_id];
|
||||||
|
const hasReport = !!reportMap[w.user_id];
|
||||||
|
const tbmSessions = (tbmMap[w.user_id] || []).map(ta => ({
|
||||||
|
session_id: ta.session_id,
|
||||||
|
leader_name: ta.leader_name,
|
||||||
|
is_proxy_input: !!ta.is_proxy_input
|
||||||
|
}));
|
||||||
|
const totalReportHours = reportMap[w.user_id]?.total_hours || 0;
|
||||||
|
const vac = vacMap[w.user_id] || null;
|
||||||
|
|
||||||
|
let status = 'both_missing';
|
||||||
|
if (hasTbm && hasReport) { status = 'complete'; bothCompleted++; }
|
||||||
|
else if (hasTbm && !hasReport) { status = 'tbm_only'; }
|
||||||
|
else if (!hasTbm && hasReport) { status = 'report_only'; }
|
||||||
|
else { bothMissing++; }
|
||||||
|
|
||||||
|
if (hasTbm) tbmCompleted++;
|
||||||
|
if (hasReport) reportCompleted++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
|
||||||
|
department_name: w.department_name, has_tbm: hasTbm, has_report: hasReport,
|
||||||
|
tbm_sessions: tbmSessions, total_report_hours: totalReportHours, status,
|
||||||
|
vacation_type_id: vac ? vac.vacation_type_id : null,
|
||||||
|
vacation_type_code: vac ? vac.vacation_type_code : null,
|
||||||
|
vacation_type_name: vac ? vac.vacation_type_name : null,
|
||||||
|
vacation_hours: vac ? (8 - parseFloat(vac.deduct_days) * 8) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
is_holiday: isWeekend || isCompanyHoliday,
|
||||||
|
holiday_name: isCompanyHoliday ? holidayName : (isWeekend ? '주말' : null),
|
||||||
|
summary: {
|
||||||
|
total_active_workers: workers.length,
|
||||||
|
tbm_completed: tbmCompleted,
|
||||||
|
tbm_missing: workers.length - tbmCompleted,
|
||||||
|
report_completed: reportCompleted,
|
||||||
|
report_missing: workers.length - reportCompleted,
|
||||||
|
both_completed: bothCompleted,
|
||||||
|
both_missing: bothMissing
|
||||||
|
},
|
||||||
|
workers: workerList
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자별 상세 조회
|
||||||
|
*/
|
||||||
|
getDailyStatusDetail: async (date, userId) => {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 작업자 정보
|
||||||
|
const [workerRows] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
// TBM 세션
|
||||||
|
const [tbmSessions] = await db.query(`
|
||||||
|
SELECT ta.session_id, s.status, s.is_proxy_input,
|
||||||
|
lu.worker_name AS leader_name,
|
||||||
|
pu.name AS proxy_input_by_name,
|
||||||
|
p.project_name, wt.work_type_name, ta.work_hours
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||||
|
LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id
|
||||||
|
LEFT JOIN projects p ON ta.project_id = p.project_id
|
||||||
|
LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id
|
||||||
|
WHERE s.session_date = ? AND ta.user_id = ? AND s.status != 'cancelled'
|
||||||
|
`, [date, userId]);
|
||||||
|
|
||||||
|
// 작업보고서
|
||||||
|
const [workReports] = await db.query(`
|
||||||
|
SELECT dwr.report_id, dwr.work_hours, dwr.created_at, dwr.created_by,
|
||||||
|
cu.name AS created_by_name,
|
||||||
|
p.project_name, wt.work_type_name, t.task_name,
|
||||||
|
ws.status_name AS work_status,
|
||||||
|
s.is_proxy_input
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN sso_users cu ON dwr.created_by = cu.user_id
|
||||||
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||||
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.work_type_id
|
||||||
|
LEFT JOIN tasks t ON dwr.task_id = t.task_id
|
||||||
|
LEFT JOIN work_statuses ws ON dwr.work_status_id = ws.work_status_id
|
||||||
|
LEFT JOIN tbm_sessions s ON dwr.tbm_session_id = s.session_id
|
||||||
|
WHERE dwr.report_date = ? AND dwr.user_id = ?
|
||||||
|
ORDER BY dwr.created_at
|
||||||
|
`, [date, userId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worker: workerRows[0] || null,
|
||||||
|
tbm_sessions: tbmSessions,
|
||||||
|
work_reports: workReports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ProxyInputModel;
|
||||||
117
system1-factory/api/models/purchaseBatchModel.js
Normal file
117
system1-factory/api/models/purchaseBatchModel.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// models/purchaseBatchModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const PurchaseBatchModel = {
|
||||||
|
async getAll(filters = {}) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = `
|
||||||
|
SELECT pb.*, su.name AS created_by_name,
|
||||||
|
v.vendor_name,
|
||||||
|
(SELECT COUNT(*) FROM purchase_requests WHERE batch_id = pb.batch_id) AS request_count
|
||||||
|
FROM purchase_batches pb
|
||||||
|
LEFT JOIN sso_users su ON pb.created_by = su.user_id
|
||||||
|
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
if (filters.status) { sql += ' AND pb.status = ?'; params.push(filters.status); }
|
||||||
|
sql += ' ORDER BY pb.created_at DESC';
|
||||||
|
const [rows] = await db.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pb.*, su.name AS created_by_name, v.vendor_name
|
||||||
|
FROM purchase_batches pb
|
||||||
|
LEFT JOIN sso_users su ON pb.created_by = su.user_id
|
||||||
|
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
|
||||||
|
WHERE pb.batch_id = ?
|
||||||
|
`, [batchId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create({ batchName, category, vendorId, notes, createdBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO purchase_batches (batch_name, category, vendor_id, notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[batchName || null, category || null, vendorId || null, notes || null, createdBy]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(batchId, { batchName, category, vendorId, notes }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET batch_name = ?, category = ?, vendor_id = ?, notes = ?
|
||||||
|
WHERE batch_id = ? AND status = 'pending'`,
|
||||||
|
[batchName || null, category || null, vendorId || null, notes || null, batchId]
|
||||||
|
);
|
||||||
|
return this.getById(batchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
// pending 상태만 삭제 가능
|
||||||
|
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
if (!batch.length || batch[0].status !== 'pending') return false;
|
||||||
|
|
||||||
|
// 포함된 요청 복원
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending' WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
await db.query('DELETE FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async markPurchased(batchId, purchasedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'purchased', purchased_at = NOW(), purchased_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'pending'`,
|
||||||
|
[purchasedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markReceived(batchId, receivedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에 요청 추가 (검증: pending이고 다른 batch에 속하지 않음)
|
||||||
|
async addRequests(batchId, requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [existing] = await db.query(
|
||||||
|
`SELECT request_id, batch_id, status FROM purchase_requests WHERE request_id IN (?)`,
|
||||||
|
[requestIds]
|
||||||
|
);
|
||||||
|
const invalid = existing.filter(r => r.status !== 'pending' || r.batch_id !== null);
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
const ids = invalid.map(r => r.request_id);
|
||||||
|
throw new Error(`다음 요청은 추가할 수 없습니다 (이미 그룹 소속이거나 대기 상태가 아님): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurchaseRequestModel = require('./purchaseRequestModel');
|
||||||
|
await PurchaseRequestModel.groupIntoBatch(requestIds, batchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에서 요청 제거
|
||||||
|
async removeRequests(batchId, requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
if (!batch.length || batch[0].status !== 'pending') {
|
||||||
|
throw new Error('진행중인 그룹에서만 요청을 제거할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const PurchaseRequestModel = require('./purchaseRequestModel');
|
||||||
|
await PurchaseRequestModel.removeFromBatch(requestIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PurchaseBatchModel;
|
||||||
@@ -2,17 +2,19 @@
|
|||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
const PurchaseRequestModel = {
|
const PurchaseRequestModel = {
|
||||||
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용)
|
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용, batch 정보 포함)
|
||||||
async getAll(filters = {}) {
|
async getAll(filters = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
pr.custom_item_name, pr.custom_category,
|
pr.custom_item_name, pr.custom_category,
|
||||||
su.name AS requester_name
|
su.name AS requester_name,
|
||||||
|
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category
|
||||||
FROM purchase_requests pr
|
FROM purchase_requests pr
|
||||||
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -25,23 +27,28 @@ const PurchaseRequestModel = {
|
|||||||
}
|
}
|
||||||
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
|
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
|
||||||
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
|
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
|
||||||
|
if (filters.batch_id) { sql += ' AND pr.batch_id = ?'; params.push(filters.batch_id); }
|
||||||
|
|
||||||
sql += ' ORDER BY pr.created_at DESC';
|
sql += ' ORDER BY pr.created_at DESC';
|
||||||
const [rows] = await db.query(sql, params);
|
const [rows] = await db.query(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 단건 조회
|
// 단건 조회 (batch 정보 포함)
|
||||||
async getById(requestId) {
|
async getById(requestId) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
pr.custom_item_name, pr.custom_category,
|
pr.custom_item_name, pr.custom_category,
|
||||||
su.name AS requester_name
|
su.name AS requester_name,
|
||||||
|
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category,
|
||||||
|
rsu.name AS received_by_name
|
||||||
FROM purchase_requests pr
|
FROM purchase_requests pr
|
||||||
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
WHERE pr.request_id = ?
|
WHERE pr.request_id = ?
|
||||||
`, [requestId]);
|
`, [requestId]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
@@ -105,6 +112,164 @@ const PurchaseRequestModel = {
|
|||||||
[requestId]
|
[requestId]
|
||||||
);
|
);
|
||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 내 신청 목록 (모바일용, 페이지네이션)
|
||||||
|
async getMyRequests(userId, { page = 1, limit = 20, status } = {}) {
|
||||||
|
const db = await getDb();
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
let where = 'WHERE pr.requester_id = ?';
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
if (status) { where += ' AND pr.status = ?'; params.push(status); }
|
||||||
|
|
||||||
|
const [[{ total }]] = await db.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM purchase_requests pr ${where}`, params
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
|
pr.custom_item_name, pr.custom_category,
|
||||||
|
pb.batch_name, pb.status AS batch_status,
|
||||||
|
rsu.name AS received_by_name
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
|
${where}
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에 요청 그룹화 (status → grouped)
|
||||||
|
async groupIntoBatch(requestIds, batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = ?, status = 'grouped'
|
||||||
|
WHERE request_id IN (?) AND status = 'pending' AND batch_id IS NULL`,
|
||||||
|
[batchId, requestIds]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에서 제거 (status → pending 복원)
|
||||||
|
async removeFromBatch(requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending'
|
||||||
|
WHERE request_id IN (?) AND status = 'grouped'`,
|
||||||
|
[requestIds]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 전체 요청 purchased 전환
|
||||||
|
async markBatchPurchased(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET status = 'purchased' WHERE batch_id = ? AND status = 'grouped'`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 개별 입고 처리
|
||||||
|
async receive(requestId, { receivedPhotoPath, receivedLocation, receivedBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'received', received_photo_path = ?, received_location = ?,
|
||||||
|
received_at = NOW(), received_by = ?
|
||||||
|
WHERE request_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedPhotoPath || null, receivedLocation || null, receivedBy, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 전체 입고 처리
|
||||||
|
async receiveBatch(batchId, { receivedPhotoPath, receivedLocation, receivedBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'received', received_photo_path = ?, received_location = ?,
|
||||||
|
received_at = NOW(), received_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedPhotoPath || null, receivedLocation || null, receivedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 모든 요청이 received인지 확인
|
||||||
|
async checkBatchAllReceived(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [[{ total, received }]] = await db.query(
|
||||||
|
`SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) AS received
|
||||||
|
FROM purchase_requests WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
return total > 0 && total === received;
|
||||||
|
},
|
||||||
|
|
||||||
|
// grouped 상태에서 hold (batch에서 자동 제거)
|
||||||
|
async holdFromGrouped(requestId, holdReason) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET status = 'hold', hold_reason = ?, batch_id = NULL
|
||||||
|
WHERE request_id = ? AND status = 'grouped'`,
|
||||||
|
[holdReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 신청자 ID 목록 조회
|
||||||
|
async getRequesterIdsByBatch(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT DISTINCT requester_id FROM purchase_requests WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
return rows.map(r => r.requester_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구매 취소 (purchased → pending 복원, batch에서도 제거)
|
||||||
|
async cancelPurchase(requestId, { cancelledBy, cancelReason }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?,
|
||||||
|
batch_id = NULL
|
||||||
|
WHERE request_id = ? AND status = 'purchased'`,
|
||||||
|
[cancelledBy, cancelReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 반품 (received → returned)
|
||||||
|
async returnItem(requestId, { cancelledBy, cancelReason }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'returned', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?
|
||||||
|
WHERE request_id = ? AND status = 'received'`,
|
||||||
|
[cancelledBy, cancelReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 취소/반품에서 원래 상태로 되돌리기
|
||||||
|
async revertCancel(requestId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'pending', cancelled_at = NULL, cancelled_by = NULL, cancel_reason = NULL
|
||||||
|
WHERE request_id = ? AND status = 'cancelled'`,
|
||||||
|
[requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ const ScheduleModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.job_no AS project_code,
|
SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.job_no AS project_code,
|
||||||
su.name AS created_by_name
|
su.name AS created_by_name, wt.name AS work_type_name
|
||||||
FROM schedule_entries e
|
FROM schedule_entries e
|
||||||
JOIN schedule_phases p ON e.phase_id = p.phase_id
|
JOIN schedule_phases p ON e.phase_id = p.phase_id
|
||||||
JOIN projects pr ON e.project_id = pr.project_id
|
JOIN projects pr ON e.project_id = pr.project_id
|
||||||
LEFT JOIN sso_users su ON e.created_by = su.user_id
|
LEFT JOIN sso_users su ON e.created_by = su.user_id
|
||||||
|
LEFT JOIN work_types wt ON e.work_type_id = wt.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -80,7 +81,8 @@ const ScheduleModel = {
|
|||||||
FROM schedule_entries e
|
FROM schedule_entries e
|
||||||
JOIN schedule_phases p ON e.phase_id = p.phase_id
|
JOIN schedule_phases p ON e.phase_id = p.phase_id
|
||||||
JOIN projects pr ON e.project_id = pr.project_id
|
JOIN projects pr ON e.project_id = pr.project_id
|
||||||
WHERE (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
|
WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL
|
||||||
|
AND (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
|
||||||
AND e.status != 'cancelled'
|
AND e.status != 'cancelled'
|
||||||
ORDER BY pr.job_no, p.display_order, e.display_order
|
ORDER BY pr.job_no, p.display_order, e.display_order
|
||||||
`, [year, year]);
|
`, [year, year]);
|
||||||
@@ -112,11 +114,12 @@ const ScheduleModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO schedule_entries
|
`INSERT INTO schedule_entries
|
||||||
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by)
|
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by, source, work_type_id, risk_assessment_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date,
|
[data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date,
|
||||||
data.progress || 0, data.status || 'planned', data.assignee || null,
|
data.progress || 0, data.status || 'planned', data.assignee || null,
|
||||||
data.notes || null, data.display_order || 0, data.created_by || null]
|
data.notes || null, data.display_order || 0, data.created_by || null,
|
||||||
|
data.source || 'manual', data.work_type_id || null, data.risk_assessment_id || null]
|
||||||
);
|
);
|
||||||
return result.insertId;
|
return result.insertId;
|
||||||
},
|
},
|
||||||
@@ -141,7 +144,7 @@ const ScheduleModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id'];
|
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id', 'work_type_id', 'risk_assessment_id'];
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
|
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
|
||||||
}
|
}
|
||||||
@@ -247,6 +250,100 @@ const ScheduleModel = {
|
|||||||
await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]);
|
await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === 제품유형 ===
|
||||||
|
async getProductTypes() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 표준공정 자동 생성 ===
|
||||||
|
async generateFromTemplate(projectId, productTypeCode, createdBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 1. 중복 체크
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM schedule_entries WHERE project_id = ? AND source = 'template'",
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (existing[0].cnt > 0) {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '이미 표준공정이 생성되었습니다' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. product_type_id 조회
|
||||||
|
const [ptRows] = await conn.query(
|
||||||
|
'SELECT id FROM product_types WHERE code = ?', [productTypeCode]
|
||||||
|
);
|
||||||
|
if (ptRows.length === 0) {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '존재하지 않는 제품유형입니다' };
|
||||||
|
}
|
||||||
|
const productTypeId = ptRows[0].id;
|
||||||
|
|
||||||
|
// 3. tksafety risk_process_templates 조회
|
||||||
|
const [templates] = await conn.query(
|
||||||
|
'SELECT * FROM risk_process_templates WHERE product_type = ? ORDER BY display_order',
|
||||||
|
[productTypeCode]
|
||||||
|
);
|
||||||
|
if (templates.length === 0) {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '해당 제품유형의 공정 템플릿이 없습니다' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 각 템플릿 → phase 매칭/생성 → entry 생성
|
||||||
|
let createdCount = 0;
|
||||||
|
for (const tmpl of templates) {
|
||||||
|
// phase 매칭: 1순위 전용, 2순위 범용, 3순위 신규
|
||||||
|
const [specificPhase] = await conn.query(
|
||||||
|
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id = ?',
|
||||||
|
[tmpl.process_name, productTypeId]
|
||||||
|
);
|
||||||
|
let phaseId;
|
||||||
|
if (specificPhase.length > 0) {
|
||||||
|
phaseId = specificPhase[0].phase_id;
|
||||||
|
} else {
|
||||||
|
const [genericPhase] = await conn.query(
|
||||||
|
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id IS NULL',
|
||||||
|
[tmpl.process_name]
|
||||||
|
);
|
||||||
|
if (genericPhase.length > 0) {
|
||||||
|
phaseId = genericPhase[0].phase_id;
|
||||||
|
} else {
|
||||||
|
// 신규 phase 생성 (제품유형 전용)
|
||||||
|
const [newPhase] = await conn.query(
|
||||||
|
'INSERT INTO schedule_phases (phase_name, display_order, product_type_id) VALUES (?, ?, ?)',
|
||||||
|
[tmpl.process_name, tmpl.display_order, productTypeId]
|
||||||
|
);
|
||||||
|
phaseId = newPhase.insertId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry 생성 (날짜 NULL — 관리자가 나중에 입력)
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO schedule_entries
|
||||||
|
(project_id, phase_id, task_name, start_date, end_date, status, progress, source, display_order, created_by)
|
||||||
|
VALUES (?, ?, ?, NULL, NULL, 'planned', 0, 'template', ?, ?)`,
|
||||||
|
[projectId, phaseId, tmpl.process_name, tmpl.display_order, createdBy]
|
||||||
|
);
|
||||||
|
createdCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { created: createdCount };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// === 부적합 연동 (격리 함수) ===
|
// === 부적합 연동 (격리 함수) ===
|
||||||
// 향후 System3 API 호출로 전환 시 이 함수만 수정
|
// 향후 System3 API 호출로 전환 시 이 함수만 수정
|
||||||
async getNonconformanceByProject(projectId) {
|
async getNonconformanceByProject(projectId) {
|
||||||
|
|||||||
@@ -83,6 +83,47 @@ const SettlementModel = {
|
|||||||
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
|
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 분류별 요약
|
||||||
|
async getCategorySummaryByReceived(yearMonth) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT ci.category,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(pr.quantity * COALESCE(p.unit_price, 0)) AS total_amount
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchases p ON p.request_id = pr.request_id
|
||||||
|
WHERE pr.status = 'received'
|
||||||
|
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
|
||||||
|
GROUP BY ci.category
|
||||||
|
`, [yearMonth]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 상세 목록
|
||||||
|
async getMonthlyReceived(yearMonth) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pr.request_id, pr.quantity, pr.received_at, pr.received_location,
|
||||||
|
pr.received_photo_path, pr.status, pr.notes,
|
||||||
|
ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price,
|
||||||
|
p.unit_price, p.purchase_date, p.vendor_id,
|
||||||
|
v.vendor_name,
|
||||||
|
su.name AS requester_name,
|
||||||
|
rsu.name AS received_by_name
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchases p ON p.request_id = pr.request_id
|
||||||
|
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||||
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
|
WHERE pr.status IN ('received', 'returned')
|
||||||
|
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
|
||||||
|
ORDER BY pr.received_at DESC
|
||||||
|
`, [yearMonth]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
// 가격 변동 목록 (월간)
|
// 가격 변동 목록 (월간)
|
||||||
async getPriceChanges(yearMonth) {
|
async getPriceChanges(yearMonth) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
vt.type_name,
|
svb.total_days, svb.used_days,
|
||||||
vt.type_code,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.priority,
|
svb.balance_type, svb.expires_at, svb.notes,
|
||||||
vt.is_special
|
svb.created_by, svb.created_at, svb.updated_at,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_name, vt.type_code, vt.priority, vt.is_special
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC, vt.type_name ASC
|
ORDER BY vt.priority ASC, vt.type_name ASC
|
||||||
`, [userId, year]);
|
`, [userId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -33,14 +34,16 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
vt.type_name,
|
svb.total_days, svb.used_days,
|
||||||
vt.type_code
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
FROM vacation_balance_details vbd
|
svb.balance_type, svb.expires_at,
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
vt.type_name, vt.type_code
|
||||||
WHERE vbd.user_id = ?
|
FROM sp_vacation_balances svb
|
||||||
AND vbd.vacation_type_id = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.year = ?
|
WHERE svb.user_id = ?
|
||||||
|
AND svb.vacation_type_id = ?
|
||||||
|
AND svb.year = ?
|
||||||
`, [userId, vacationTypeId, year]);
|
`, [userId, vacationTypeId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
@@ -52,16 +55,17 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
w.worker_name,
|
svb.total_days, svb.used_days,
|
||||||
w.employment_status,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.type_name,
|
svb.balance_type, svb.expires_at, svb.notes,
|
||||||
vt.type_code,
|
svb.created_by, svb.created_at, svb.updated_at,
|
||||||
vt.priority
|
w.worker_name, w.employment_status,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_name, vt.type_code, vt.priority
|
||||||
INNER JOIN workers w ON vbd.user_id = w.user_id
|
FROM sp_vacation_balances svb
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
INNER JOIN workers w ON svb.user_id = w.user_id
|
||||||
WHERE vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.year = ?
|
||||||
AND w.employment_status = 'employed'
|
AND w.employment_status = 'employed'
|
||||||
ORDER BY w.worker_name ASC, vt.priority ASC
|
ORDER BY w.worker_name ASC, vt.priority ASC
|
||||||
`, [year]);
|
`, [year]);
|
||||||
@@ -73,7 +77,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async create(balanceData) {
|
async create(balanceData) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`INSERT INTO vacation_balance_details SET ?`, balanceData);
|
const [result] = await db.query(`INSERT INTO sp_vacation_balances SET ?`, balanceData);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -82,7 +86,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async update(id, updateData) {
|
async update(id, updateData) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`UPDATE vacation_balance_details SET ? WHERE id = ?`, [updateData, id]);
|
const [result] = await db.query(`UPDATE sp_vacation_balances SET ? WHERE id = ?`, [updateData, id]);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -91,7 +95,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`DELETE FROM vacation_balance_details WHERE id = ?`, [id]);
|
const [result] = await db.query(`DELETE FROM sp_vacation_balances WHERE id = ?`, [id]);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ const vacationBalanceModel = {
|
|||||||
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
|
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`
|
const [result] = await db.query(`
|
||||||
UPDATE vacation_balance_details
|
UPDATE sp_vacation_balances
|
||||||
SET used_days = used_days + ?,
|
SET used_days = used_days + ?,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -117,7 +121,7 @@ const vacationBalanceModel = {
|
|||||||
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
|
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`
|
const [result] = await db.query(`
|
||||||
UPDATE vacation_balance_details
|
UPDATE sp_vacation_balances
|
||||||
SET used_days = GREATEST(0, used_days - ?),
|
SET used_days = GREATEST(0, used_days - ?),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -134,20 +138,21 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.id,
|
svb.id,
|
||||||
vbd.vacation_type_id,
|
svb.vacation_type_id,
|
||||||
vt.type_name,
|
vt.type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
vt.priority,
|
vt.priority,
|
||||||
vbd.total_days,
|
svb.total_days,
|
||||||
vbd.used_days,
|
svb.used_days,
|
||||||
vbd.remaining_days
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
FROM vacation_balance_details vbd
|
svb.balance_type
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.year = ?
|
WHERE svb.user_id = ?
|
||||||
AND vbd.remaining_days > 0
|
AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC
|
AND (svb.total_days - svb.used_days) > 0
|
||||||
|
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
||||||
`, [userId, year]);
|
`, [userId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
@@ -161,8 +166,8 @@ const vacationBalanceModel = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const query = `INSERT INTO vacation_balance_details
|
const query = `INSERT INTO sp_vacation_balances
|
||||||
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
|
||||||
VALUES ?`;
|
VALUES ?`;
|
||||||
|
|
||||||
const values = balances.map(b => [
|
const values = balances.map(b => [
|
||||||
@@ -172,7 +177,8 @@ const vacationBalanceModel = {
|
|||||||
b.total_days || 0,
|
b.total_days || 0,
|
||||||
b.used_days || 0,
|
b.used_days || 0,
|
||||||
b.notes || null,
|
b.notes || null,
|
||||||
b.created_by
|
b.created_by,
|
||||||
|
b.balance_type || 'AUTO'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [result] = await db.query(query, [values]);
|
const [result] = await db.query(query, [values]);
|
||||||
@@ -204,52 +210,57 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async deductByPriority(userId, year, daysToDeduct) {
|
async deductByPriority(userId, year, daysToDeduct) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
const [balances] = await db.query(`
|
const [balances] = await conn.query(`
|
||||||
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days,
|
SELECT svb.id, svb.vacation_type_id, svb.total_days, svb.used_days,
|
||||||
(vbd.total_days - vbd.used_days) as remaining_days,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.type_code, vt.type_name, vt.priority
|
svb.balance_type,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_code, vt.type_name, vt.priority
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND (vbd.total_days - vbd.used_days) > 0
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC
|
AND (svb.total_days - svb.used_days) > 0
|
||||||
`, [userId, year]);
|
AND (svb.expires_at IS NULL OR svb.expires_at >= CURDATE())
|
||||||
|
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
||||||
|
FOR UPDATE
|
||||||
|
`, [userId, year]);
|
||||||
|
|
||||||
if (balances.length === 0) {
|
if (balances.length === 0) {
|
||||||
console.warn(`[VacationBalance] 작업자 ${userId}의 ${year}년 휴가 잔액이 없습니다`);
|
await conn.rollback();
|
||||||
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
|
console.warn(`[VacationBalance] 작업자 ${userId}의 ${year}년 휴가 잔액이 없습니다`);
|
||||||
}
|
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
|
||||||
|
|
||||||
let remaining = daysToDeduct;
|
|
||||||
const deductions = [];
|
|
||||||
|
|
||||||
for (const balance of balances) {
|
|
||||||
if (remaining <= 0) break;
|
|
||||||
|
|
||||||
const available = parseFloat(balance.remaining_days);
|
|
||||||
const toDeduct = Math.min(remaining, available);
|
|
||||||
|
|
||||||
if (toDeduct > 0) {
|
|
||||||
await db.query(`
|
|
||||||
UPDATE vacation_balance_details
|
|
||||||
SET used_days = used_days + ?, updated_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
`, [toDeduct, balance.id]);
|
|
||||||
|
|
||||||
deductions.push({
|
|
||||||
balance_id: balance.id,
|
|
||||||
type_code: balance.type_code,
|
|
||||||
type_name: balance.type_name,
|
|
||||||
deducted: toDeduct
|
|
||||||
});
|
|
||||||
|
|
||||||
remaining -= toDeduct;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
|
let remaining = daysToDeduct;
|
||||||
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
|
const deductions = [];
|
||||||
|
|
||||||
|
for (const balance of balances) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
const available = parseFloat(balance.remaining_days);
|
||||||
|
const toDeduct = Math.min(remaining, available);
|
||||||
|
if (toDeduct > 0) {
|
||||||
|
await conn.query(`
|
||||||
|
UPDATE sp_vacation_balances
|
||||||
|
SET used_days = used_days + ?, updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`, [toDeduct, balance.id]);
|
||||||
|
deductions.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, deducted: toDeduct });
|
||||||
|
remaining -= toDeduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
|
||||||
|
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,46 +268,49 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async restoreByPriority(userId, year, daysToRestore) {
|
async restoreByPriority(userId, year, daysToRestore) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
const [balances] = await db.query(`
|
const [balances] = await conn.query(`
|
||||||
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days,
|
SELECT svb.id, svb.vacation_type_id, svb.used_days,
|
||||||
vt.type_code, vt.type_name, vt.priority
|
svb.balance_type,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_code, vt.type_name, vt.priority
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.used_days > 0
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority DESC
|
AND svb.used_days > 0
|
||||||
`, [userId, year]);
|
ORDER BY vt.priority DESC, FIELD(svb.balance_type, 'COMPANY_GRANT', 'LONG_SERVICE', 'MANUAL', 'AUTO', 'CARRY_OVER')
|
||||||
|
FOR UPDATE
|
||||||
|
`, [userId, year]);
|
||||||
|
|
||||||
let remaining = daysToRestore;
|
let remaining = daysToRestore;
|
||||||
const restorations = [];
|
const restorations = [];
|
||||||
|
|
||||||
for (const balance of balances) {
|
for (const balance of balances) {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
|
const usedDays = parseFloat(balance.used_days);
|
||||||
const usedDays = parseFloat(balance.used_days);
|
const toRestore = Math.min(remaining, usedDays);
|
||||||
const toRestore = Math.min(remaining, usedDays);
|
if (toRestore > 0) {
|
||||||
|
await conn.query(`
|
||||||
if (toRestore > 0) {
|
UPDATE sp_vacation_balances
|
||||||
await db.query(`
|
SET used_days = used_days - ?, updated_at = NOW()
|
||||||
UPDATE vacation_balance_details
|
WHERE id = ?
|
||||||
SET used_days = used_days - ?, updated_at = NOW()
|
`, [toRestore, balance.id]);
|
||||||
WHERE id = ?
|
restorations.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, restored: toRestore });
|
||||||
`, [toRestore, balance.id]);
|
remaining -= toRestore;
|
||||||
|
}
|
||||||
restorations.push({
|
|
||||||
balance_id: balance.id,
|
|
||||||
type_code: balance.type_code,
|
|
||||||
type_name: balance.type_name,
|
|
||||||
restored: toRestore
|
|
||||||
});
|
|
||||||
|
|
||||||
remaining -= toRestore;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
|
await conn.commit();
|
||||||
return { success: true, restorations, totalRestored: daysToRestore - remaining };
|
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
|
||||||
|
return { success: true, restorations, totalRestored: daysToRestore - remaining };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"express-validator": "^7.2.1",
|
"express-validator": "^7.2.1",
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ function setupRoutes(app) {
|
|||||||
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||||
|
app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황
|
||||||
|
app.use('/api/monthly-comparison', require('./routes/monthlyComparisonRoutes')); // 월간 비교·확인·정산
|
||||||
|
app.use('/api/dashboard', require('./routes/dashboardRoutes')); // 대시보드 개인 요약
|
||||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||||
|
|||||||
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal file
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/consumableCategoryController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', ctrl.getAll);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
|
||||||
|
router.put('/:id/deactivate', requirePage('factory_purchases'), ctrl.deactivate);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 라우터
|
||||||
|
* Sprint 003 — 개인 요약 API
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const dashboardController = require('../controllers/dashboardController');
|
||||||
|
const { verifyToken } = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// 모든 인증된 사용자 접근 가능
|
||||||
|
router.get('/my-summary', verifyToken, dashboardController.getMySummary);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const departmentController = require('../controllers/departmentController');
|
const departmentController = require('../controllers/departmentController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 부서 목록 조회 (인증 필요)
|
// 부서 목록 조회 (인증 필요)
|
||||||
router.get('/', requireAuth, departmentController.getAll);
|
router.get('/', requireAuth, departmentController.getAll);
|
||||||
@@ -14,18 +17,18 @@ router.get('/:id', requireAuth, departmentController.getById);
|
|||||||
router.get('/:id/workers', requireAuth, departmentController.getWorkers);
|
router.get('/:id/workers', requireAuth, departmentController.getWorkers);
|
||||||
|
|
||||||
// 부서 생성 (관리자만)
|
// 부서 생성 (관리자만)
|
||||||
router.post('/', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.create);
|
router.post('/', requireAuth, requirePage('factory_departments'), departmentController.create);
|
||||||
|
|
||||||
// 부서 수정 (관리자만)
|
// 부서 수정 (관리자만)
|
||||||
router.put('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.update);
|
router.put('/:id', requireAuth, requirePage('factory_departments'), departmentController.update);
|
||||||
|
|
||||||
// 부서 삭제 (관리자만)
|
// 부서 삭제 (관리자만)
|
||||||
router.delete('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.delete);
|
router.delete('/:id', requireAuth, requirePage('factory_departments'), departmentController.delete);
|
||||||
|
|
||||||
// 작업자 부서 이동 (관리자만)
|
// 작업자 부서 이동 (관리자만)
|
||||||
router.post('/move-worker', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorker);
|
router.post('/move-worker', requireAuth, requirePage('factory_departments'), departmentController.moveWorker);
|
||||||
|
|
||||||
// 여러 작업자 부서 일괄 이동 (관리자만)
|
// 여러 작업자 부서 일괄 이동 (관리자만)
|
||||||
router.post('/move-workers', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorkers);
|
router.post('/move-workers', requireAuth, requirePage('factory_departments'), departmentController.moveWorkers);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
12
system1-factory/api/routes/itemAliasRoutes.js
Normal file
12
system1-factory/api/routes/itemAliasRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/itemAliasController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/meetingController');
|
const ctrl = require('../controllers/meetingController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 회의록
|
// 회의록
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.get('/action-items', ctrl.getActionItems);
|
router.get('/action-items', ctrl.getActionItems);
|
||||||
router.get('/:id', ctrl.getById);
|
router.get('/:id', ctrl.getById);
|
||||||
router.post('/', requireMinLevel('support_team'), ctrl.create);
|
router.post('/', requirePage('factory_meetings'), ctrl.create);
|
||||||
router.put('/:id', requireMinLevel('support_team'), ctrl.update);
|
router.put('/:id', requirePage('factory_meetings'), ctrl.update);
|
||||||
router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish);
|
router.put('/:id/publish', requirePage('factory_meetings'), ctrl.publish);
|
||||||
router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish);
|
router.put('/:id/unpublish', requirePage('factory_meetings'), ctrl.unpublish);
|
||||||
router.delete('/:id', requireMinLevel('admin'), ctrl.delete);
|
router.delete('/:id', requirePage('factory_meetings'), ctrl.delete);
|
||||||
|
|
||||||
// 안건
|
// 안건
|
||||||
router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem);
|
router.post('/:id/items', requirePage('factory_meetings'), ctrl.addItem);
|
||||||
router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem);
|
router.put('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.updateItem);
|
||||||
router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem);
|
router.delete('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.deleteItem);
|
||||||
|
|
||||||
// 조치상태 업데이트
|
// 조치상태 업데이트
|
||||||
router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus);
|
router.put('/items/:itemId/status', requirePage('factory_meetings'), ctrl.updateItemStatus);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
41
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal file
41
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/monthlyComparisonController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
function requireSupportTeam(req, res, next) {
|
||||||
|
const role = (req.user?.role || '').toLowerCase();
|
||||||
|
if (!ADMIN_ROLES.includes(role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '지원팀 이상 권한이 필요합니다.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본인 월간 비교
|
||||||
|
router.get('/my-records', ctrl.getMyRecords);
|
||||||
|
|
||||||
|
// 특정 작업자 비교 (내부에서 권한 체크)
|
||||||
|
router.get('/records', ctrl.getRecords);
|
||||||
|
|
||||||
|
// 확인/반려
|
||||||
|
router.post('/confirm', ctrl.confirm);
|
||||||
|
|
||||||
|
// 관리자: 확인요청 발송 (pending → review_sent)
|
||||||
|
router.post('/review-send', requireSupportTeam, ctrl.reviewSend);
|
||||||
|
|
||||||
|
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
|
||||||
|
router.post('/review-respond', requireSupportTeam, ctrl.reviewRespond);
|
||||||
|
|
||||||
|
// 관리자: 개별 검토 태깅
|
||||||
|
router.post('/admin-check', requireSupportTeam, ctrl.adminCheck);
|
||||||
|
|
||||||
|
// 전체 현황 (support_team+)
|
||||||
|
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);
|
||||||
|
|
||||||
|
// 엑셀 다운로드 (support_team+)
|
||||||
|
router.get('/export', requireSupportTeam, ctrl.exportExcel);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,6 +3,11 @@ const router = express.Router();
|
|||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
const { requireAuth, requireAdmin } = require('../middlewares/auth');
|
const { requireAuth, requireAdmin } = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// Admin 역할 확인 헬퍼
|
||||||
|
function isAdminRole(role) {
|
||||||
|
return ['admin', 'system'].includes((role || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 페이지 목록 조회
|
* 모든 페이지 목록 조회
|
||||||
* GET /api/pages
|
* GET /api/pages
|
||||||
@@ -11,7 +16,7 @@ router.get('/pages', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [pages] = await db.query(`
|
const [pages] = await db.query(`
|
||||||
SELECT id, page_key, page_name, page_path, category, description, is_admin_only, display_order
|
SELECT id, page_key, page_name, page_path, category, description, display_order
|
||||||
FROM pages
|
FROM pages
|
||||||
ORDER BY display_order, page_name
|
ORDER BY display_order, page_name
|
||||||
`);
|
`);
|
||||||
@@ -19,7 +24,7 @@ router.get('/pages', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, data: pages });
|
res.json({ success: true, data: pages });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 목록 조회 오류:', error);
|
console.error('페이지 목록 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 목록을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,24 +37,21 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 사용자의 역할 확인
|
// 사용자 조회 (sso_users)
|
||||||
const [userRows] = await db.query(`
|
const [userRows] = await db.query(`
|
||||||
SELECT u.user_id, u.username, u.role_id, r.name as role_name
|
SELECT user_id, name, role FROM sso_users WHERE user_id = ?
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userRows[0];
|
const user = userRows[0];
|
||||||
|
|
||||||
// Admin/System Admin인 경우 모든 페이지 접근 가능
|
// Admin인 경우 모든 페이지 접근 가능
|
||||||
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
|
if (isAdminRole(user.role)) {
|
||||||
const [allPages] = await db.query(`
|
const [allPages] = await db.query(`
|
||||||
SELECT id, page_key, page_name, page_path, category, is_admin_only
|
SELECT id, page_key, page_name, page_path, category
|
||||||
FROM pages
|
FROM pages
|
||||||
ORDER BY display_order, page_name
|
ORDER BY display_order, page_name
|
||||||
`);
|
`);
|
||||||
@@ -60,15 +62,24 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
page_name: page.page_name,
|
page_name: page.page_name,
|
||||||
page_path: page.page_path,
|
page_path: page.page_path,
|
||||||
category: page.category,
|
category: page.category,
|
||||||
is_admin_only: page.is_admin_only,
|
|
||||||
can_access: true,
|
can_access: true,
|
||||||
is_default: true // Admin은 기본적으로 모든 권한 보유
|
is_default: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({ success: true, data: { user, pageAccess } });
|
return res.json({ success: true, data: { user, pageAccess } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자의 부서 조회 (workers 우선, 없으면 sso_users fallback)
|
||||||
|
const [workerRows] = await db.query(`
|
||||||
|
SELECT COALESCE(w.department_id, su2.department_id, 0) AS department_id
|
||||||
|
FROM sso_users su2
|
||||||
|
LEFT JOIN workers w ON su2.user_id = w.user_id
|
||||||
|
WHERE su2.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
const departmentId = workerRows[0]?.department_id || 0;
|
||||||
|
|
||||||
// 일반 사용자의 페이지 접근 권한 조회
|
// 일반 사용자의 페이지 접근 권한 조회
|
||||||
|
// department_page_permissions.page_name은 's1.' 접두사 사용, pages.page_key는 접두사 없음
|
||||||
const [pageAccess] = await db.query(`
|
const [pageAccess] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id as page_id,
|
p.id as page_id,
|
||||||
@@ -76,21 +87,22 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
p.page_name,
|
p.page_name,
|
||||||
p.page_path,
|
p.page_path,
|
||||||
p.category,
|
p.category,
|
||||||
p.is_admin_only,
|
COALESCE(upp.can_access, dpp.can_access, p.is_default_accessible, 0) as can_access,
|
||||||
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
|
upp.granted_at
|
||||||
upa.granted_at,
|
|
||||||
u2.username as granted_by_username
|
|
||||||
FROM pages p
|
FROM pages p
|
||||||
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
LEFT JOIN user_page_permissions upp
|
||||||
LEFT JOIN users u2 ON upa.granted_by = u2.user_id
|
ON upp.user_id = ?
|
||||||
WHERE p.is_admin_only = 0
|
AND (upp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR upp.page_name COLLATE utf8mb4_general_ci = p.page_key)
|
||||||
|
LEFT JOIN department_page_permissions dpp
|
||||||
|
ON dpp.department_id = ?
|
||||||
|
AND (dpp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR dpp.page_name COLLATE utf8mb4_general_ci = p.page_key)
|
||||||
ORDER BY p.display_order, p.page_name
|
ORDER BY p.display_order, p.page_name
|
||||||
`, [userId]);
|
`, [userId, departmentId]);
|
||||||
|
|
||||||
res.json({ success: true, data: { user, pageAccess } });
|
res.json({ success: true, data: { user, pageAccess } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 조회 오류:', error);
|
console.error('페이지 접근 권한 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,56 +113,35 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId } = req.params;
|
if (!isAdminRole(req.user.role)) {
|
||||||
const { pageIds, canAccess } = req.body;
|
return res.status(403).json({ success: false, message: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
||||||
const adminUserId = req.user.user_id; // 권한을 부여하는 Admin의 user_id
|
|
||||||
|
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { pageIds, canAccess } = req.body;
|
||||||
|
const adminUserId = req.user.user_id;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
// 사용자 존재 확인
|
// 사용자 존재 확인
|
||||||
const [userRows] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [userId]);
|
const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 접근 권한 업데이트
|
// 페이지 접근 권한 업데이트
|
||||||
for (const pageId of pageIds) {
|
for (const pageId of pageIds) {
|
||||||
// 기존 권한 확인
|
await db.query(`
|
||||||
const [existing] = await db.query(
|
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by)
|
||||||
'SELECT * FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
VALUES (?, ?, ?, ?)
|
||||||
[userId, pageId]
|
ON DUPLICATE KEY UPDATE can_access = ?, granted_at = NOW(), granted_by = ?
|
||||||
);
|
`, [userId, pageId, canAccess ? 1 : 0, adminUserId, canAccess ? 1 : 0, adminUserId]);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
// 업데이트
|
|
||||||
await db.query(
|
|
||||||
'UPDATE user_page_access SET can_access = ?, granted_at = NOW(), granted_by = ? WHERE user_id = ? AND page_id = ?',
|
|
||||||
[canAccess ? 1 : 0, adminUserId, userId, pageId]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 삽입
|
|
||||||
await db.query(
|
|
||||||
'INSERT INTO user_page_access (user_id, page_id, can_access, granted_by) VALUES (?, ?, ?, ?)',
|
|
||||||
[userId, pageId, canAccess ? 1 : 0, adminUserId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 부여 오류:', error);
|
console.error('페이지 접근 권한 부여 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,23 +151,13 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId, pageId } = req.params;
|
if (!isAdminRole(req.user.role)) {
|
||||||
const adminUserId = req.user.user_id;
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
|
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 접근 권한 삭제
|
const { userId, pageId } = req.params;
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
||||||
[userId, pageId]
|
[userId, pageId]
|
||||||
@@ -185,7 +166,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 회수 오류:', error);
|
console.error('페이지 접근 권한 회수 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,42 +176,29 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
*/
|
*/
|
||||||
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const adminUserId = req.user.user_id;
|
if (!isAdminRole(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 사용자와 페이지 권한 조회
|
const db = await getDb();
|
||||||
const [summary] = await db.query(`
|
const [summary] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
u.user_id,
|
su.user_id,
|
||||||
u.username,
|
su.name,
|
||||||
u.name,
|
su.role,
|
||||||
r.name as role_name,
|
|
||||||
COUNT(DISTINCT upa.page_id) as accessible_pages_count,
|
COUNT(DISTINCT upa.page_id) as accessible_pages_count,
|
||||||
(SELECT COUNT(*) FROM pages WHERE is_admin_only = 0) as total_pages_count
|
(SELECT COUNT(*) FROM pages) as total_pages_count
|
||||||
FROM users u
|
FROM sso_users su
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
LEFT JOIN user_page_access upa ON su.user_id = upa.user_id AND upa.can_access = 1
|
||||||
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
|
WHERE su.role NOT IN ('admin', 'system')
|
||||||
WHERE r.name NOT IN ('Admin', 'System Admin')
|
GROUP BY su.user_id, su.name, su.role
|
||||||
GROUP BY u.user_id, u.username, u.name, r.name
|
ORDER BY su.name
|
||||||
ORDER BY u.username
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({ success: true, data: summary });
|
res.json({ success: true, data: summary });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const projectController = require('../controllers/projectController');
|
const projectController = require('../controllers/projectController');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// READ - 인증된 사용자
|
// READ - 인증된 사용자
|
||||||
router.get('/', requireAuth, projectController.getAllProjects);
|
router.get('/', requireAuth, projectController.getAllProjects);
|
||||||
@@ -10,10 +13,10 @@ router.get('/active/list', requireAuth, projectController.getActiveProjects);
|
|||||||
router.get('/:project_id', requireAuth, projectController.getProjectById);
|
router.get('/:project_id', requireAuth, projectController.getProjectById);
|
||||||
|
|
||||||
// CREATE/UPDATE - support_team 이상 권한 필요
|
// CREATE/UPDATE - support_team 이상 권한 필요
|
||||||
router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject);
|
router.post('/', requireAuth, requirePage('factory_projects'), projectController.createProject);
|
||||||
router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject);
|
router.put('/:project_id', requireAuth, requirePage('factory_projects'), projectController.updateProject);
|
||||||
|
|
||||||
// DELETE - admin 이상 권한 필요
|
// DELETE - admin 이상 권한 필요
|
||||||
router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject);
|
router.delete('/:project_id', requireAuth, requirePage('factory_projects'), projectController.removeProject);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
20
system1-factory/api/routes/proxyInputRoutes.js
Normal file
20
system1-factory/api/routes/proxyInputRoutes.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 라우터
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const proxyInputController = require('../controllers/proxyInputController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
// 대리입력
|
||||||
|
router.post('/', requirePage('factory_proxy_input'), proxyInputController.proxyInput);
|
||||||
|
|
||||||
|
// 일별 현황
|
||||||
|
router.get('/daily-status', requirePage('factory_daily_status'), proxyInputController.getDailyStatus);
|
||||||
|
|
||||||
|
// 작업자별 상세
|
||||||
|
router.get('/daily-status/detail', requirePage('factory_daily_status'), proxyInputController.getDailyStatusDetail);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
16
system1-factory/api/routes/purchaseBatchRoutes.js
Normal file
16
system1-factory/api/routes/purchaseBatchRoutes.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/purchaseBatchController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
|
||||||
|
router.get('/:id', requirePage('factory_purchases'), ctrl.getById);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
|
||||||
|
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
|
||||||
|
router.post('/:id/purchase', requirePage('factory_purchases'), ctrl.purchase);
|
||||||
|
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/purchaseRequestController');
|
const ctrl = require('../controllers/purchaseRequestController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 보조 데이터
|
// 보조 데이터
|
||||||
router.get('/consumable-items', ctrl.getConsumableItems);
|
router.get('/consumable-items', ctrl.getConsumableItems);
|
||||||
|
router.put('/consumable-items/:id/photo', ctrl.updateItemPhoto);
|
||||||
router.get('/vendors', ctrl.getVendors);
|
router.get('/vendors', ctrl.getVendors);
|
||||||
|
router.get('/search', ctrl.search);
|
||||||
|
|
||||||
|
// 내 신청 (모바일용 페이지네이션) — /:id 보다 먼저 등록
|
||||||
|
router.get('/my-requests', ctrl.getMyRequests);
|
||||||
|
|
||||||
|
// 품목 등록 + 신청 동시 (트랜잭션)
|
||||||
|
router.post('/register-and-request', ctrl.registerAndRequest);
|
||||||
|
// 일괄 신청 (장바구니)
|
||||||
|
router.post('/bulk', ctrl.bulkCreate);
|
||||||
|
|
||||||
// 구매신청 CRUD
|
// 구매신청 CRUD
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.get('/:id', ctrl.getById);
|
router.get('/:id', ctrl.getById);
|
||||||
router.post('/', ctrl.create);
|
router.post('/', ctrl.create);
|
||||||
router.put('/:id/hold', requireMinLevel('admin'), ctrl.hold);
|
router.put('/:id/hold', requirePage('factory_purchases'), ctrl.hold);
|
||||||
router.put('/:id/revert', requireMinLevel('admin'), ctrl.revert);
|
router.put('/:id/revert', requirePage('factory_purchases'), ctrl.revert);
|
||||||
|
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
|
||||||
|
router.put('/:id/cancel', requirePage('factory_purchases'), ctrl.cancel);
|
||||||
|
router.put('/:id/return', requirePage('factory_purchases'), ctrl.returnItem);
|
||||||
|
router.put('/:id/revert-cancel', requirePage('factory_purchases'), ctrl.revertCancel);
|
||||||
router.delete('/:id', ctrl.delete);
|
router.delete('/:id', ctrl.delete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/purchaseController');
|
const ctrl = require('../controllers/purchaseController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.post('/', requireMinLevel('admin'), ctrl.create);
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
router.get('/price-history/:itemId', ctrl.getPriceHistory);
|
router.get('/price-history/:itemId', ctrl.getPriceHistory);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/scheduleController');
|
const ctrl = require('../controllers/scheduleController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
// 제품유형
|
||||||
|
router.get('/product-types', ctrl.getProductTypes);
|
||||||
|
|
||||||
|
// 표준공정 자동 생성
|
||||||
|
router.post('/generate-from-template', requirePage('factory_schedules'), ctrl.generateFromTemplate);
|
||||||
|
|
||||||
// 공정 단계
|
// 공정 단계
|
||||||
router.get('/phases', ctrl.getPhases);
|
router.get('/phases', ctrl.getPhases);
|
||||||
router.post('/phases', requireMinLevel('admin'), ctrl.createPhase);
|
router.post('/phases', requirePage('factory_schedules'), ctrl.createPhase);
|
||||||
router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase);
|
router.put('/phases/:id', requirePage('factory_schedules'), ctrl.updatePhase);
|
||||||
|
|
||||||
// 작업 템플릿
|
// 작업 템플릿
|
||||||
router.get('/templates', ctrl.getTemplates);
|
router.get('/templates', ctrl.getTemplates);
|
||||||
@@ -14,21 +22,21 @@ router.get('/templates', ctrl.getTemplates);
|
|||||||
// 공정표 항목
|
// 공정표 항목
|
||||||
router.get('/entries', ctrl.getEntries);
|
router.get('/entries', ctrl.getEntries);
|
||||||
router.get('/entries/gantt', ctrl.getGanttData);
|
router.get('/entries/gantt', ctrl.getGanttData);
|
||||||
router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry);
|
router.post('/entries', requirePage('factory_schedules'), ctrl.createEntry);
|
||||||
router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries);
|
router.post('/entries/batch', requirePage('factory_schedules'), ctrl.createBatchEntries);
|
||||||
router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry);
|
router.put('/entries/:id', requirePage('factory_schedules'), ctrl.updateEntry);
|
||||||
router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress);
|
router.put('/entries/:id/progress', requirePage('factory_schedules'), ctrl.updateProgress);
|
||||||
router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry);
|
router.delete('/entries/:id', requirePage('factory_schedules'), ctrl.deleteEntry);
|
||||||
|
|
||||||
// 의존관계
|
// 의존관계
|
||||||
router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency);
|
router.post('/entries/:id/dependencies', requirePage('factory_schedules'), ctrl.addDependency);
|
||||||
router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency);
|
router.delete('/entries/:id/dependencies/:depId', requirePage('factory_schedules'), ctrl.removeDependency);
|
||||||
|
|
||||||
// 마일스톤
|
// 마일스톤
|
||||||
router.get('/milestones', ctrl.getMilestones);
|
router.get('/milestones', ctrl.getMilestones);
|
||||||
router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone);
|
router.post('/milestones', requirePage('factory_schedules'), ctrl.createMilestone);
|
||||||
router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone);
|
router.put('/milestones/:id', requirePage('factory_schedules'), ctrl.updateMilestone);
|
||||||
router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone);
|
router.delete('/milestones/:id', requirePage('factory_schedules'), ctrl.deleteMilestone);
|
||||||
|
|
||||||
// 부적합 연동
|
// 부적합 연동
|
||||||
router.get('/nonconformance', ctrl.getNonconformance);
|
router.get('/nonconformance', ctrl.getNonconformance);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/settlementController');
|
const ctrl = require('../controllers/settlementController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
router.get('/summary', ctrl.getMonthlySummary);
|
router.get('/summary', ctrl.getMonthlySummary);
|
||||||
router.get('/purchases', ctrl.getMonthlyPurchases);
|
router.get('/purchases', ctrl.getMonthlyPurchases);
|
||||||
router.get('/price-changes', ctrl.getPriceChanges);
|
router.get('/price-changes', ctrl.getPriceChanges);
|
||||||
router.post('/complete', requireMinLevel('admin'), ctrl.complete);
|
router.get('/received-summary', ctrl.getMonthlyReceivedSummary);
|
||||||
router.post('/cancel', requireMinLevel('admin'), ctrl.cancel);
|
router.get('/received-list', ctrl.getMonthlyReceivedList);
|
||||||
|
router.post('/complete', requirePage('factory_settlements'), ctrl.complete);
|
||||||
|
router.post('/cancel', requirePage('factory_settlements'), ctrl.cancel);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const TbmController = require('../controllers/tbmController');
|
const TbmController = require('../controllers/tbmController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// ==================== TBM 세션 관련 ====================
|
// ==================== TBM 세션 관련 ====================
|
||||||
|
|
||||||
@@ -56,13 +59,13 @@ router.delete('/sessions/:sessionId/team/:userId', requireAuth, TbmController.re
|
|||||||
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
||||||
|
|
||||||
// 안전 체크 항목 생성 (관리자용)
|
// 안전 체크 항목 생성 (관리자용)
|
||||||
router.post('/safety-checks', requireAuth, requireRole('admin', 'system'), TbmController.createSafetyCheck);
|
router.post('/safety-checks', requireAuth, requirePage('factory_tbm'), TbmController.createSafetyCheck);
|
||||||
|
|
||||||
// 안전 체크 항목 수정 (관리자용)
|
// 안전 체크 항목 수정 (관리자용)
|
||||||
router.put('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.updateSafetyCheck);
|
router.put('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.updateSafetyCheck);
|
||||||
|
|
||||||
// 안전 체크 항목 삭제 (관리자용)
|
// 안전 체크 항목 삭제 (관리자용)
|
||||||
router.delete('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.deleteSafetyCheck);
|
router.delete('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.deleteSafetyCheck);
|
||||||
|
|
||||||
// TBM 세션의 안전 체크 기록 조회
|
// TBM 세션의 안전 체크 기록 조회
|
||||||
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const controller = require('../controllers/toolsController');
|
const controller = require('../controllers/toolsController');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 읽기 작업: 인증된 사용자
|
// 읽기 작업: 인증된 사용자
|
||||||
router.get('/', requireAuth, controller.getAll);
|
router.get('/', requireAuth, controller.getAll);
|
||||||
router.get('/:id', requireAuth, controller.getById);
|
router.get('/:id', requireAuth, controller.getById);
|
||||||
|
|
||||||
// 쓰기 작업: group_leader 이상 권한 필요
|
// 쓰기 작업: group_leader 이상 권한 필요
|
||||||
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
|
router.post('/', requireAuth, requirePage('factory_tools'), controller.create);
|
||||||
router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update);
|
router.put('/:id', requireAuth, requirePage('factory_tools'), controller.update);
|
||||||
router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete);
|
router.delete('/:id', requireAuth, requirePage('factory_tools'), controller.delete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
|
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
@@ -31,7 +34,7 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 관리자 권한 필요
|
// 관리자 권한 필요
|
||||||
router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => {
|
router.post('/upload-bg', requireAuth, requirePage('factory_uploads'), upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
|
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const workIssueController = require('../controllers/workIssueController');
|
const workIssueController = require('../controllers/workIssueController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// ==================== 카테고리 관리 ====================
|
// ==================== 카테고리 관리 ====================
|
||||||
|
|
||||||
@@ -16,13 +18,13 @@ router.get('/categories', workIssueController.getAllCategories);
|
|||||||
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
|
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
|
||||||
|
|
||||||
// 카테고리 생성 (admin 이상)
|
// 카테고리 생성 (admin 이상)
|
||||||
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory);
|
router.post('/categories', requirePage('factory_work_issues'), workIssueController.createCategory);
|
||||||
|
|
||||||
// 카테고리 수정 (admin 이상)
|
// 카테고리 수정 (admin 이상)
|
||||||
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory);
|
router.put('/categories/:id', requirePage('factory_work_issues'), workIssueController.updateCategory);
|
||||||
|
|
||||||
// 카테고리 삭제 (admin 이상)
|
// 카테고리 삭제 (admin 이상)
|
||||||
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory);
|
router.delete('/categories/:id', requirePage('factory_work_issues'), workIssueController.deleteCategory);
|
||||||
|
|
||||||
// ==================== 사전 정의 항목 관리 ====================
|
// ==================== 사전 정의 항목 관리 ====================
|
||||||
|
|
||||||
@@ -33,24 +35,24 @@ router.get('/items', workIssueController.getAllItems);
|
|||||||
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
|
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
|
||||||
|
|
||||||
// 항목 생성 (admin 이상)
|
// 항목 생성 (admin 이상)
|
||||||
router.post('/items', requireMinLevel('admin'), workIssueController.createItem);
|
router.post('/items', requirePage('factory_work_issues'), workIssueController.createItem);
|
||||||
|
|
||||||
// 항목 수정 (admin 이상)
|
// 항목 수정 (admin 이상)
|
||||||
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem);
|
router.put('/items/:id', requirePage('factory_work_issues'), workIssueController.updateItem);
|
||||||
|
|
||||||
// 항목 삭제 (admin 이상)
|
// 항목 삭제 (admin 이상)
|
||||||
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem);
|
router.delete('/items/:id', requirePage('factory_work_issues'), workIssueController.deleteItem);
|
||||||
|
|
||||||
// ==================== 통계 ====================
|
// ==================== 통계 ====================
|
||||||
|
|
||||||
// 통계 요약 (support_team 이상)
|
// 통계 요약 (support_team 이상)
|
||||||
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary);
|
router.get('/stats/summary', requirePage('factory_work_issues'), workIssueController.getStatsSummary);
|
||||||
|
|
||||||
// 카테고리별 통계 (support_team 이상)
|
// 카테고리별 통계 (support_team 이상)
|
||||||
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory);
|
router.get('/stats/by-category', requirePage('factory_work_issues'), workIssueController.getStatsByCategory);
|
||||||
|
|
||||||
// 작업장별 통계 (support_team 이상)
|
// 작업장별 통계 (support_team 이상)
|
||||||
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace);
|
router.get('/stats/by-workplace', requirePage('factory_work_issues'), workIssueController.getStatsByWorkplace);
|
||||||
|
|
||||||
// ==================== 문제 신고 관리 ====================
|
// ==================== 문제 신고 관리 ====================
|
||||||
|
|
||||||
@@ -72,10 +74,10 @@ router.delete('/:id', workIssueController.deleteReport);
|
|||||||
// ==================== 상태 관리 ====================
|
// ==================== 상태 관리 ====================
|
||||||
|
|
||||||
// 신고 접수 (support_team 이상)
|
// 신고 접수 (support_team 이상)
|
||||||
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport);
|
router.put('/:id/receive', requirePage('factory_work_issues'), workIssueController.receiveReport);
|
||||||
|
|
||||||
// 담당자 배정 (support_team 이상)
|
// 담당자 배정 (support_team 이상)
|
||||||
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport);
|
router.put('/:id/assign', requirePage('factory_work_issues'), workIssueController.assignReport);
|
||||||
|
|
||||||
// 처리 시작
|
// 처리 시작
|
||||||
router.put('/:id/start', workIssueController.startProcessing);
|
router.put('/:id/start', workIssueController.startProcessing);
|
||||||
@@ -84,7 +86,7 @@ router.put('/:id/start', workIssueController.startProcessing);
|
|||||||
router.put('/:id/complete', workIssueController.completeReport);
|
router.put('/:id/complete', workIssueController.completeReport);
|
||||||
|
|
||||||
// 신고 종료 (admin 이상)
|
// 신고 종료 (admin 이상)
|
||||||
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport);
|
router.put('/:id/close', requirePage('factory_work_issues'), workIssueController.closeReport);
|
||||||
|
|
||||||
// 상태 변경 이력 조회
|
// 상태 변경 이력 조회
|
||||||
router.get('/:id/logs', workIssueController.getStatusLogs);
|
router.get('/:id/logs', workIssueController.getStatusLogs);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const workReportAnalysisController = require('../controllers/workReportAnalysisController');
|
const workReportAnalysisController = require('../controllers/workReportAnalysisController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 🔒 모든 분석 라우트에 인증 + Admin 권한 필요
|
// 🔒 모든 분석 라우트에 인증 + Admin 권한 필요
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.use(requireRole('admin', 'system'));
|
router.use(requirePage('factory_work_analysis'));
|
||||||
|
|
||||||
// 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
// 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
||||||
router.get('/filters', workReportAnalysisController.getAnalysisFilters);
|
router.get('/filters', workReportAnalysisController.getAnalysisFilters);
|
||||||
|
|||||||
@@ -9,16 +9,21 @@
|
|||||||
|
|
||||||
const AttendanceModel = require('../models/attendanceModel');
|
const AttendanceModel = require('../models/attendanceModel');
|
||||||
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴가 사용 유형 ID를 차감 일수로 변환
|
* 휴가 사용 유형 ID → 차감 일수 (DB vacation_types.deduct_days 조회)
|
||||||
* vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일)
|
|
||||||
*/
|
*/
|
||||||
const getVacationDays = (vacationTypeId) => {
|
const getVacationDays = async (vacationTypeId) => {
|
||||||
const daysMap = { 1: 1, 2: 0.5, 3: 0.25 };
|
if (!vacationTypeId) return 0;
|
||||||
return daysMap[vacationTypeId] || 0;
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT deduct_days FROM vacation_types WHERE id = ?',
|
||||||
|
[vacationTypeId]
|
||||||
|
);
|
||||||
|
return rows.length > 0 ? parseFloat(rows[0].deduct_days) || 0 : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,8 +148,8 @@ const upsertAttendanceRecordService = async (recordData) => {
|
|||||||
|
|
||||||
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
|
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
|
||||||
const year = new Date(record_date).getFullYear();
|
const year = new Date(record_date).getFullYear();
|
||||||
const previousDays = getVacationDays(previousVacationTypeId);
|
const previousDays = await getVacationDays(previousVacationTypeId);
|
||||||
const newDays = getVacationDays(vacation_type_id);
|
const newDays = await getVacationDays(vacation_type_id);
|
||||||
|
|
||||||
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
|
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
|
||||||
if (previousDays !== newDays) {
|
if (previousDays !== newDays) {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ try {
|
|||||||
const UPLOAD_DIRS = {
|
const UPLOAD_DIRS = {
|
||||||
issues: path.join(__dirname, '../uploads/issues'),
|
issues: path.join(__dirname, '../uploads/issues'),
|
||||||
equipments: path.join(__dirname, '../uploads/equipments'),
|
equipments: path.join(__dirname, '../uploads/equipments'),
|
||||||
purchase_requests: path.join(__dirname, '../uploads/purchase_requests')
|
purchase_requests: path.join(__dirname, '../uploads/purchase_requests'),
|
||||||
|
purchase_received: path.join(__dirname, '../uploads/purchase_received'),
|
||||||
|
consumables: path.join(__dirname, '../uploads/consumables')
|
||||||
};
|
};
|
||||||
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
||||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||||
|
|||||||
156
system1-factory/api/utils/koreanSearch.js
Normal file
156
system1-factory/api/utils/koreanSearch.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 한국어 스마트 검색 유틸리티
|
||||||
|
* - 초성 추출 및 매칭
|
||||||
|
* - 별칭(alias) 매칭
|
||||||
|
* - 인메모리 캐시 (5분 TTL)
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
// 초성 목록 (19개)
|
||||||
|
const CHOSUNG = [
|
||||||
|
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||||
|
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 자음 문자 집합 (초성 판별용)
|
||||||
|
const JAMO_SET = new Set([
|
||||||
|
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||||
|
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 캐시
|
||||||
|
let cache = null;
|
||||||
|
let cacheTime = 0;
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글 완성형 문자에서 초성 추출
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {string} 초성 문자열
|
||||||
|
*/
|
||||||
|
function extractChosung(str) {
|
||||||
|
let result = '';
|
||||||
|
for (const ch of str) {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||||
|
const idx = Math.floor((code - 0xAC00) / (21 * 28));
|
||||||
|
result += CHOSUNG[idx];
|
||||||
|
} else {
|
||||||
|
result += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색어가 모두 자음(초성)인지 판별
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isChosungOnly(query) {
|
||||||
|
if (query.length < 2) return false;
|
||||||
|
for (const ch of query) {
|
||||||
|
if (!JAMO_SET.has(ch)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 로드 (consumable_items + item_aliases)
|
||||||
|
*/
|
||||||
|
async function loadCache() {
|
||||||
|
if (cache && (Date.now() - cacheTime < CACHE_TTL)) return cache;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const [items] = await db.query(
|
||||||
|
`SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path
|
||||||
|
FROM consumable_items WHERE is_active = 1`
|
||||||
|
);
|
||||||
|
const [aliases] = await db.query(
|
||||||
|
`SELECT alias_id, item_id, alias_name FROM item_aliases`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 아이템별 별칭 맵 생성
|
||||||
|
const aliasMap = {};
|
||||||
|
for (const a of aliases) {
|
||||||
|
if (!aliasMap[a.item_id]) aliasMap[a.item_id] = [];
|
||||||
|
aliasMap[a.item_id].push(a.alias_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성 미리 계산
|
||||||
|
const enriched = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
aliases: aliasMap[item.item_id] || [],
|
||||||
|
chosung_name: extractChosung(item.item_name),
|
||||||
|
chosung_aliases: (aliasMap[item.item_id] || []).map(a => extractChosung(a))
|
||||||
|
}));
|
||||||
|
|
||||||
|
cache = enriched;
|
||||||
|
cacheTime = Date.now();
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 무효화
|
||||||
|
*/
|
||||||
|
function clearCache() {
|
||||||
|
cache = null;
|
||||||
|
cacheTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스마트 검색
|
||||||
|
* @param {string} query - 검색어
|
||||||
|
* @returns {Promise<Array>} 스코어 기준 상위 20건
|
||||||
|
*/
|
||||||
|
async function search(query) {
|
||||||
|
if (!query || query.trim().length === 0) return [];
|
||||||
|
const items = await loadCache();
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const qChosung = isChosungOnly(q) ? q : null;
|
||||||
|
|
||||||
|
const scored = [];
|
||||||
|
for (const item of items) {
|
||||||
|
let score = 0;
|
||||||
|
let matchType = '';
|
||||||
|
|
||||||
|
const nameLower = item.item_name.toLowerCase();
|
||||||
|
const specLower = (item.spec || '').toLowerCase();
|
||||||
|
const makerLower = (item.maker || '').toLowerCase();
|
||||||
|
|
||||||
|
// exact match (이름 완전 일치)
|
||||||
|
if (nameLower === q) {
|
||||||
|
score = 100; matchType = 'exact';
|
||||||
|
}
|
||||||
|
// substring match (이름)
|
||||||
|
else if (nameLower.includes(q)) {
|
||||||
|
score = 80; matchType = 'name';
|
||||||
|
}
|
||||||
|
// alias match
|
||||||
|
else if (item.aliases.some(a => a.toLowerCase().includes(q))) {
|
||||||
|
score = 75; matchType = 'alias';
|
||||||
|
}
|
||||||
|
// spec/maker match
|
||||||
|
else if (specLower.includes(q) || makerLower.includes(q)) {
|
||||||
|
score = 70; matchType = 'spec';
|
||||||
|
}
|
||||||
|
// 초성 매칭 (이름)
|
||||||
|
else if (qChosung && item.chosung_name.includes(qChosung)) {
|
||||||
|
score = 50; matchType = 'chosung';
|
||||||
|
}
|
||||||
|
// 초성 매칭 (별칭)
|
||||||
|
else if (qChosung && item.chosung_aliases.some(ca => ca.includes(qChosung))) {
|
||||||
|
score = 40; matchType = 'chosung_alias';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
scored.push({ ...item, _score: score, _matchType: matchType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점수 높은 순, 같은 점수면 이름 짧은 순 (더 구체적)
|
||||||
|
scored.sort((a, b) => b._score - a._score || a.item_name.length - b.item_name.length);
|
||||||
|
return scored.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search, clearCache, extractChosung, isChosungOnly };
|
||||||
@@ -11,11 +11,15 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# non-root user 생성
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
# 애플리케이션 코드 복사
|
# 애플리케이션 코드 복사
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 애플리케이션 실행
|
# 애플리케이션 실행
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -8,6 +8,7 @@ class Settings:
|
|||||||
# 기본 설정
|
# 기본 설정
|
||||||
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
||||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
||||||
|
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import jwt as pyjwt
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -206,10 +207,29 @@ async def analytics_dashboard():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _verify_proxy_token(request: Request) -> dict:
|
||||||
|
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
if not settings.JWT_SECRET:
|
||||||
|
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||||
|
return payload
|
||||||
|
except pyjwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||||
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
||||||
|
|
||||||
|
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
|
||||||
|
user_payload = _verify_proxy_token(request)
|
||||||
|
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
|
||||||
|
|
||||||
# Express.js API URL 구성
|
# Express.js API URL 구성
|
||||||
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
||||||
|
|
||||||
@@ -219,9 +239,9 @@ async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
|||||||
|
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
|
|
||||||
# GET 요청에 대해서만 캐싱 적용
|
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
cache_key = cache_manager._generate_key("api", path, **params)
|
cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
|
||||||
cached_result = await cache_manager.get(cache_key)
|
cached_result = await cache_manager.get(cache_key)
|
||||||
|
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ aiohttp==3.9.1
|
|||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
PyJWT==2.8.0
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# 정적 파일 복사
|
|
||||||
COPY . /usr/share/nginx/html/
|
|
||||||
|
|
||||||
# 디렉토리 권한 보정 (macOS에서 복사 시 700이 되는 문제 방지)
|
|
||||||
RUN find /usr/share/nginx/html -type d -exec chmod 755 {} +
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY public/ /usr/share/nginx/html/
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
// js/change-password.js
|
|
||||||
// 개인 비밀번호 변경 페이지 JavaScript
|
|
||||||
|
|
||||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
|
||||||
|
|
||||||
// 인증 확인
|
|
||||||
const token = ensureAuthenticated();
|
|
||||||
|
|
||||||
// DOM 요소
|
|
||||||
const form = document.getElementById('changePasswordForm');
|
|
||||||
const messageArea = document.getElementById('message-area');
|
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
|
||||||
const resetBtn = document.getElementById('resetBtn');
|
|
||||||
|
|
||||||
// 비밀번호 토글 기능
|
|
||||||
document.querySelectorAll('.password-toggle').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const targetId = this.getAttribute('data-target');
|
|
||||||
const input = document.getElementById(targetId);
|
|
||||||
|
|
||||||
if (input) {
|
|
||||||
const isPassword = input.type === 'password';
|
|
||||||
input.type = isPassword ? 'text' : 'password';
|
|
||||||
this.textContent = isPassword ? '👁️🗨️' : '👁️';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 초기화 버튼
|
|
||||||
resetBtn?.addEventListener('click', () => {
|
|
||||||
form.reset();
|
|
||||||
clearMessages();
|
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 메시지 표시 함수
|
|
||||||
function showMessage(type, message) {
|
|
||||||
messageArea.innerHTML = `
|
|
||||||
<div class="message-box ${type}">
|
|
||||||
${type === 'error' ? '❌' : '✅'} ${message}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 에러 메시지는 5초 후 자동 제거
|
|
||||||
if (type === 'error') {
|
|
||||||
setTimeout(clearMessages, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMessages() {
|
|
||||||
messageArea.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 강도 체크
|
|
||||||
async function checkPasswordStrength(password) {
|
|
||||||
if (!password) {
|
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/auth/check-password-strength`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await res.json();
|
|
||||||
updatePasswordStrengthUI(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Password strength check error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 강도 UI 업데이트
|
|
||||||
function updatePasswordStrengthUI(strength) {
|
|
||||||
const container = document.getElementById('passwordStrength');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
0: '#f44336',
|
|
||||||
1: '#ff9800',
|
|
||||||
2: '#ffc107',
|
|
||||||
3: '#4caf50',
|
|
||||||
4: '#2196f3'
|
|
||||||
};
|
|
||||||
|
|
||||||
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
|
|
||||||
const color = colors[strength.strength] || '#ccc';
|
|
||||||
const percentage = (strength.score / strength.maxScore) * 100;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div style="margin-top: 12px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
|
|
||||||
${strengthText}
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.8rem; color: #666;">
|
|
||||||
${strength.score}/${strength.maxScore}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
|
||||||
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
|
|
||||||
</div>
|
|
||||||
${strength.feedback && strength.feedback.length > 0 ? `
|
|
||||||
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
|
|
||||||
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 입력 이벤트
|
|
||||||
let strengthCheckTimer;
|
|
||||||
document.getElementById('newPassword')?.addEventListener('input', (e) => {
|
|
||||||
clearTimeout(strengthCheckTimer);
|
|
||||||
strengthCheckTimer = setTimeout(() => {
|
|
||||||
checkPasswordStrength(e.target.value);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
form?.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
clearMessages();
|
|
||||||
|
|
||||||
const currentPassword = document.getElementById('currentPassword').value;
|
|
||||||
const newPassword = document.getElementById('newPassword').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
// 유효성 검사
|
|
||||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
||||||
showMessage('error', '모든 필드를 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPassword === newPassword) {
|
|
||||||
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 버튼 상태 변경
|
|
||||||
const originalText = submitBtn.innerHTML;
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/auth/change-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword,
|
|
||||||
newPassword
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await res.json();
|
|
||||||
|
|
||||||
if (res.ok && result.success) {
|
|
||||||
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
|
|
||||||
form.reset();
|
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
|
||||||
|
|
||||||
// 카운트다운 시작
|
|
||||||
let countdown = 3;
|
|
||||||
const countdownInterval = setInterval(() => {
|
|
||||||
showMessage('success',
|
|
||||||
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
|
|
||||||
);
|
|
||||||
countdown--;
|
|
||||||
|
|
||||||
if (countdown < 0) {
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
if (window.clearSSOAuth) window.clearSSOAuth();
|
|
||||||
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
|
|
||||||
showMessage('error', errorMessage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Password change error:', error);
|
|
||||||
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
|
||||||
} finally {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 페이지 로드 시 현재 사용자 정보 표시
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -12,18 +12,53 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# 민감 파일 차단 — exact match (^~ 우회 불가)
|
||||||
|
location = /Dockerfile { return 404; }
|
||||||
|
location = /docker-compose.yml { return 404; }
|
||||||
|
location = /nginx.conf { return 404; }
|
||||||
|
location = /.env { return 404; }
|
||||||
|
location = /.gitignore { return 404; }
|
||||||
|
|
||||||
|
# .git 디렉토리 전체 차단
|
||||||
|
location ^~ /.git/ { return 404; }
|
||||||
|
location = /.git { return 404; }
|
||||||
|
|
||||||
|
# 민감 파일 차단 — regex (하위 경로 + 변형 대비)
|
||||||
|
location ~* (Dockerfile|docker-compose|\.env|nginx\.conf) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
# HTML 캐시 비활성화
|
# HTML 캐시 비활성화
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires -1;
|
expires -1;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SW, manifest 캐시 비활성화 (PWA 업데이트 즉시 반영)
|
||||||
|
location = /sw.js {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
location = /manifest.json {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# 정적 파일 캐시 (JS, CSS, 이미지 등)
|
# 정적 파일 캐시 (JS, CSS, 이미지 등)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, no-transform";
|
add_header Cache-Control "public, no-transform";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SSO Auth API 프록시 (/api/auth/* → sso-auth)
|
||||||
|
location /api/auth/ {
|
||||||
|
set $upstream http://sso-auth:3000;
|
||||||
|
proxy_pass $upstream$request_uri;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# API 프록시 (System 1 API)
|
# API 프록시 (System 1 API)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://system1-api:3005;
|
set $upstream http://system1-api:3005;
|
||||||
@@ -81,12 +116,19 @@ server {
|
|||||||
proxy_send_timeout 180s;
|
proxy_send_timeout 180s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 레거시 /login, /dashboard → gateway(tkds) 리다이렉트
|
# /login → 로그인 페이지 (gateway 대시보드)
|
||||||
|
# tkfb.technicalkorea.net이 system1-web을 직접 가리키므로
|
||||||
|
# 외부 리다이렉트 대신 gateway 내부 프록시로 처리
|
||||||
location = /login {
|
location = /login {
|
||||||
return 302 $scheme://tkds.technicalkorea.net/dashboard$is_args$args;
|
set $gw http://gateway:80;
|
||||||
|
rewrite ^/login$ /dashboard break;
|
||||||
|
proxy_pass $gw;
|
||||||
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
location = /dashboard {
|
location = /dashboard {
|
||||||
return 301 $scheme://tkds.technicalkorea.net/dashboard;
|
set $gw http://gateway:80;
|
||||||
|
proxy_pass $gw;
|
||||||
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>대시보드 - TK 공장관리</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50">
|
|
||||||
<header class="bg-orange-700 text-white sticky top-0 z-50">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center h-14">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
|
|
||||||
<i class="fas fa-bars text-xl"></i>
|
|
||||||
</button>
|
|
||||||
<i class="fas fa-industry text-xl text-orange-200"></i>
|
|
||||||
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
|
||||||
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
|
||||||
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
|
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile overlay -->
|
|
||||||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<!-- Sidebar Nav -->
|
|
||||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- 날짜/시간 헤더 -->
|
|
||||||
<div class="flex items-center justify-between mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">대시보드</h2>
|
|
||||||
<p class="text-sm text-gray-500 mt-0.5" id="dateTimeDisplay">-</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="loadDashboard()" class="text-sm text-gray-500 hover:text-orange-600 border border-gray-200 px-3 py-1.5 rounded-lg hover:bg-orange-50">
|
|
||||||
<i class="fas fa-sync-alt mr-1"></i>새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 요약 카드 -->
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-orange-600" id="statTbm">-</div>
|
|
||||||
<div class="stat-label">금일 TBM</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-blue-600" id="statWorkers">-</div>
|
|
||||||
<div class="stat-label">출근 인원</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-red-600" id="statRepairs">-</div>
|
|
||||||
<div class="stat-label">수리 요청</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-purple-600" id="statNotifications"><i class="fas fa-external-link-alt text-base"></i></div>
|
|
||||||
<div class="stat-label">알림 관리</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
||||||
<!-- 금일 TBM 현황 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
|
||||||
<i class="fas fa-clipboard-list text-orange-500 mr-2"></i>금일 TBM
|
|
||||||
</h3>
|
|
||||||
<div id="tbmList" class="space-y-2">
|
|
||||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 알림 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
|
||||||
<i class="fas fa-bell text-orange-500 mr-2"></i>최근 알림
|
|
||||||
</h3>
|
|
||||||
<div id="notificationList" class="space-y-2">
|
|
||||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 미완료 수리 요청 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
|
||||||
<i class="fas fa-tools text-orange-500 mr-2"></i>수리 요청 현황
|
|
||||||
</h3>
|
|
||||||
<div id="repairList" class="space-y-2">
|
|
||||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 빠른 이동 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
|
||||||
<i class="fas fa-rocket text-orange-500 mr-2"></i>빠른 이동
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<a href="/pages/work/tbm.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-clipboard-list text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">TBM 관리</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/work/report-create.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-file-alt text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">작업보고서</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/attendance/checkin.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-user-check text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">출근 체크</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/admin/repair-management.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-tools text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">시설설비 관리</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/attendance/vacation-request.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-paper-plane text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">휴가 신청</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/dashboard.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
<i class="fas fa-map text-orange-500 w-5 text-center"></i>
|
|
||||||
<span class="text-sm text-gray-700">작업장 현황</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
|
||||||
<script src="/static/js/tkfb-dashboard.js?v=2026031701"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>부적합 현황 - TK 공장관리</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50">
|
|
||||||
<header class="bg-orange-700 text-white sticky top-0 z-50">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center h-14">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
|
|
||||||
<i class="fas fa-bars text-xl"></i>
|
|
||||||
</button>
|
|
||||||
<i class="fas fa-industry text-xl text-orange-200"></i>
|
|
||||||
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
|
||||||
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
|
||||||
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
|
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile overlay -->
|
|
||||||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<!-- Sidebar Nav -->
|
|
||||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- 페이지 헤더 -->
|
|
||||||
<div class="mb-5">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">부적합 현황</h2>
|
|
||||||
<p class="text-sm text-gray-500 mt-0.5">자재, 설계, 검사 등 작업 관련 부적합 신고 현황입니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 통계 카드 -->
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5" id="statsGrid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-blue-600" id="statReported">-</div>
|
|
||||||
<div class="stat-label">신고</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-orange-600" id="statReceived">-</div>
|
|
||||||
<div class="stat-label">접수</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-purple-600" id="statProgress">-</div>
|
|
||||||
<div class="stat-label">처리중</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value text-green-600" id="statCompleted">-</div>
|
|
||||||
<div class="stat-label">완료</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 필터 바 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-5 flex flex-wrap items-center gap-3">
|
|
||||||
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
|
|
||||||
<option value="">전체 상태</option>
|
|
||||||
<option value="reported">신고</option>
|
|
||||||
<option value="received">접수</option>
|
|
||||||
<option value="in_progress">처리중</option>
|
|
||||||
<option value="completed">완료</option>
|
|
||||||
<option value="closed">종료</option>
|
|
||||||
</select>
|
|
||||||
<input type="date" id="filterStartDate" class="input-field px-3 py-2 rounded-lg text-sm" title="시작일">
|
|
||||||
<input type="date" id="filterEndDate" class="input-field px-3 py-2 rounded-lg text-sm" title="종료일">
|
|
||||||
<a id="btnNewReport" href="#" class="ml-auto inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-semibold hover:bg-orange-700 transition-colors">
|
|
||||||
<i class="fas fa-plus"></i>부적합 신고
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 신고 목록 -->
|
|
||||||
<div id="issueList" class="space-y-3">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-400 text-sm">로딩 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
|
||||||
<script src="/static/js/tkfb-nonconformity.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user