From 93edf9529a90b354a32f65a7809150dc8f86a9f9 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 25 Feb 2026 08:19:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=B3=B4=EC=95=88=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90=20=EC=A0=9C=EA=B1=B0=20+=20=EB=8D=B0?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20+=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증 없는 임시 엔드포인트 삭제 (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 --- PROGRESS.md | 344 +++----- system1-factory/api/config/routes.js | 7 +- .../api/controllers/pageAccessController.js | 200 ----- system1-factory/api/db.js | 35 - system1-factory/api/index.js | 216 ------ system1-factory/api/middlewares/access.js | 9 - .../api/middlewares/accessMiddleware.js | 29 - .../api/middlewares/authMiddleware.js | 36 - system1-factory/api/routes/analysisRoutes.js | 2 +- .../api/routes/attendanceRoutes.js | 2 +- system1-factory/api/routes/auth.js | 215 ----- system1-factory/api/routes/authRoutes.js | 2 +- .../api/routes/departmentRoutes.js | 2 +- system1-factory/api/routes/healthRoutes.js | 95 --- .../api/routes/monthlyStatusRoutes.js | 2 +- .../api/routes/notificationRecipientRoutes.js | 2 +- system1-factory/api/routes/tbmRoutes.js | 8 +- system1-factory/api/routes/userRoutes.js | 2 +- .../api/routes/visitRequestRoutes.js | 2 +- system1-factory/api/routes/workAnalysis.js | 74 -- system1-factory/web/js/app-init.js | 10 +- system1-factory/web/js/common/security.js | 259 ------ system1-factory/web/js/daily-patrol.js | 11 +- system1-factory/web/js/load-navbar.js | 10 +- system1-factory/web/js/mobile-dashboard.js | 7 +- system1-factory/web/js/zone-detail.js | 11 +- .../web/pages/work/tbm-create.html | 2 +- .../api/routers/reports.py.bak | 734 ------------------ 28 files changed, 136 insertions(+), 2192 deletions(-) delete mode 100644 system1-factory/api/controllers/pageAccessController.js delete mode 100644 system1-factory/api/db.js delete mode 100644 system1-factory/api/middlewares/access.js delete mode 100644 system1-factory/api/middlewares/accessMiddleware.js delete mode 100644 system1-factory/api/middlewares/authMiddleware.js delete mode 100644 system1-factory/api/routes/auth.js delete mode 100644 system1-factory/api/routes/workAnalysis.js delete mode 100644 system1-factory/web/js/common/security.js delete mode 100644 system3-nonconformance/api/routers/reports.py.bak diff --git a/PROGRESS.md b/PROGRESS.md index df0b2bc..c4561fc 100644 --- a/PROGRESS.md +++ b/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/...')`, ``)을 사용하여 경로 프리픽스 없이 요청 → 라우팅 불가. - -**해결**: 서브도메인 기반 라우팅 + 쿠키 기반 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/` diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index a03b560..6bc8d7e 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -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보다 먼저 실행) diff --git a/system1-factory/api/controllers/pageAccessController.js b/system1-factory/api/controllers/pageAccessController.js deleted file mode 100644 index 0448e39..0000000 --- a/system1-factory/api/controllers/pageAccessController.js +++ /dev/null @@ -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; diff --git a/system1-factory/api/db.js b/system1-factory/api/db.js deleted file mode 100644 index e099cab..0000000 --- a/system1-factory/api/db.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index fded5b8..7e7661d 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -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); diff --git a/system1-factory/api/middlewares/access.js b/system1-factory/api/middlewares/access.js deleted file mode 100644 index 2e12395..0000000 --- a/system1-factory/api/middlewares/access.js +++ /dev/null @@ -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(); - }; -}; \ No newline at end of file diff --git a/system1-factory/api/middlewares/accessMiddleware.js b/system1-factory/api/middlewares/accessMiddleware.js deleted file mode 100644 index d774915..0000000 --- a/system1-factory/api/middlewares/accessMiddleware.js +++ /dev/null @@ -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 -}; diff --git a/system1-factory/api/middlewares/authMiddleware.js b/system1-factory/api/middlewares/authMiddleware.js deleted file mode 100644 index 6fded28..0000000 --- a/system1-factory/api/middlewares/authMiddleware.js +++ /dev/null @@ -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 -}; diff --git a/system1-factory/api/routes/analysisRoutes.js b/system1-factory/api/routes/analysisRoutes.js index e0d9c92..aea6907 100644 --- a/system1-factory/api/routes/analysisRoutes.js +++ b/system1-factory/api/routes/analysisRoutes.js @@ -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); diff --git a/system1-factory/api/routes/attendanceRoutes.js b/system1-factory/api/routes/attendanceRoutes.js index b93a7e4..99e4c6b 100644 --- a/system1-factory/api/routes/attendanceRoutes.js +++ b/system1-factory/api/routes/attendanceRoutes.js @@ -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); diff --git a/system1-factory/api/routes/auth.js b/system1-factory/api/routes/auth.js deleted file mode 100644 index ee49f4f..0000000 --- a/system1-factory/api/routes/auth.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/system1-factory/api/routes/authRoutes.js b/system1-factory/api/routes/authRoutes.js index 46d7f8c..9a92881 100644 --- a/system1-factory/api/routes/authRoutes.js +++ b/system1-factory/api/routes/authRoutes.js @@ -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'); diff --git a/system1-factory/api/routes/departmentRoutes.js b/system1-factory/api/routes/departmentRoutes.js index fa4fd43..d6e7198 100644 --- a/system1-factory/api/routes/departmentRoutes.js +++ b/system1-factory/api/routes/departmentRoutes.js @@ -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); diff --git a/system1-factory/api/routes/healthRoutes.js b/system1-factory/api/routes/healthRoutes.js index 25f74b2..984d676 100644 --- a/system1-factory/api/routes/healthRoutes.js +++ b/system1-factory/api/routes/healthRoutes.js @@ -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; \ No newline at end of file diff --git a/system1-factory/api/routes/monthlyStatusRoutes.js b/system1-factory/api/routes/monthlyStatusRoutes.js index 56af931..1274daa 100644 --- a/system1-factory/api/routes/monthlyStatusRoutes.js +++ b/system1-factory/api/routes/monthlyStatusRoutes.js @@ -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); diff --git a/system1-factory/api/routes/notificationRecipientRoutes.js b/system1-factory/api/routes/notificationRecipientRoutes.js index ded32a5..12f91bb 100644 --- a/system1-factory/api/routes/notificationRecipientRoutes.js +++ b/system1-factory/api/routes/notificationRecipientRoutes.js @@ -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); diff --git a/system1-factory/api/routes/tbmRoutes.js b/system1-factory/api/routes/tbmRoutes.js index 0db3eb2..2b21464 100644 --- a/system1-factory/api/routes/tbmRoutes.js +++ b/system1-factory/api/routes/tbmRoutes.js @@ -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); diff --git a/system1-factory/api/routes/userRoutes.js b/system1-factory/api/routes/userRoutes.js index 610fefe..4e6a93a 100644 --- a/system1-factory/api/routes/userRoutes.js +++ b/system1-factory/api/routes/userRoutes.js @@ -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'); /** diff --git a/system1-factory/api/routes/visitRequestRoutes.js b/system1-factory/api/routes/visitRequestRoutes.js index 5d48d80..9adf676 100644 --- a/system1-factory/api/routes/visitRequestRoutes.js +++ b/system1-factory/api/routes/visitRequestRoutes.js @@ -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); diff --git a/system1-factory/api/routes/workAnalysis.js b/system1-factory/api/routes/workAnalysis.js deleted file mode 100644 index 3d6c901..0000000 --- a/system1-factory/api/routes/workAnalysis.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/system1-factory/web/js/app-init.js b/system1-factory/web/js/app-init.js index 25a3641..086b118 100644 --- a/system1-factory/web/js/app-init.js +++ b/system1-factory/web/js/app-init.js @@ -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() { diff --git a/system1-factory/web/js/common/security.js b/system1-factory/web/js/common/security.js deleted file mode 100644 index 19304c4..0000000 --- a/system1-factory/web/js/common/security.js +++ /dev/null @@ -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 = `${SecurityUtils.escapeHtml(userInput)}`; - */ - 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, '{{name}}', { 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); diff --git a/system1-factory/web/js/daily-patrol.js b/system1-factory/web/js/daily-patrol.js index cec5220..a963b20 100644 --- a/system1-factory/web/js/daily-patrol.js +++ b/system1-factory/web/js/daily-patrol.js @@ -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, '''); -} +// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공 // 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외) function getImageUrl(path) { diff --git a/system1-factory/web/js/load-navbar.js b/system1-factory/web/js/load-navbar.js index 077ad0c..3baa3d0 100644 --- a/system1-factory/web/js/load-navbar.js +++ b/system1-factory/web/js/load-navbar.js @@ -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 () => { diff --git a/system1-factory/web/js/mobile-dashboard.js b/system1-factory/web/js/mobile-dashboard.js index 47489ca..2283513 100644 --- a/system1-factory/web/js/mobile-dashboard.js +++ b/system1-factory/web/js/mobile-dashboard.js @@ -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; diff --git a/system1-factory/web/js/zone-detail.js b/system1-factory/web/js/zone-detail.js index 86b5393..59daef1 100644 --- a/system1-factory/web/js/zone-detail.js +++ b/system1-factory/web/js/zone-detail.js @@ -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, '''); -} +// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공 // axios 설정 대기 function waitForAxiosConfig() { diff --git a/system1-factory/web/pages/work/tbm-create.html b/system1-factory/web/pages/work/tbm-create.html index 00d52ee..2106186 100644 --- a/system1-factory/web/pages/work/tbm-create.html +++ b/system1-factory/web/pages/work/tbm-create.html @@ -818,6 +818,6 @@ - + diff --git a/system3-nonconformance/api/routers/reports.py.bak b/system3-nonconformance/api/routers/reports.py.bak deleted file mode 100644 index 9bff59b..0000000 --- a/system3-nonconformance/api/routers/reports.py.bak +++ /dev/null @@ -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" # 기본 파란색