refactor: 보안 취약점 제거 + 데드코드 정리 + 프론트엔드 중복 통합
- 인증 없는 임시 엔드포인트 삭제 (index.js, healthRoutes.js, publicPaths) - skipAuth 우회 라우트 삭제 (workAnalysis.js) - 하드코딩 유저 백도어 삭제 (routes/auth.js) - 안전체크 CRUD에 admin 권한 추가 (tbmRoutes.js) - deprecated shim 3개 삭제 + 8개 소비 파일 import 정리 (auth.js 직접 참조) - 미사용 pageAccessController, db.js, common/security.js 삭제 - escapeHtml() 5곳 로컬 중복 제거 → api-base.js 전역 사용 - userPageAccess_v2_v2 캐시 키 버그 수정 (app-init.js) - system3 .bak 파일 삭제, PROGRESS.md 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
344
PROGRESS.md
344
PROGRESS.md
@@ -1,6 +1,6 @@
|
||||
# TK 공장관리 3-System 분리 및 통합 - 진행 상황
|
||||
|
||||
> 최종 업데이트: 2026-02-09
|
||||
> 최종 업데이트: 2026-02-25
|
||||
|
||||
---
|
||||
|
||||
@@ -8,190 +8,136 @@
|
||||
|
||||
TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독립 시스템**으로 분리하고 **SSO + 데이터 연동**으로 통합.
|
||||
|
||||
| 시스템 | 설명 | 기반 코드 |
|
||||
| 시스템 | 설명 | 서브도메인 |
|
||||
|--------|------|-----------|
|
||||
| System 1 - 공장관리 | TK-FB에서 신고 기능 제거 | TK-FB-Project |
|
||||
| System 2 - 신고 | TK-FB에서 추출한 독립 서비스 | TK-FB-Project (workIssue 코드) |
|
||||
| System 3 - 부적합관리 (TKQC) | M-Project 기반 | M-Project |
|
||||
| System 1 - 공장관리 | TBM, 출근, 작업보고, 설비, 모바일 대시보드 | `tkfb.technicalkorea.net` |
|
||||
| System 2 - 안전신고 | 5단계 신고 위자드, 작업장 연동 | `tkreport.technicalkorea.net` |
|
||||
| System 3 - 부적합관리 (TKQC) | NCR, 일일공수, 보고서 | `tkqc.technicalkorea.net` |
|
||||
| tkuser - 통합 관리 | 사용자/권한/마스터데이터 관리 | `tkuser.technicalkorea.net` |
|
||||
|
||||
**배포 대상**: Synology DS923+ NAS (192.168.0.3)
|
||||
**배포 환경**: Synology DS923+ NAS (LAN: 192.168.0.3, Tailscale: 100.71.132.52)
|
||||
|
||||
---
|
||||
|
||||
## 현재 NAS 운영 상태
|
||||
## 현재 NAS 운영 상태 (통합 배포 완료)
|
||||
|
||||
### TK-FB (공장관리+신고 - 기존 시스템, 운영 중)
|
||||
| 컨테이너 | 상태 | 포트 |
|
||||
|-----------|------|------|
|
||||
| tkfb_web | Up | - |
|
||||
| tkfb_api | Up (healthy) | - |
|
||||
| tkfb_fastapi | Up | - |
|
||||
| tkfb_db (MariaDB) | Up (healthy) | - |
|
||||
| tkfb_redis | Up | - |
|
||||
| tkfb_phpmyadmin | Up | - |
|
||||
| tkfb_cloudflared | Up | - |
|
||||
- 도메인: `tkfb.technicalkorea.net` (Cloudflare Tunnel)
|
||||
- 위치: `/volume1/Technicalkorea Document/tkfb-package/`
|
||||
**배포일**: 2026-02-11 | **위치**: `/volume1/docker_1/tk-factory-services`
|
||||
|
||||
### TKQC (부적합관리 - 신규 배포 완료)
|
||||
| 컨테이너 | 상태 | 포트 |
|
||||
|-----------|------|------|
|
||||
| tkqc-db (PostgreSQL 15) | Up (healthy) | 16432 |
|
||||
| tkqc-backend (FastAPI) | Up | 16000 |
|
||||
| tkqc-nginx | Up | 16080 |
|
||||
- 도메인: `tkqc.technicalkorea.net` (Cloudflare Tunnel)
|
||||
- 위치: `/volume1/docker/tkqc/tkqc-package/`
|
||||
- cloudflared가 tkfb_network + tkqc-network 양쪽 연결됨
|
||||
| 컨테이너 | 이미지 | 역할 |
|
||||
|-----------|--------|------|
|
||||
| tk-gateway | nginx:alpine | 서브도메인 라우팅 + 포털/로그인 |
|
||||
| tk-sso-auth | node:18-alpine | SSO 인증 (JWT + 쿠키) |
|
||||
| tk-tkuser-api | node:18-alpine | 마스터데이터 API |
|
||||
| tk-tkuser-web | nginx:alpine | tkuser 웹 |
|
||||
| tk-system1-api | node:18-alpine | 공장관리 API |
|
||||
| tk-system1-web | nginx:alpine | 공장관리 웹 |
|
||||
| tk-system1-fastapi | python:3.9-slim | FastAPI 캐싱 브릿지 |
|
||||
| tk-system2-api | node:18-alpine | 안전신고 API |
|
||||
| tk-system2-web | nginx:alpine | 안전신고 웹 |
|
||||
| tk-system3-api | python:3.9-slim | 부적합관리 API (FastAPI) |
|
||||
| tk-system3-web | nginx:alpine | 부적합관리 웹 |
|
||||
| tk-mariadb | mariadb:10.9 | MariaDB (System 1, 2, tkuser 공유) |
|
||||
| tk-postgres | postgres:15-alpine | PostgreSQL (System 3 전용) |
|
||||
| tk-redis | redis:6-alpine | 캐시 |
|
||||
| tk-phpmyadmin | phpmyadmin | DB 관리 (LAN only, 30880) |
|
||||
| tk-cloudflared | cloudflare/cloudflared | Cloudflare Tunnel |
|
||||
|
||||
---
|
||||
|
||||
## 단계별 진행 상황
|
||||
|
||||
### Phase 0: M-Project(TKQC) NAS 배포 ✅ 완료
|
||||
### Phase 0: TKQC NAS 배포 ✅ 완료
|
||||
|
||||
- [x] M-Project 배포 패키지 생성 (deploy/package.sh)
|
||||
- [x] NAS로 패키지 전송 및 설치
|
||||
- [x] Docker 컨테이너 빌드 및 실행 (tkqc-db, tkqc-backend, tkqc-nginx)
|
||||
- [x] PostgreSQL 마이그레이션 실행 (001~020 SQL)
|
||||
- [x] nginx.conf 수정 (Docker 내부 IP 허용, uploads 프록시)
|
||||
- [x] Cloudflare Tunnel에 `tkqc.technicalkorea.net` 추가
|
||||
- [x] cloudflared를 tkqc-network에 연결
|
||||
- [x] 컨테이너 이름 m-project → tkqc로 리네임
|
||||
- [x] 헬스체크 확인 (API: healthy, Web: 200)
|
||||
- [ ] **Cloudflare 대시보드에서 서비스 URL 변경 필요**
|
||||
- 변경: `http://m-project-nginx:80` → `http://tkqc-nginx:80`
|
||||
- (사용자가 직접 Cloudflare 대시보드에서 수정)
|
||||
- [x] M-Project 배포 패키지 → NAS 설치 → Docker 기동
|
||||
- [x] PostgreSQL 마이그레이션 (001~020)
|
||||
- [x] Cloudflare Tunnel `tkqc.technicalkorea.net` 설정
|
||||
- [x] 컨테이너 리네임 (m-project → tkqc)
|
||||
|
||||
### Phase 1: 준비 - 로컬 코드 구조 생성 ✅ 완료
|
||||
### Phase 1~4: 코드 분리 + SSO + Gateway ✅ 완료
|
||||
|
||||
- [x] `tk-factory-services/` 디렉토리 구조 생성
|
||||
- [x] 통합 `docker-compose.yml` 작성 (13개 서비스, 포트 30000~30880)
|
||||
- [x] `.env.example` 작성
|
||||
- [x] 3개 시스템 코드 분리 (TK-FB → System 1+2, M-Project → System 3)
|
||||
- [x] SSO Auth 서비스 (bcrypt + pbkdf2 이중 해시, JWT 7일/30일)
|
||||
- [x] 프론트엔드 SSO 쿠키 통합 (52+ 파일, `domain=.technicalkorea.net`)
|
||||
- [x] Gateway 서브도메인 라우팅 (path-based에서 전환)
|
||||
- [x] tkuser 통합 관리 서비스 (사용자, 권한, 프로젝트, 작업장, 설비, 부서, 작업자)
|
||||
|
||||
### Phase 2: SSO 인증 서비스 - 로컬 코드 작성 ✅ 완료
|
||||
### Phase 5: 마이그레이션 스크립트 ✅ 완료
|
||||
|
||||
- [x] `sso-auth-service/` 코드 작성
|
||||
- `index.js` - Express 서버
|
||||
- `routes/authRoutes.js` - 인증 라우트
|
||||
- `controllers/authController.js` - 로그인/검증/리프레시/유저 CRUD
|
||||
- `models/userModel.js` - MariaDB 유저 모델
|
||||
- `Dockerfile`, `package.json`
|
||||
- [x] bcrypt + pbkdf2_sha256 이중 비밀번호 해시 지원
|
||||
- [x] JWT 기반 인증 (SSO_JWT_SECRET 공유)
|
||||
- [ ] **실제 테스트 미완료** (NAS 배포 전)
|
||||
- [ ] **sso_users 테이블 생성 및 기존 유저 마이그레이션 미완료**
|
||||
- [x] `scripts/migrate-users.sql`, `backup.sh`, `deploy.sh`, `health-check.sh`
|
||||
|
||||
### Phase 3: 시스템별 코드 분리 - 로컬 코드 작성 ✅ 완료
|
||||
### Phase 6: NAS 통합 배포 ✅ 완료 (2026-02-11)
|
||||
|
||||
#### System 1 - 공장관리
|
||||
- [x] TK-FB-Project에서 복사 (api, web, fastapi-bridge)
|
||||
- [x] `config/routes.js`에서 workIssue 라우트 제거
|
||||
- [x] auth 미들웨어 SSO JWT 시크릿 연동 (백엔드: JWT_SECRET=SSO_JWT_SECRET 이미 설정)
|
||||
- [x] 프론트엔드 토큰 키 통일 (`token`/`user` → `sso_token`/`sso_user`, 52개 파일 변경)
|
||||
- [x] SSO 쿠키 기반 인증 통합 (api-base.js, app-init.js, auth-check.js, auth.js)
|
||||
- `window.getSSOToken()`, `window.getSSOUser()`, `window.getLoginUrl()` 글로벌 함수
|
||||
- 쿠키 `sso_token` 우선 → localStorage `sso_token` 폴백
|
||||
- [x] nginx.conf 생성 (API, uploads, FastAPI 프록시)
|
||||
- [x] 신고 관련 프론트엔드 페이지 → System 2로 서브도메인 리다이렉트
|
||||
- `issue-report.html`, `issue-detail.html`, `report-status.html` → 동적 서브도메인 리다이렉트
|
||||
- `sidebar-nav.html` 크로스시스템 링크 → `data-system` 속성 + JS 동적 해석
|
||||
- [x] 로그인 플로우: 인증 없으면 중앙 로그인 (`tkfb.technicalkorea.net/login`)으로 리다이렉트
|
||||
- [ ] **실제 테스트 미완료**
|
||||
- [x] 전체 백업 (MariaDB, PostgreSQL, uploads)
|
||||
- [x] tk-factory-services NAS 전송 + .env 설정
|
||||
- [x] 기존 TK-FB/TKQC 중지 → 통합 docker-compose up (16 컨테이너)
|
||||
- [x] SSO 유저 마이그레이션
|
||||
- [x] Cloudflare Tunnel 서브도메인 4개 설정
|
||||
|
||||
#### System 2 - 신고
|
||||
- [x] TK-FB에서 workIssue 관련 코드 추출
|
||||
- `workIssueModel.js`, `workIssueController.js`, `workIssueRoutes.js`
|
||||
- `mProjectService.js` (baseUrl → system3-api:8000으로 변경)
|
||||
- `imageUploadService.js`, `dateUtils.js`, `logger.js`, `errors.js`
|
||||
- [x] 독립 Express 서버 구성 (index.js, package.json, Dockerfile)
|
||||
- [x] 프론트엔드 페이지 복사 (issue-report, issue-detail, report-status)
|
||||
- [x] auth 미들웨어 SSO JWT 시크릿 연동 (백엔드: JWT_SECRET=SSO_JWT_SECRET 이미 설정)
|
||||
- [x] 프론트엔드 인프라 구축: `api-base.js`, `app-init.js` 신규 생성
|
||||
- [x] SSO 쿠키 기반 인증 통합 (쿠키 우선, localStorage 폴백, 중앙 로그인 리다이렉트)
|
||||
- [x] 인라인 JS 토큰 읽기 → `window.getSSOToken()` 폴백 패턴 적용 (28곳)
|
||||
- [ ] **M-Project(TKQC) 연동 실제 테스트 미완료**
|
||||
- [ ] **실제 테스트 미완료**
|
||||
### Phase 7: 테스트 및 Go-Live ✅ 완료 (2026-02-12)
|
||||
|
||||
#### System 3 - 부적합관리 (TKQC)
|
||||
- [x] M-Project에서 복사 (api, web)
|
||||
- [x] `auth_service.py` → bcrypt + pbkdf2 이중 지원 코드 추가
|
||||
- [x] `requirements.txt`에 bcrypt 추가
|
||||
- [x] SSO JWT 연동: `verify_token()` 전체 payload 반환, DB에 없는 SSO 유저 임시 객체 생성
|
||||
- [x] SSO 쿠키 기반 인증 통합 (api.js, auth-manager.js, app.js, common-header.js)
|
||||
- `_cookieGet('sso_token')` 우선 → localStorage 폴백
|
||||
- 인증 실패 시 중앙 로그인 리다이렉트
|
||||
- [x] 인라인 JS `localStorage.getItem('access_token')` → `TokenManager.getToken()` 일괄 치환 (9개 HTML, 81곳)
|
||||
- [ ] **SSO JWT 시크릿 연동 실제 테스트 미완료**
|
||||
|
||||
### Phase 4: Gateway + 서브도메인 라우팅 ✅ 코드 완료
|
||||
|
||||
> **아키텍처 변경**: Path-based(`/factory/`, `/report/`, `/nc/`) → **서브도메인 기반** 라우팅으로 전환.
|
||||
>
|
||||
> **이유**: 프론트엔드 JS가 root-relative URL (`/api/...`, `/pages/...`)을 사용하므로 경로 기반 라우팅이 근본적으로 불가능.
|
||||
>
|
||||
> **해결**: 각 시스템에 독립 서브도메인을 부여하고, SSO 쿠키(`domain=.technicalkorea.net`)로 인증 공유.
|
||||
|
||||
- [x] `gateway/nginx.conf` - **서브도메인 기반 라우팅**
|
||||
- `tkfb.technicalkorea.net` → Gateway (포털/로그인) + System 1 프록시
|
||||
- `tkreport.technicalkorea.net` → System 2 (Cloudflare Tunnel 직접)
|
||||
- `tkqc.technicalkorea.net` → System 3 (Cloudflare Tunnel 직접)
|
||||
- [x] `gateway/html/portal.html` - 서브도메인 기반 시스템 카드 링크
|
||||
- [x] `gateway/html/login.html` - SSO 쿠키 설정 (`domain=.technicalkorea.net`, 7일 만료)
|
||||
- 로그인 성공 → 쿠키 + localStorage 이중 저장, `?redirect=` 파라미터 지원
|
||||
- [x] `gateway/html/shared/nav-header.js` - SSOAuth 글로벌 객체 (쿠키 우선)
|
||||
- [x] `gateway/Dockerfile`
|
||||
- [x] `system1-factory/web/nginx.conf` 신규 생성 (API/uploads/FastAPI 프록시)
|
||||
- [x] `docker-compose.yml`에 cloudflared 서비스 추가
|
||||
- [ ] **Cloudflare Tunnel 서브도메인 설정** (사용자 대시보드 작업)
|
||||
- [ ] **실제 배포 및 테스트 미완료**
|
||||
|
||||
### Phase 5: 마이그레이션 스크립트 - 로컬 코드 작성 ✅ 완료
|
||||
|
||||
- [x] `scripts/migrate-users.sql` - SSO 통합 유저 테이블 생성 + 데이터 이전
|
||||
- [x] `scripts/backup.sh` - 전체 백업 스크립트
|
||||
- [x] `scripts/deploy.sh` - 배포 스크립트
|
||||
- [x] `scripts/health-check.sh` - 헬스체크 스크립트
|
||||
- [ ] **실제 실행 미완료**
|
||||
|
||||
### Phase 6: NAS 통합 배포 ❌ 미착수
|
||||
|
||||
- [ ] 전체 백업 (MariaDB, PostgreSQL, uploads, git)
|
||||
- [ ] tk-factory-services를 NAS로 전송
|
||||
- [ ] .env 파일 생성 (NAS 기존 DB 패스워드 등 수집)
|
||||
- [ ] 기존 TK-FB 중지 + TKQC 중지
|
||||
- [ ] 통합 docker-compose.yml로 전체 서비스 기동
|
||||
- [ ] SSO 유저 마이그레이션 실행
|
||||
- [ ] 전체 서비스 헬스체크
|
||||
- [ ] Cloudflare Tunnel 서브도메인 설정 업데이트:
|
||||
- `tkfb.technicalkorea.net` → `http://tk-gateway:80`
|
||||
- `tkreport.technicalkorea.net` → `http://tk-system2-web:80`
|
||||
- `tkqc.technicalkorea.net` → `http://tk-system3-web:80`
|
||||
|
||||
### Phase 7: 테스트 및 Go-Live ❌ 미착수
|
||||
|
||||
- [ ] SSO 로그인 → 3개 시스템 간 이동 테스트 (재로그인 없이)
|
||||
- [ ] 신고 생성 → 부적합 자동 생성 연동 테스트
|
||||
- [ ] 사진 업로드 테스트
|
||||
- [ ] 알림 기능 테스트
|
||||
- [ ] 기존 데이터 정합성 확인
|
||||
- [ ] 프로덕션 Go-Live
|
||||
- [x] SSO 로그인 → 3개 시스템 간 이동 (재로그인 없음)
|
||||
- [x] 신고 생성 → 부적합 자동 전달 (tkreport → tkqc)
|
||||
- [x] 사진 업로드, 알림, 데이터 정합성 확인
|
||||
- [x] 프로덕션 Go-Live
|
||||
|
||||
---
|
||||
|
||||
## 완료율 요약
|
||||
## 배포 이후 개발 (Phase 8+)
|
||||
|
||||
| 단계 | 상태 | 완료도 |
|
||||
|------|------|--------|
|
||||
| Phase 0: TKQC NAS 배포 | ✅ 거의 완료 | 95% (CF 대시보드 URL 변경만 남음) |
|
||||
| Phase 1: 디렉토리 구조 | ✅ 완료 | 100% |
|
||||
| Phase 2: SSO 코드 작성 | ✅ 코드 완료 | 70% (코드만 완료, 테스트/배포 미완) |
|
||||
| Phase 3: 시스템 분리 + SSO 쿠키 통합 | ✅ 코드 완료 | 80% (코드+프론트엔드 SSO 완료, 실제 테스트 미완) |
|
||||
| Phase 4: Gateway + 서브도메인 라우팅 | ✅ 코드 완료 | 80% (코드 완료, CF Tunnel 설정/테스트 미완) |
|
||||
| Phase 5: 스크립트 | ✅ 코드 완료 | 50% (코드만 완료, 실행 미완) |
|
||||
| Phase 6: NAS 통합 배포 | ❌ 미착수 | 0% |
|
||||
| Phase 7: 테스트/Go-Live | ❌ 미착수 | 0% |
|
||||
### 2026-02-13: System 2/3 업데이트 + 문서 재구성 ✅
|
||||
|
||||
**전체 진행률: 약 55%** (코드 작성 + SSO 프론트엔드 통합 완료, NAS 배포/테스트 남음)
|
||||
- [x] System 2, 3 웹/API 업데이트 및 재빌드
|
||||
- [x] 문서 4개 MD → `docs/` 폴더 7개 파일로 재구성
|
||||
|
||||
### 2026-02-14: 모바일 UX + PWA ✅
|
||||
|
||||
- [x] 모바일 대시보드 v1 (카테고리 탭 → 작업장 리스트 → 요약 숫자)
|
||||
- [x] PWA 구현 (manifest, service worker)
|
||||
- [x] 모바일 CSS 전면 최적화 (터치 타겟, 레이아웃)
|
||||
|
||||
### 2026-02-24~25: TBM 시스템 전면 개편 ✅
|
||||
|
||||
- [x] 4단계 워크플로우: draft → detail_edit → completed → work_report
|
||||
- [x] 모바일 전용 TBM 페이지 (`tbm-mobile.html`) + 3단계 생성 위자드
|
||||
- [x] 작업자 작업 분할 (`work_hours` + `split_seq`)
|
||||
- [x] 작업자 이동 보내기/빼오기 (`tbm_transfers` 테이블)
|
||||
- [x] 중복 배정 방지 (당일 배정 현황 조회)
|
||||
- [x] 모바일 작업보고서 페이지 (`report-create-mobile.html`)
|
||||
- [x] DB 마이그레이션 4개 (`attendance_type`, `work_hours`, `tbm_transfers`, `split_seq`)
|
||||
|
||||
### 2026-02-25: 권한 시스템 통합 ✅
|
||||
|
||||
- [x] tkuser `user_page_permissions` → system1 페이지 접근 연동
|
||||
- [x] 라우트 우선순위 수정 (`pageAccessRoutes` > `userRoutes`)
|
||||
- [x] 3단계 폴백: 개인 권한 → 부서 기본 → `TKUSER_DEFAULT_ACCESS`
|
||||
- [x] 40+ 페이지 키 매핑 (`PAGEKEY_TO_TKUSER`)
|
||||
|
||||
### 2026-02-25: 모바일 대시보드 v2 — 작업장 카드 확장 뷰 ✅
|
||||
|
||||
- [x] 카드 탭 → 아코디언 확장 (TBM 작업, 방문, 신고, 이동설비 상세)
|
||||
- [x] 캐시 데이터 활용 (추가 API 호출 없음)
|
||||
- [x] NAS 배포 완료
|
||||
|
||||
---
|
||||
|
||||
## 포트 매핑
|
||||
|
||||
| 포트 | 서비스 |
|
||||
|------|--------|
|
||||
| 30000 | Gateway (nginx) |
|
||||
| 30005 | System 1 API (Express) |
|
||||
| 30008 | System 1 FastAPI Bridge |
|
||||
| 30050 | SSO Auth (Express) |
|
||||
| 30080 | System 1 Web (nginx) |
|
||||
| 30105 | System 2 API (Express) |
|
||||
| 30180 | System 2 Web (nginx) |
|
||||
| 30200 | System 3 API (FastAPI) |
|
||||
| 30280 | System 3 Web (nginx) |
|
||||
| 30300 | tkuser API (Express) |
|
||||
| 30380 | tkuser Web (nginx) |
|
||||
| 30306 | MariaDB |
|
||||
| 30432 | PostgreSQL |
|
||||
| 30880 | phpMyAdmin |
|
||||
|
||||
---
|
||||
|
||||
@@ -199,76 +145,14 @@ TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독
|
||||
|
||||
### 로컬 (Mac)
|
||||
- 통합 프로젝트: `/Users/hyungiahn/Documents/code/tk-factory-services/`
|
||||
- TK-FB 원본: `/Users/hyungiahn/Documents/code/TK-FB-Project/`
|
||||
- M-Project 원본: `/Users/hyungiahn/Documents/code/M-Project/`
|
||||
- M-Project 배포 도구: `/Users/hyungiahn/Documents/code/M-Project/deploy/`
|
||||
- 가이드 문서: `~/Library/CloudStorage/SynologyDrive-Technicalkorea/tkfb-package/docs/`
|
||||
- Git: `https://git.hyungi.net/hyungi/tk-factory-services.git`
|
||||
|
||||
### NAS (192.168.0.3)
|
||||
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
|
||||
- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
|
||||
- SSH: `hyungi` / `fukdon-riwbaq-fiQfy2`
|
||||
### NAS (Synology DS923+)
|
||||
- 통합 배포: `/volume1/docker_1/tk-factory-services`
|
||||
- SSH: `hyungi@100.71.132.52` (Tailscale)
|
||||
- Docker: `echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker`
|
||||
|
||||
---
|
||||
|
||||
## 포트 계획 (통합 배포 시)
|
||||
|
||||
| 현재 포트 | 통합 후 포트 | 서비스 |
|
||||
|-----------|-------------|--------|
|
||||
| - | 30000 | Gateway Nginx (통합 포털) |
|
||||
| tkfb API | 30005 | System 1 API |
|
||||
| tkfb FastAPI | 30008 | System 1 FastAPI Bridge |
|
||||
| - | 30050 | SSO Auth |
|
||||
| tkfb Web | 30080 | System 1 Web |
|
||||
| - | 30105 | System 2 API |
|
||||
| - | 30180 | System 2 Web |
|
||||
| 16000 → 30200 | 30200 | System 3 API |
|
||||
| 16080 → 30280 | 30280 | System 3 Web |
|
||||
| tkfb MariaDB | 30306 | MariaDB (공유) |
|
||||
| 16432 → 30432 | 30432 | PostgreSQL |
|
||||
| tkfb phpMyAdmin | 30880 | phpMyAdmin |
|
||||
|
||||
---
|
||||
|
||||
## 다음에 할 일 (우선순위)
|
||||
|
||||
1. **NAS 접속 확인** (현재 LAN/Tailscale 모두 타임아웃)
|
||||
2. **.env 파일 생성** - NAS에서 기존 DB 패스워드 등 수집
|
||||
3. **Phase 6: NAS 통합 배포**
|
||||
- 전체 백업 → 프로젝트 전송 → docker-compose up
|
||||
4. **Cloudflare Tunnel 서브도메인 설정** (사용자 대시보드 작업)
|
||||
- `tkfb.technicalkorea.net` → `http://tk-gateway:80`
|
||||
- `tkreport.technicalkorea.net` → `http://tk-system2-web:80`
|
||||
- `tkqc.technicalkorea.net` → `http://tk-system3-web:80`
|
||||
5. **Phase 7**: 전체 SSO 로그인/시스템 간 이동 테스트
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 변경 이력
|
||||
|
||||
### 2026-02-09: Path-based → 서브도메인 기반 라우팅
|
||||
|
||||
**문제**: Gateway에서 `/factory/`, `/report/`, `/nc/` 경로 기반 라우팅 시, 프론트엔드 JS가 root-relative URL (`fetch('/api/...')`, `<a href="/pages/...">`)을 사용하여 경로 프리픽스 없이 요청 → 라우팅 불가.
|
||||
|
||||
**해결**: 서브도메인 기반 라우팅 + 쿠키 기반 SSO
|
||||
|
||||
| 서브도메인 | Cloudflare Tunnel 대상 | 시스템 |
|
||||
|-----------|----------------------|--------|
|
||||
| `tkfb.technicalkorea.net` | `http://tk-gateway:80` | 포털 + System 1 (프록시) |
|
||||
| `tkreport.technicalkorea.net` | `http://tk-system2-web:80` | System 2 |
|
||||
| `tkqc.technicalkorea.net` | `http://tk-system3-web:80` | System 3 |
|
||||
|
||||
**SSO 쿠키 전략**: `sso_token`, `sso_user` 쿠키를 `domain=.technicalkorea.net`으로 설정하여 서브도메인 간 공유. localStorage는 폴백용으로 유지 (개발 환경 및 하위호환).
|
||||
|
||||
---
|
||||
|
||||
## 해결했던 이슈 (참고)
|
||||
|
||||
| 이슈 | 해결 방법 |
|
||||
|------|-----------|
|
||||
| nginx uploads 볼륨 read-only 에러 | nginx에서 uploads 볼륨 제거, backend 프록시로 변경 |
|
||||
| `users.department` 컬럼 누락 | 마이그레이션 수동 실행 (ALTER TABLE) |
|
||||
| nginx 403 Forbidden (Docker IP 차단) | `allow 172.16.0.0/12`, `allow 10.0.0.0/8` 추가 |
|
||||
| SCP subsystem 실패 | `scp -O` (레거시 프로토콜) 사용 |
|
||||
| sudo 패스워드 파이프 실패 (for 루프) | SQL 파일 합쳐서 한번에 실행 |
|
||||
| 네트워크 충돌 (리네임 시) | cloudflared 연결 해제 후 네트워크 삭제 |
|
||||
| 새 볼륨 비어있음 (리네임 후) | 마이그레이션 재실행 |
|
||||
### 레거시 (참고, 중지됨)
|
||||
- TK-FB 원본: `/volume1/Technicalkorea Document/tkfb-package/`
|
||||
- TKQC 원본: `/volume1/docker/tkqc/tkqc-package/`
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerSpec = require('./swagger');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
const { activityLogger } = require('../middlewares/activityLogger');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -107,10 +107,7 @@ function setupRoutes(app) {
|
||||
'/api/setup/migrate-existing-data',
|
||||
'/api/setup/check-data-status',
|
||||
'/api/monthly-status/calendar',
|
||||
'/api/monthly-status/daily-details',
|
||||
'/api/migrate-work-type-id', // 임시 마이그레이션 - 실행 후 삭제!
|
||||
'/api/diagnose-work-type-id', // 임시 진단 - 실행 후 삭제!
|
||||
'/api/test-analysis' // 임시 분석 테스트 - 실행 후 삭제!
|
||||
'/api/monthly-status/daily-details'
|
||||
];
|
||||
|
||||
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
// controllers/pageAccessController.js
|
||||
const PageAccessModel = require('../models/pageAccessModel');
|
||||
|
||||
const PageAccessController = {
|
||||
// 사용자의 페이지 권한 조회
|
||||
getUserPageAccess: (req, res) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 사용자 ID입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
PageAccessModel.getUserPageAccess(userId, (err, results) => {
|
||||
if (err) {
|
||||
console.error('페이지 권한 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 권한 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 모든 페이지 목록 조회
|
||||
getAllPages: (req, res) => {
|
||||
PageAccessModel.getAllPages((err, results) => {
|
||||
if (err) {
|
||||
console.error('페이지 목록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 목록 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 페이지 권한 부여
|
||||
grantPageAccess: (req, res) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { pageId } = req.body;
|
||||
const grantedBy = req.user.user_id;
|
||||
|
||||
if (isNaN(userId) || !pageId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 파라미터가 누락되었습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
PageAccessModel.grantPageAccess(userId, pageId, grantedBy, (err, result) => {
|
||||
if (err) {
|
||||
console.error('페이지 권한 부여 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 권한 부여 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '페이지 권한이 부여되었습니다.',
|
||||
data: result
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 페이지 권한 회수
|
||||
revokePageAccess: (req, res) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const pageId = parseInt(req.params.pageId);
|
||||
|
||||
if (isNaN(userId) || isNaN(pageId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
PageAccessModel.revokePageAccess(userId, pageId, (err, result) => {
|
||||
if (err) {
|
||||
console.error('페이지 권한 회수 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 권한 회수 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '페이지 권한이 회수되었습니다.',
|
||||
data: result
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 사용자 페이지 권한 일괄 설정
|
||||
setUserPageAccess: (req, res) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { pageIds } = req.body;
|
||||
const grantedBy = req.user.user_id;
|
||||
|
||||
if (isNaN(userId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 사용자 ID입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(pageIds)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'pageIds는 배열이어야 합니다.'
|
||||
});
|
||||
}
|
||||
|
||||
PageAccessModel.setUserPageAccess(userId, pageIds, grantedBy, (err, result) => {
|
||||
if (err) {
|
||||
console.error('페이지 권한 설정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 권한 설정 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '페이지 권한이 설정되었습니다.',
|
||||
data: result
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 특정 페이지 접근 권한 확인
|
||||
checkPageAccess: (req, res) => {
|
||||
const userId = req.user.user_id;
|
||||
const { pageKey } = req.params;
|
||||
|
||||
if (!pageKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '페이지 키가 필요합니다.'
|
||||
});
|
||||
}
|
||||
|
||||
PageAccessModel.checkPageAccess(userId, pageKey, (err, result) => {
|
||||
if (err) {
|
||||
console.error('페이지 접근 권한 확인 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '페이지 접근 권한 확인 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 계정이 있는 사용자 목록 조회 (권한 관리용)
|
||||
getUsersWithAccounts: (req, res) => {
|
||||
PageAccessModel.getUsersWithAccounts((err, results) => {
|
||||
if (err) {
|
||||
console.error('사용자 목록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '사용자 목록 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PageAccessController;
|
||||
@@ -1,35 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
const retry = require('async-retry');
|
||||
|
||||
// 초기화된 pool을 export 하기 위한 변수
|
||||
let pool = null;
|
||||
|
||||
const initPool = async () => {
|
||||
if (pool) return pool; // 이미 초기화된 경우 재사용
|
||||
|
||||
await retry(async () => {
|
||||
pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
await conn.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
console.log(`✅ MariaDB 연결 성공: ${process.env.DB_HOST}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME}`);
|
||||
conn.release();
|
||||
}, {
|
||||
retries: 10,
|
||||
minTimeout: 3000
|
||||
});
|
||||
|
||||
return pool;
|
||||
};
|
||||
|
||||
module.exports = initPool;
|
||||
@@ -22,222 +22,6 @@ const PORT = process.env.PORT || 20005;
|
||||
// Trust proxy for accurate IP addresses
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// JSON body parser 미리 적용 (마이그레이션용)
|
||||
app.use(express.json());
|
||||
|
||||
// 임시 분석 테스트 엔드포인트 - 실행 후 삭제!
|
||||
app.get('/api/test-analysis', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('./dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
// 수정된 COALESCE 로직 테스트 (tasks 우선)
|
||||
const [results] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
w.worker_name,
|
||||
dwr.report_date,
|
||||
dwr.work_type_id as original_work_type_id,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
|
||||
wt.id
|
||||
) as resolved_work_type_id,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
|
||||
wt.name
|
||||
) as work_type_name,
|
||||
t.task_name,
|
||||
wt.name as direct_match_work_type,
|
||||
wt2.name as task_work_type
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
||||
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
||||
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
|
||||
ORDER BY dwr.report_date DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'tasks 테이블 우선 조회 결과',
|
||||
data: results.map(r => ({
|
||||
id: r.id,
|
||||
worker: r.worker_name,
|
||||
date: r.report_date,
|
||||
original_id: r.original_work_type_id,
|
||||
resolved_work_type: r.work_type_name,
|
||||
task: r.task_name,
|
||||
note: `원래 ID ${r.original_work_type_id} → ${r.work_type_name}`
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('테스트 실패:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 임시 진단 엔드포인트 - 실행 후 삭제!
|
||||
app.get('/api/diagnose-work-type-id', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('./dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
// 1. 전체 작업보고서 현황
|
||||
const [totalStats] = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_reports,
|
||||
COUNT(tbm_assignment_id) as tbm_reports,
|
||||
COUNT(CASE WHEN tbm_assignment_id IS NULL THEN 1 END) as non_tbm_reports
|
||||
FROM daily_work_reports
|
||||
`);
|
||||
|
||||
// 2. work_type_id 값 분포 (상위 20개)
|
||||
const [workTypeDistribution] = await db.query(`
|
||||
SELECT
|
||||
dwr.work_type_id,
|
||||
COUNT(*) as count,
|
||||
wt.name as if_work_type,
|
||||
t.task_name as if_task,
|
||||
wt2.name as task_work_type
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
||||
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
||||
GROUP BY dwr.work_type_id
|
||||
ORDER BY count DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// 3. 특정 작업자 데이터 확인 (조승민, 최광욱)
|
||||
const [workerSamples] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
w.worker_name,
|
||||
dwr.work_type_id,
|
||||
dwr.tbm_assignment_id,
|
||||
wt.name as direct_work_type,
|
||||
t.task_name,
|
||||
wt2.name as task_work_type,
|
||||
dwr.report_date
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
||||
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
||||
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
|
||||
ORDER BY dwr.report_date DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_stats: totalStats[0],
|
||||
work_type_distribution: workTypeDistribution,
|
||||
worker_samples: workerSamples
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('진단 실패:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 임시 마이그레이션 엔드포인트 (인증 없이 실행) - 실행 후 삭제!
|
||||
app.post('/api/migrate-work-type-id', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('./dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
|
||||
|
||||
// 1. 수정 대상 확인
|
||||
const [checkResult] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.work_type_id as current_work_type_id,
|
||||
ta.task_id as correct_task_id,
|
||||
w.worker_name,
|
||||
dwr.report_date
|
||||
FROM daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
INNER JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND dwr.work_type_id != ta.task_id
|
||||
ORDER BY dwr.report_date DESC
|
||||
`);
|
||||
|
||||
console.log('📊 수정 대상:', checkResult.length, '개 레코드');
|
||||
|
||||
if (checkResult.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '수정할 데이터가 없습니다.',
|
||||
data: { affected_rows: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 업데이트 실행
|
||||
const [updateResult] = await db.query(`
|
||||
UPDATE daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
SET dwr.work_type_id = ta.task_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND dwr.work_type_id != ta.task_id
|
||||
`);
|
||||
|
||||
console.log('✅ 업데이트 완료:', updateResult.affectedRows, '개 레코드 수정됨');
|
||||
|
||||
// 3. 수정 후 확인
|
||||
const [samples] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.work_type_id,
|
||||
t.task_name,
|
||||
wt.name as work_type_name,
|
||||
w.worker_name,
|
||||
dwr.report_date
|
||||
FROM daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
ORDER BY dwr.report_date DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: updateResult.affectedRows + '개 레코드가 수정되었습니다.',
|
||||
data: {
|
||||
affected_rows: updateResult.affectedRows,
|
||||
before_count: checkResult.length,
|
||||
samples: samples.map(s => ({
|
||||
id: s.id,
|
||||
worker: s.worker_name,
|
||||
date: s.report_date,
|
||||
task: s.task_name,
|
||||
work_type: s.work_type_name
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '마이그레이션 실패: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 미들웨어 설정
|
||||
setupMiddlewares(app);
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// utils/access.js
|
||||
exports.requireAccess = (...allowed) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !allowed.includes(req.user.access_level)) {
|
||||
return res.status(403).json({ error: '접근 권한이 없습니다' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 '../middlewares/auth'의 requireMinLevel을 사용하세요.
|
||||
*
|
||||
* @example
|
||||
* // 이전 방식 (deprecated)
|
||||
* const { requireAccess, ACCESS_LEVELS } = require('../middlewares/accessMiddleware');
|
||||
* router.get('/admin', requireAccess('admin'), handler);
|
||||
*
|
||||
* // 새로운 방식 (권장)
|
||||
* const { requireMinLevel, ACCESS_LEVELS } = require('../middlewares/auth');
|
||||
* router.get('/admin', requireAuth, requireMinLevel('admin'), handler);
|
||||
*/
|
||||
|
||||
const { requireMinLevel, ACCESS_LEVELS } = require('./auth');
|
||||
|
||||
/**
|
||||
* @deprecated requireMinLevel을 사용하세요
|
||||
*/
|
||||
const requireAccess = (requiredLevel) => {
|
||||
return requireMinLevel(requiredLevel);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
requireAccess,
|
||||
ACCESS_LEVELS,
|
||||
// 새로운 API
|
||||
requireMinLevel
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 './auth'를 직접 import하세요.
|
||||
*
|
||||
* @example
|
||||
* // 이전 방식 (deprecated)
|
||||
* const { verifyToken, requireAdmin } = require('../middlewares/authMiddleware');
|
||||
*
|
||||
* // 새로운 방식 (권장)
|
||||
* const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
*/
|
||||
|
||||
const {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
ACCESS_LEVELS
|
||||
} = require('./auth');
|
||||
|
||||
module.exports = {
|
||||
// 레거시 별칭 (하위 호환성)
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
|
||||
// 새로운 API (권장)
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
ACCESS_LEVELS
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAnalysisData } = require('../controllers/analysisController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware'); // 인증 미들웨어 추가
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
|
||||
// GET /api/analysis?startDate=...&endDate=...
|
||||
router.get('/', verifyToken, getAnalysisData);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AttendanceController = require('../controllers/attendanceController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(verifyToken);
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
// routes/auth.js
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// 임시 사용자 데이터 (실제 운영 시 DB 사용 필수)
|
||||
// 비밀번호 해시는 bcrypt.hash('password', 10)으로 생성됨
|
||||
let users = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: 'admin',
|
||||
password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
|
||||
name: '관리자',
|
||||
access_level: 'admin',
|
||||
worker_id: null,
|
||||
created_at: new Date()
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: 'group_leader1',
|
||||
password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
|
||||
name: '김그룹장',
|
||||
access_level: 'group_leader',
|
||||
worker_id: 1,
|
||||
created_at: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
// 보안 경고: 운영 환경에서는 반드시 .env의 JWT_SECRET을 설정해야 합니다
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.warn('⚠️ WARNING: JWT_SECRET이 설정되지 않았습니다. 운영 환경에서는 반드시 설정하세요!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '사용자명과 비밀번호를 입력해주세요.' });
|
||||
}
|
||||
|
||||
const user = users.find(u => u.username === username);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 비밀번호 확인 (bcrypt.compare 사용)
|
||||
const isValid = await bcrypt.compare(password, user.password);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: '비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자 등록 (관리자만)
|
||||
*/
|
||||
router.post('/register', requireAuth, requireRole('admin', 'system'), async (req, res) => {
|
||||
try {
|
||||
const { username, password, name, access_level, worker_id } = req.body;
|
||||
|
||||
if (!username || !password || !name || !access_level) {
|
||||
return res.status(400).json({ error: '필수 항목을 모두 입력해주세요.' });
|
||||
}
|
||||
|
||||
// 사용자명 중복 체크
|
||||
const existingUser = users.find(u => u.username === username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: '이미 존재하는 사용자명입니다.' });
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const newUser = {
|
||||
user_id: users.length + 1,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
access_level,
|
||||
worker_id: worker_id || null,
|
||||
created_at: new Date()
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '사용자가 성공적으로 등록되었습니다.',
|
||||
user: {
|
||||
user_id: newUser.user_id,
|
||||
username: newUser.username,
|
||||
name: newUser.name,
|
||||
access_level: newUser.access_level,
|
||||
worker_id: newUser.worker_id
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회 (관리자만)
|
||||
*/
|
||||
router.get('/users', requireAuth, requireRole('admin', 'system'), (req, res) => {
|
||||
try {
|
||||
const userList = users.map(user => ({
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id,
|
||||
created_at: user.created_at
|
||||
}));
|
||||
|
||||
res.json(userList);
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자 삭제 (관리자만)
|
||||
*/
|
||||
router.delete('/users/:id', requireAuth, requireRole('admin', 'system'), (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
|
||||
// 자기 자신 삭제 방지
|
||||
if (userId === req.user.user_id) {
|
||||
return res.status(400).json({ error: '자기 자신은 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const userIndex = users.findIndex(u => u.user_id === userId);
|
||||
if (userIndex === -1) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
users.splice(userIndex, 1);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '사용자가 성공적으로 삭제되었습니다.'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -10,7 +10,7 @@ const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const mysql = require('mysql2/promise');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
const { validatePassword, getPasswordError } = require('../utils/passwordValidator');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const departmentController = require('../controllers/departmentController');
|
||||
const { requireAuth, requireRole } = require('../middlewares/authMiddleware');
|
||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
|
||||
// 부서 목록 조회 (인증 필요)
|
||||
router.get('/', requireAuth, departmentController.getAll);
|
||||
|
||||
@@ -25,99 +25,4 @@ router.get('/detail', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 임시 마이그레이션 엔드포인트 - TBM work_type_id 수정
|
||||
// 실행 후 이 코드를 삭제하세요!
|
||||
router.post('/migrate-work-type-id', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
|
||||
|
||||
// 1. 수정 대상 확인
|
||||
const [checkResult] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.work_type_id as current_work_type_id,
|
||||
ta.task_id as correct_task_id,
|
||||
w.worker_name,
|
||||
dwr.report_date
|
||||
FROM daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
INNER JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND dwr.work_type_id != ta.task_id
|
||||
ORDER BY dwr.report_date DESC
|
||||
`);
|
||||
|
||||
console.log(`📊 수정 대상: ${checkResult.length}개 레코드`);
|
||||
|
||||
if (checkResult.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '수정할 데이터가 없습니다.',
|
||||
data: { affected_rows: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 전 샘플 로깅
|
||||
console.log('수정 전 샘플:', checkResult.slice(0, 5));
|
||||
|
||||
// 2. 업데이트 실행
|
||||
const [updateResult] = await db.query(`
|
||||
UPDATE daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
SET dwr.work_type_id = ta.task_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND dwr.work_type_id != ta.task_id
|
||||
`);
|
||||
|
||||
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}개 레코드 수정됨`);
|
||||
|
||||
// 3. 수정 후 확인
|
||||
const [samples] = await db.query(`
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.work_type_id,
|
||||
t.task_name,
|
||||
wt.name as work_type_name,
|
||||
w.worker_name,
|
||||
dwr.report_date
|
||||
FROM daily_work_reports dwr
|
||||
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
||||
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
WHERE dwr.tbm_assignment_id IS NOT NULL
|
||||
ORDER BY dwr.report_date DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${updateResult.affectedRows}개 레코드가 수정되었습니다.`,
|
||||
data: {
|
||||
affected_rows: updateResult.affectedRows,
|
||||
before_count: checkResult.length,
|
||||
samples: samples.map(s => ({
|
||||
id: s.id,
|
||||
worker: s.worker_name,
|
||||
date: s.report_date,
|
||||
task: s.task_name,
|
||||
work_type: s.work_type_name
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '마이그레이션 실패: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,7 +4,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const MonthlyStatusController = require('../controllers/monthlyStatusController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용 (임시로 주석 처리 - 테스트용)
|
||||
// router.use(verifyToken);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const notificationRecipientController = require('../controllers/notificationRecipientController');
|
||||
const { verifyToken, requireMinLevel } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken, requireMinLevel } = require('../middlewares/auth');
|
||||
|
||||
// 모든 라우트에 인증 필요
|
||||
router.use(verifyToken);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const TbmController = require('../controllers/tbmController');
|
||||
const { requireAuth } = require('../middlewares/auth');
|
||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
|
||||
// ==================== TBM 세션 관련 ====================
|
||||
|
||||
@@ -56,13 +56,13 @@ router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.
|
||||
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
||||
|
||||
// 안전 체크 항목 생성 (관리자용)
|
||||
router.post('/safety-checks', requireAuth, TbmController.createSafetyCheck);
|
||||
router.post('/safety-checks', requireAuth, requireRole('admin', 'system'), TbmController.createSafetyCheck);
|
||||
|
||||
// 안전 체크 항목 수정 (관리자용)
|
||||
router.put('/safety-checks/:checkId', requireAuth, TbmController.updateSafetyCheck);
|
||||
router.put('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.updateSafetyCheck);
|
||||
|
||||
// 안전 체크 항목 삭제 (관리자용)
|
||||
router.delete('/safety-checks/:checkId', requireAuth, TbmController.deleteSafetyCheck);
|
||||
router.delete('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.deleteSafetyCheck);
|
||||
|
||||
// TBM 세션의 안전 체크 기록 조회
|
||||
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const userController = require('../controllers/userController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const visitRequestController = require('../controllers/visitRequestController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(verifyToken);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// routes/workAnalysis.js
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const workAnalysisController = require('../controllers/workAnalysisController');
|
||||
|
||||
// 🔒 분석 기능은 admin 또는 system 권한만 접근 가능
|
||||
const requireAnalysisAccess = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '인증이 필요합니다.' });
|
||||
}
|
||||
|
||||
const allowedLevels = ['admin', 'system'];
|
||||
if (!allowedLevels.includes(req.user.access_level)) {
|
||||
return res.status(403).json({
|
||||
error: '분석 기능 접근 권한이 없습니다. 관리자 권한이 필요합니다.',
|
||||
required: 'admin 또는 system',
|
||||
current: req.user.access_level
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔓 분석 기능 접근 허용: ${req.user.username} (${req.user.access_level})`);
|
||||
next();
|
||||
};
|
||||
|
||||
// 임시로 권한 체크 건너뛰기 (테스트용)
|
||||
const skipAuth = (req, res, next) => {
|
||||
console.log('⚠️ 임시로 권한 체크 건너뛰기');
|
||||
next();
|
||||
};
|
||||
|
||||
// 기본 통계 조회 - 임시로 권한 체크 비활성화
|
||||
router.get('/stats', skipAuth, workAnalysisController.getStats);
|
||||
|
||||
// 일별 작업시간 추이 - 임시로 권한 체크 비활성화
|
||||
router.get('/daily-trend', skipAuth, workAnalysisController.getDailyTrend);
|
||||
|
||||
// 작업자별 통계 - 임시로 권한 체크 비활성화
|
||||
router.get('/worker-stats', skipAuth, workAnalysisController.getWorkerStats);
|
||||
|
||||
// 프로젝트별 통계 - 임시로 권한 체크 비활성화
|
||||
router.get('/project-stats', skipAuth, workAnalysisController.getProjectStats);
|
||||
|
||||
// 작업유형별 통계 - 임시로 권한 체크 비활성화
|
||||
router.get('/worktype-stats', skipAuth, workAnalysisController.getWorkTypeStats);
|
||||
|
||||
// 최근 작업 현황 - 임시로 권한 체크 비활성화
|
||||
router.get('/recent-work', skipAuth, workAnalysisController.getRecentWork);
|
||||
|
||||
// 요일별 패턴 분석
|
||||
router.get('/weekday-pattern', requireAnalysisAccess, workAnalysisController.getWeekdayPattern);
|
||||
|
||||
// 에러 분석
|
||||
router.get('/error-analysis', requireAnalysisAccess, workAnalysisController.getErrorAnalysis);
|
||||
|
||||
// 월별 비교 분석
|
||||
router.get('/monthly-comparison', requireAnalysisAccess, workAnalysisController.getMonthlyComparison);
|
||||
|
||||
// 작업자별 전문분야 분석
|
||||
router.get('/worker-specialization', requireAnalysisAccess, workAnalysisController.getWorkerSpecialization);
|
||||
|
||||
// 대시보드용 종합 데이터 (한 번에 여러 데이터 조회)
|
||||
router.get('/dashboard', requireAnalysisAccess, workAnalysisController.getDashboardData);
|
||||
|
||||
// 헬스체크 - 인증 없이 접근 가능
|
||||
router.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Work Analysis API is running',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -36,7 +36,7 @@
|
||||
if (!currentUser || !currentUser.user_id) return null;
|
||||
|
||||
// 캐시 확인
|
||||
const cached = localStorage.getItem('userPageAccess_v2_v2');
|
||||
const cached = localStorage.getItem('userPageAccess_v2');
|
||||
if (cached) {
|
||||
try {
|
||||
const cacheData = JSON.parse(cached);
|
||||
@@ -388,12 +388,8 @@
|
||||
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
|
||||
var escapeHtml = window.escapeHtml;
|
||||
|
||||
// ===== 날짜/시간 업데이트 =====
|
||||
function updateDateTime() {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Security Utilities - 보안 관련 유틸리티 함수
|
||||
*
|
||||
* XSS 방지, 입력값 검증, 안전한 DOM 조작을 위한 함수 모음
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-04
|
||||
*/
|
||||
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
const SecurityUtils = {
|
||||
/**
|
||||
* HTML 특수문자 이스케이프 (XSS 방지)
|
||||
* innerHTML에 사용자 입력을 삽입할 때 반드시 사용
|
||||
*
|
||||
* @param {string} str - 이스케이프할 문자열
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*
|
||||
* @example
|
||||
* element.innerHTML = `<span>${SecurityUtils.escapeHtml(userInput)}</span>`;
|
||||
*/
|
||||
escapeHtml: function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
if (typeof str !== 'string') str = String(str);
|
||||
|
||||
const htmlEntities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
return str.replace(/[&<>"'`=\/]/g, function(char) {
|
||||
return htmlEntities[char];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* URL 파라미터 이스케이프
|
||||
* URL에 사용자 입력을 포함할 때 사용
|
||||
*
|
||||
* @param {string} str - 이스케이프할 문자열
|
||||
* @returns {string} URL 인코딩된 문자열
|
||||
*/
|
||||
escapeUrl: function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return encodeURIComponent(String(str));
|
||||
},
|
||||
|
||||
/**
|
||||
* JavaScript 문자열 이스케이프
|
||||
* 동적 JavaScript 생성 시 사용 (권장하지 않음)
|
||||
*
|
||||
* @param {string} str - 이스케이프할 문자열
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
escapeJs: function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전한 텍스트 설정
|
||||
* innerHTML 대신 textContent 사용 권장
|
||||
*
|
||||
* @param {Element} element - DOM 요소
|
||||
* @param {string} text - 설정할 텍스트
|
||||
*/
|
||||
setTextSafe: function(element, text) {
|
||||
if (element && element.nodeType === 1) {
|
||||
element.textContent = text;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전한 HTML 삽입
|
||||
* 사용자 입력이 포함된 HTML을 삽입할 때 사용
|
||||
*
|
||||
* @param {Element} element - DOM 요소
|
||||
* @param {string} template - HTML 템플릿 ({{변수}} 형식)
|
||||
* @param {Object} data - 삽입할 데이터 (자동 이스케이프됨)
|
||||
*
|
||||
* @example
|
||||
* SecurityUtils.setHtmlSafe(div, '<span>{{name}}</span>', { name: userInput });
|
||||
*/
|
||||
setHtmlSafe: function(element, template, data) {
|
||||
if (!element || element.nodeType !== 1) return;
|
||||
|
||||
const self = this;
|
||||
const safeHtml = template.replace(/\{\{(\w+)\}\}/g, function(match, key) {
|
||||
return data.hasOwnProperty(key) ? self.escapeHtml(data[key]) : '';
|
||||
});
|
||||
|
||||
element.innerHTML = safeHtml;
|
||||
},
|
||||
|
||||
/**
|
||||
* 입력값 검증 - 숫자
|
||||
*
|
||||
* @param {any} value - 검증할 값
|
||||
* @param {Object} options - 옵션 { min, max, allowFloat }
|
||||
* @returns {number|null} 유효한 숫자 또는 null
|
||||
*/
|
||||
validateNumber: function(value, options) {
|
||||
options = options || {};
|
||||
const num = options.allowFloat ? parseFloat(value) : parseInt(value, 10);
|
||||
|
||||
if (isNaN(num)) return null;
|
||||
if (options.min !== undefined && num < options.min) return null;
|
||||
if (options.max !== undefined && num > options.max) return null;
|
||||
|
||||
return num;
|
||||
},
|
||||
|
||||
/**
|
||||
* 입력값 검증 - 이메일
|
||||
*
|
||||
* @param {string} email - 검증할 이메일
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
validateEmail: function(email) {
|
||||
if (!email || typeof email !== 'string') return false;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
},
|
||||
|
||||
/**
|
||||
* 입력값 검증 - 길이
|
||||
*
|
||||
* @param {string} str - 검증할 문자열
|
||||
* @param {Object} options - 옵션 { min, max }
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
validateLength: function(str, options) {
|
||||
options = options || {};
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
|
||||
const len = str.length;
|
||||
if (options.min !== undefined && len < options.min) return false;
|
||||
if (options.max !== undefined && len > options.max) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전한 JSON 파싱
|
||||
*
|
||||
* @param {string} jsonString - 파싱할 JSON 문자열
|
||||
* @param {any} defaultValue - 파싱 실패 시 기본값
|
||||
* @returns {any} 파싱된 객체 또는 기본값
|
||||
*/
|
||||
parseJsonSafe: function(jsonString, defaultValue) {
|
||||
defaultValue = defaultValue === undefined ? null : defaultValue;
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.warn('[SecurityUtils] JSON 파싱 실패:', e.message);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* localStorage에서 안전하게 데이터 가져오기
|
||||
*
|
||||
* @param {string} key - 키
|
||||
* @param {any} defaultValue - 기본값
|
||||
* @returns {any} 저장된 값 또는 기본값
|
||||
*/
|
||||
getStorageSafe: function(key, defaultValue) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item === null) return defaultValue;
|
||||
return this.parseJsonSafe(item, defaultValue);
|
||||
} catch (e) {
|
||||
console.warn('[SecurityUtils] localStorage 접근 실패:', e.message);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* URL 파라미터 안전하게 가져오기
|
||||
*
|
||||
* @param {string} name - 파라미터 이름
|
||||
* @param {string} defaultValue - 기본값
|
||||
* @returns {string} 파라미터 값 (이스케이프됨)
|
||||
*/
|
||||
getUrlParamSafe: function(name, defaultValue) {
|
||||
defaultValue = defaultValue === undefined ? '' : defaultValue;
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const value = urlParams.get(name);
|
||||
return value !== null ? value : defaultValue;
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ID 파라미터 안전하게 가져오기 (숫자 검증)
|
||||
*
|
||||
* @param {string} name - 파라미터 이름
|
||||
* @returns {number|null} 유효한 ID 또는 null
|
||||
*/
|
||||
getIdParamSafe: function(name) {
|
||||
const value = this.getUrlParamSafe(name);
|
||||
return this.validateNumber(value, { min: 1 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Content Security Policy 위반 리포터
|
||||
*
|
||||
* @param {string} reportUri - 리포트 전송 URL
|
||||
*/
|
||||
enableCspReporting: function(reportUri) {
|
||||
document.addEventListener('securitypolicyviolation', function(e) {
|
||||
console.error('[CSP Violation]', {
|
||||
blockedUri: e.blockedURI,
|
||||
violatedDirective: e.violatedDirective,
|
||||
originalPolicy: e.originalPolicy
|
||||
});
|
||||
|
||||
if (reportUri) {
|
||||
fetch(reportUri, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blocked_uri: e.blockedURI,
|
||||
violated_directive: e.violatedDirective,
|
||||
document_uri: e.documentURI,
|
||||
timestamp: new Date().toISOString()
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).catch(function() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 노출
|
||||
global.SecurityUtils = SecurityUtils;
|
||||
|
||||
// 편의를 위한 단축 함수
|
||||
global.escapeHtml = SecurityUtils.escapeHtml.bind(SecurityUtils);
|
||||
global.escapeUrl = SecurityUtils.escapeUrl.bind(SecurityUtils);
|
||||
|
||||
console.log('[Module] common/security.js 로드 완료');
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -12,16 +12,7 @@ let workplaceItems = []; // 현재 작업장 물품
|
||||
let isItemEditMode = false;
|
||||
let workplaceDetail = null; // 작업장 상세 정보
|
||||
|
||||
// XSS 방지를 위한 HTML 이스케이프 함수
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
|
||||
|
||||
// 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외)
|
||||
function getImageUrl(path) {
|
||||
|
||||
@@ -441,15 +441,7 @@ function formatTimeAgo(dateString) {
|
||||
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 이스케이프
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
|
||||
|
||||
function waitForApi(timeout) {
|
||||
timeout = timeout || 5000;
|
||||
|
||||
@@ -9,16 +9,7 @@ let isAddingItem = false;
|
||||
let selectionStart = null;
|
||||
let selectionBox = null;
|
||||
|
||||
// XSS 방지를 위한 HTML 이스케이프 함수
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
|
||||
|
||||
// axios 설정 대기
|
||||
function waitForAxiosConfig() {
|
||||
|
||||
@@ -818,6 +818,6 @@
|
||||
<script src="/js/tbm/state.js"></script>
|
||||
<script src="/js/tbm/utils.js"></script>
|
||||
<script src="/js/tbm/api.js"></script>
|
||||
<script src="/js/tbm-create.js?v=12"></script>
|
||||
<script src="/js/tbm-create.js?v=13"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,734 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, date
|
||||
from typing import List
|
||||
import io
|
||||
import re
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
or_(
|
||||
Issue.review_status == ReviewStatus.in_progress,
|
||||
and_(
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 이슈 리스트를 schema로 변환
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
}
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (한번도 추출 안된 항목만)
|
||||
completed_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
).all()
|
||||
|
||||
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
|
||||
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
|
||||
|
||||
# 전체 이슈 (통계 계산용)
|
||||
issues = in_progress_issues + completed_issues
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
stats_font = Font(bold=True, size=12)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
card_header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
label_font = Font(bold=True, size=10)
|
||||
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
content_font = Font(size=10)
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 두 개의 시트를 생성하고 각각 데이터 입력
|
||||
sheets_data = [
|
||||
(wb.active, in_progress_issues, "진행 중"),
|
||||
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
|
||||
]
|
||||
|
||||
sheets_data[0][0].title = "진행 중"
|
||||
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells('A2:L2')
|
||||
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
|
||||
ws['A2'].alignment = center_alignment
|
||||
|
||||
# 프로젝트 통계 (4행부터)
|
||||
ws.merge_cells('A4:L4')
|
||||
ws['A4'] = "프로젝트 현황"
|
||||
ws['A4'].font = stats_font
|
||||
ws['A4'].fill = stats_fill
|
||||
ws['A4'].alignment = center_alignment
|
||||
|
||||
# 통계 데이터
|
||||
stats_row = 5
|
||||
ws[f'A{stats_row}'] = "총 신고 수량"
|
||||
ws[f'B{stats_row}'] = stats.total_count
|
||||
ws[f'D{stats_row}'] = "관리처리 현황"
|
||||
ws[f'E{stats_row}'] = stats.management_count
|
||||
ws[f'G{stats_row}'] = "완료 현황"
|
||||
ws[f'H{stats_row}'] = stats.completed_count
|
||||
ws[f'J{stats_row}'] = "지연 중"
|
||||
ws[f'K{stats_row}'] = stats.delayed_count
|
||||
|
||||
# 통계 스타일 적용
|
||||
for col in ['A', 'D', 'G', 'J']:
|
||||
ws[f'{col}{stats_row}'].font = Font(bold=True)
|
||||
ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
|
||||
# 카드 형태로 데이터 입력 (7행부터)
|
||||
current_row = 7
|
||||
|
||||
for idx, issue in enumerate(sheet_issues, 1):
|
||||
card_start_row = current_row
|
||||
|
||||
# 상태별 헤더 색상 설정
|
||||
header_color = get_issue_status_header_color(issue)
|
||||
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
||||
|
||||
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
|
||||
ws.merge_cells(f'A{current_row}:C{current_row}')
|
||||
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
|
||||
ws[f'A{current_row}'].font = card_header_font
|
||||
ws[f'A{current_row}'].fill = card_header_fill
|
||||
ws[f'A{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'D{current_row}:G{current_row}')
|
||||
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
|
||||
ws[f'D{current_row}'].font = card_header_font
|
||||
ws[f'D{current_row}'].fill = card_header_fill
|
||||
ws[f'D{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
|
||||
ws[f'H{current_row}'].font = card_header_font
|
||||
ws[f'H{current_row}'].fill = card_header_fill
|
||||
ws[f'H{current_row}'].alignment = center_alignment
|
||||
current_row += 1
|
||||
|
||||
# 부적합명
|
||||
ws[f'A{current_row}'] = "부적합명"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# final_description이 있으면 사용, 없으면 description 사용
|
||||
issue_title = issue.final_description or issue.description or "내용 없음"
|
||||
ws[f'B{current_row}'] = issue_title
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
current_row += 1
|
||||
|
||||
# 상세내용 (detail_notes가 실제 상세 설명)
|
||||
if issue.detail_notes:
|
||||
ws[f'A{current_row}'] = "상세내용"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.detail_notes
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
ws.row_dimensions[current_row].height = 50
|
||||
current_row += 1
|
||||
|
||||
# 원인분류
|
||||
ws[f'A{current_row}'] = "원인분류"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "원인부서"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:F{current_row}')
|
||||
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'G{current_row}'] = "신고자"
|
||||
ws[f'G{current_row}'].font = label_font
|
||||
ws[f'G{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
|
||||
ws[f'H{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 해결방안 (완료 반려 내용 및 댓글 제거)
|
||||
ws[f'A{current_row}'] = "해결방안"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# management_comment에서 완료 반려 패턴과 댓글 제거
|
||||
clean_solution = clean_management_comment_for_export(issue.management_comment)
|
||||
ws[f'B{current_row}'] = clean_solution
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 담당정보
|
||||
ws[f'A{current_row}'] = "담당부서"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "담당자"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:G{current_row}')
|
||||
ws[f'E{current_row}'] = issue.responsible_person or ""
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'H{current_row}'] = "조치예상일"
|
||||
ws[f'H{current_row}'].font = label_font
|
||||
ws[f'H{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'I{current_row}:L{current_row}')
|
||||
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
|
||||
ws[f'I{current_row}'].font = content_font
|
||||
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
|
||||
current_row += 1
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
has_report_photo = issue.photo_path or issue.photo_path2
|
||||
if has_report_photo:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "신고 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
report_image_inserted = False
|
||||
|
||||
# 신고 사진 1
|
||||
if issue.photo_path:
|
||||
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
# 신고 사진 2
|
||||
if issue.photo_path2:
|
||||
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
|
||||
if os.path.exists(photo_path2):
|
||||
try:
|
||||
img = XLImage(photo_path2)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# === 완료 관련 정보 (완료된 항목만) ===
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
# 완료 코멘트
|
||||
if issue.completion_comment:
|
||||
ws[f'A{current_row}'] = "완료 의견"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.completion_comment
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 완료 사진
|
||||
if issue.completion_photo_path:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "완료 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
completion_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
|
||||
completion_image_inserted = False
|
||||
|
||||
if os.path.exists(completion_path):
|
||||
try:
|
||||
img = XLImage(completion_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 사진
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({completion_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# 완료일 정보
|
||||
if issue.actual_completion_date:
|
||||
ws[f'A{current_row}'] = "완료일"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 사진이 하나도 없을 경우
|
||||
# 진행중: 신고 사진만 체크
|
||||
# 완료됨: 신고 사진 + 완료 사진 체크
|
||||
has_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
|
||||
|
||||
if not has_any_photo:
|
||||
ws[f'A{current_row}'] = "첨부사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = "첨부된 사진 없음"
|
||||
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
current_row += 1
|
||||
|
||||
# 카드 전체에 테두리 적용 (A-L 열)
|
||||
card_end_row = current_row - 1
|
||||
for row in range(card_start_row, card_end_row + 1):
|
||||
for col in range(1, 13): # A-L 열 (12열)
|
||||
cell = ws.cell(row=row, column=col)
|
||||
if not cell.border or cell.border.left.style != 'medium':
|
||||
cell.border = border
|
||||
|
||||
# 카드 외곽에 굵은 테두리 (A-L 열)
|
||||
for col in range(1, 13):
|
||||
ws.cell(row=card_start_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
ws.column_dimensions['A'].width = 12 # 레이블 열
|
||||
ws.column_dimensions['B'].width = 15 # 내용 열
|
||||
ws.column_dimensions['C'].width = 15 # 내용 열
|
||||
ws.column_dimensions['D'].width = 15 # 내용 열
|
||||
ws.column_dimensions['E'].width = 15 # 내용 열
|
||||
ws.column_dimensions['F'].width = 15 # 내용 열
|
||||
ws.column_dimensions['G'].width = 15 # 내용 열
|
||||
ws.column_dimensions['H'].width = 15 # 내용 열
|
||||
ws.column_dimensions['I'].width = 15 # 내용 열
|
||||
ws.column_dimensions['J'].width = 15 # 내용 열
|
||||
ws.column_dimensions['K'].width = 15 # 내용 열
|
||||
ws.column_dimensions['L'].width = 15 # 내용 열
|
||||
|
||||
# 엑셀 파일을 메모리에 저장
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
# 추출 이력 업데이트
|
||||
export_time = datetime.now()
|
||||
for issue in issues:
|
||||
issue.last_exported_at = export_time
|
||||
issue.export_count = (issue.export_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(excel_buffer.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
|
||||
"""프로젝트 통계 계산"""
|
||||
stats = schemas.DailyReportStats()
|
||||
|
||||
today = date.today()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
|
||||
if issue.expected_completion_date:
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_category_text(category: IssueCategory) -> str:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
return status_map.get(status, str(status))
|
||||
|
||||
def clean_management_comment_for_export(text: str) -> str:
|
||||
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
|
||||
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
|
||||
|
||||
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
|
||||
lines = text.split('\n')
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# └ 또는 ↳로 시작하는 줄 제외
|
||||
if not re.match(r'^\s*[└↳]', line):
|
||||
filtered_lines.append(line)
|
||||
|
||||
# 3. 빈 줄 정리
|
||||
result = '\n'.join(filtered_lines).strip()
|
||||
# 연속된 빈 줄을 하나로
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result
|
||||
|
||||
def get_status_color(status: ReviewStatus) -> str:
|
||||
"""상태별 색상 반환"""
|
||||
color_map = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
ReviewStatus.disposed: "F2F2F2" # 연한 회색
|
||||
}
|
||||
return color_map.get(status, None)
|
||||
|
||||
def get_issue_priority(issue: Issue) -> int:
|
||||
"""이슈 우선순위 반환 (엑셀 정렬용)
|
||||
1: 지연 (빨강)
|
||||
2: 진행중 (노랑)
|
||||
3: 완료됨 (초록)
|
||||
"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return 3
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
# 조치 예상일이 지난 경우 지연
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return 1 # 지연
|
||||
return 2 # 진행중
|
||||
return 2
|
||||
|
||||
def get_issue_status_text(issue: Issue) -> str:
|
||||
"""이슈 상태 텍스트 반환"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return "완료됨"
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return "지연중"
|
||||
return "진행중"
|
||||
return get_status_text(issue.review_status)
|
||||
|
||||
def get_issue_status_header_color(issue: Issue) -> str:
|
||||
"""이슈 상태별 헤더 색상 반환"""
|
||||
priority = get_issue_priority(issue)
|
||||
if priority == 1: # 지연
|
||||
return "E74C3C" # 빨간색
|
||||
elif priority == 2: # 진행중
|
||||
return "F39C12" # 주황/노란색
|
||||
elif priority == 3: # 완료
|
||||
return "27AE60" # 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
Reference in New Issue
Block a user