Compare commits

...

156 Commits

Author SHA1 Message Date
Hyungi Ahn
312369d9ac fix: system1-web bind mount를 public/으로 수정
bind mount가 web/ 전체를 웹루트에 마운트하여 Dockerfile의 COPY public/을
덮어쓰고 있었음. public/ 서브디렉토리만 마운트하도록 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:56:36 +09:00
Hyungi Ahn
9d2179e47a security: 웹루트 분리 — COPY . → COPY public/ + nginx deny 3중 방어
3개 서비스(system1/system2/system3 web)에서 Dockerfile, nginx.conf,
docker-compose.yml이 외부 노출되는 취약점 수정.

[구조 수정]
- Dockerfile: COPY . → COPY public/ (정적 파일만 웹루트에 복사)
- system3 uploads: plain prefix → ^~ (regex deny 우선순위 충돌 방지)

[nginx deny (defense in depth)]
- exact match: /Dockerfile, /docker-compose.yml, /nginx.conf, /.env, /.gitignore
- prefix: ^~ /.git/ 디렉토리 전체 차단
- regex: 하위 경로 + 변형 대비

[CI 보안 게이트]
- scripts/check-webroot-security.sh: 화이트리스트 방식, find + exact match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:52:53 +09:00
Hyungi Ahn
ba9ef32808 security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거
보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:44:21 +09:00
Hyungi Ahn
bbffa47a9d fix: HEIC 프리뷰 placeholder + 모바일 사진함 접근 허용
1. issue-report.js updatePhotoSlot: 브라우저가 <img> 로 렌더링 못하는
   포맷(HEIC 등) 감지해서 녹색 placeholder("📷 HEIC 첨부됨") 로 대체.
   photos[index] 데이터는 유지해서 업로드는 정상 동작 (서버 ImageMagick
   fallback 이 HEIC→JPEG 변환). 데스크톱 Chrome 에서 깨진 이미지 아이콘
   보이던 문제 해결.

2. m/management.html editPhotoInput: capture="environment" 제거.
   이 속성이 있으면 모바일에서 카메라 직접 호출만 되고 사진함 선택 UI 가
   나오지 않음. 관리함 사진 보충 시 기존 사진에서 고를 수 있어야 함.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:01:29 +09:00
Hyungi Ahn
bf0d7fd87a fix(tkqc): iPhone HEIC 업로드 실패 → ImageMagick fallback 추가
증상: 사용자가 iPhone HEIC 사진을 관리함에서 업로드하면 400 Bad Request.
로그:
  ⚠️ pillow_heif 직접 처리 실패: Metadata not correctly assigned to image
   HEIF 처리도 실패: cannot identify image file

원인: pillow_heif 가 특정 iPhone 이 생성한 HEIC 의 메타데이터를 처리 못함.
libheif 를 직접 사용하는 ImageMagick 이 더 범용적이라 system2-report/imageUploadService.js
와 동일한 패턴으로 fallback 추가.

변경:
- Dockerfile: imagemagick + libheif1 apt-get 추가 + HEIC policy.xml 해제
- file_service.py: pillow_heif/PIL 실패 시 subprocess 로 magick/convert 호출해서
  임시 파일로 JPEG 변환 후 다시 PIL 로 open

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:51:24 +09:00
Hyungi Ahn
56f626911a fix(tkqc): 관리함 데스크톱 인라인 카드에도 사진 보충 UI 추가
첫 커밋(178155d)에서는 createModalContent(완료된 이슈 상세 모달)에만
사진 보충 input을 추가했는데, 데스크톱 관리함은 상세 모달이 아니라
createInProgressRow 로 렌더링되는 인라인 카드(저장/삭제/완료처리 버튼이
카드에 직접 있는 형태) 이었음. 사용자 스크린샷으로 확인.

- createInProgressRow: "업로드 사진" 섹션 아래 file input 추가
- saveIssueChanges: 저장 시 빈 슬롯에만 photo/photo2~5 base64 업로드

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:46:46 +09:00
Hyungi Ahn
178155df6b fix(tkqc): 사진 silent data loss 차단 + 관리함 사진 보충 기능
근본 원인: 2026-04-01 보안 패치(f09c86e)에서 system3-api Dockerfile에
USER appuser 추가했으나, named volume(tkqc-package_uploads)이 root 소유로
남아있어 appuser(999)가 /app/uploads 에 쓰기 실패. 하지만 file_service.py
가 except → return None 으로 silent failure 처리해서 system2는 200 OK 로
인식. 결과: 4/1 이후 qc_issues.id=185~191 사진이 전부 photo_path=NULL.

조치:
1. system3 Dockerfile: entrypoint.sh 추가 → 시작 시 chown 후 gosu appuser 강등
   (named volume 이 restart 후에도 root로 돌아가는 문제 영구 해결)
2. file_service.py: save_base64_image 실패 시 RuntimeError raise (silent 금지)
3. system2 workIssueController: sendToMProject 실패/예외 시 system 알림 발송
4. 관리함 (desktop + mobile): 이슈 상세/편집 모달에 원본 사진 보충 UI 추가
   - 빈 슬롯(photo_path{N}=NULL)에만 자동 채움, 기존 사진 유지
   - ManagementUpdateRequest 스키마에 photo/photo2~5 필드 추가
   - update_issue_management 엔드포인트에 사진 저장 루프 추가

런타임 chown 으로 immediate data loss 는 이미 차단됨 (09:28 KST).
이 커밋은 재발 방지 + 데이터 복구 UI 제공.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:28:24 +09:00
Hyungi Ahn
d49aa01bd5 fix: gateway 공장관리 카드 → dashboard-new.html로 변경
구 대시보드(dashboard.html)로 보내고 있었음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:50:00 +09:00
Hyungi Ahn
f28922a3ae fix(sw): /dashboard, /login 경로 SW fetch에서 제외 — 리다이렉트 루프 방지
SW가 /dashboard fetch → nginx proxy → gateway → 응답 →
SW가 다시 fetch → 무한 루프. 프록시 경로는 SW 캐싱에서 제외.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:47:32 +09:00
Hyungi Ahn
7db072ed14 fix: /login, /dashboard 301 무한루프 해소 — gateway 내부 프록시로 전환
tkfb.technicalkorea.net이 system1-web을 직접 가리키므로
자기 자신으로 301 리다이렉트 → 무한 루프 발생.
외부 리다이렉트 대신 gateway 컨테이너로 내부 프록시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:44:08 +09:00
Hyungi Ahn
0de9d5bb48 feat(sso): 인앱 브라우저 SSO 토큰 릴레이 — 카톡 WebView 쿠키 미공유 해결
카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아
tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제.

- sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정
- gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가
- 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:44:02 +09:00
Hyungi Ahn
de6d918d42 fix: nginx 레거시 리다이렉트 tkds→tkfb 수정
/login, /dashboard 경로가 여전히 tkds.technicalkorea.net으로
302/301 리다이렉트하고 있었음. 이게 진짜 원인.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:41:18 +09:00
Hyungi Ahn
1cfd4da8ba feat(pwa): Network-First 서비스 워커 — 홈화면 앱 자동 갱신
- sw.js: Network-First 캐시 전략 (GET + same-origin + res.ok만 캐시)
- tkfb-core.js: SW 등록 + 업데이트 감지 시 자동 새로고침
  (최초 설치 시 토스트 방지: controller 체크)
- manifest.json: start_url → dashboard-new.html
- nginx: sw.js, manifest.json no-cache 헤더
- 배포 시 sw.js의 APP_VERSION만 변경하면 전 사용자 자동 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:36:36 +09:00
Hyungi Ahn
46a1f8310d fix(cache): tkds→tkfb 변경 후 전 서비스 캐시 버스팅 갱신
tkfb-core.js v=2026040104, tksupport/tksafety/tkpurchase/tkuser-core.js,
system2 api-base.js, system3 app.js 캐시 버스팅 일괄 갱신.
브라우저 캐시에 남은 구버전(tkds 리다이렉트) 강제 갱신.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:31:14 +09:00
Hyungi Ahn
28a5924e76 fix: tkds.technicalkorea.net → tkfb.technicalkorea.net 일괄 전환
tkds 도메인 폐기. 로그인 리다이렉트, CORS, 알림벨 등 16개 파일에서
tkds → tkfb로 변경. tkds로 접속 시 gateway에 /pages/ 경로가 없어
404 발생하던 문제 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:26:32 +09:00
Hyungi Ahn
d3487fd8fd fix(tkfb): 로그인 후 redirect 404 수정 — 상대경로→절대URL
index.html에서 /pages/dashboard-new.html 상대경로로 redirect 파라미터를 넘기면
tkds에서 로그인 후 tkds.technicalkorea.net/pages/dashboard-new.html로 이동하여 404 발생.
window.location.origin을 붙여 절대 URL로 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:22:22 +09:00
Hyungi Ahn
48e3b58865 fix(cors): 인앱 브라우저 CORS 차단 해결 — 카톡 WebView 대응
- new Error() → cb(null, false): 500 에러 대신 CORS 헤더 미포함으로 거부
- *.technicalkorea.net 와일드카드 추가: 서브도메인 간 통신 보장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:15:49 +09:00
Hyungi Ahn
697af50963 docs: CLAUDE.md에 개발 주의사항 추가 — 오늘 실수 기록
- admin 계정 전용 테스트 금지 (권한 미들웨어 스킵 문제)
- workers/sso_users department_id 불일치 주의
- const vs function 전역 스코프 충돌
- 캐시 버스팅 누락 방지
- nginx 프록시 경로 우선순위

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:13:45 +09:00
Hyungi Ahn
b70904a4de fix(auth): pagePermission 미들웨어 getPool await 누락 수정
getDb()가 async(Promise 반환)인데 await 없이 호출하여
non-admin 사용자 API 호출 시 db.query is not a function 에러 발생.
admin은 L18에서 조기 리턴하여 영향 없었음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:09:47 +09:00
Hyungi Ahn
cc69b452ab fix(auth): pageAccessRoutes 부서 조회에 sso_users fallback 추가
workers 테이블에 없는 사용자(55명 중 45명)의 department_id가 0이 되어
부서 권한 매칭 실패 → 모든 페이지 접근 차단되던 문제.
COALESCE(w.department_id, su.department_id) fallback 적용.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:50:16 +09:00
Hyungi Ahn
05d9e90c39 fix(nav): 하단 배너 데스크톱(768px+)에서 숨김 처리
모바일 전용 하단 네비가 데스크톱에서 본문과 겹치는 문제.
768px 이상에서 display:none, 480-767px에서 max-width 제한.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:24:09 +09:00
Hyungi Ahn
72e4a8b277 fix(nav): 하단 배너에서 연차관리 제거
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:18:54 +09:00
Hyungi Ahn
3f870b247d fix(nav): 사이드바 메뉴를 DB 권한(accessibleKeys) 기반으로 필터링
기존: non-admin 페이지는 무조건 표시 (publicPageKeys 개념)
변경: accessibleKeys에 포함된 페이지만 표시 (대시보드 그리드와 동일 기준)
- publicPageKeys 로직 제거, accessibleKeys 단일 기준 통합
- external 링크(부적합, 휴가 신청 등)는 항상 표시
- dashboard, profile.* 페이지는 전체 공개 유지
- tkfb-core.js 캐시 버스팅 v=2026040103

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:14:53 +09:00
Hyungi Ahn
4063eba5bb feat(purchase): 카테고리 테이블 분리 + 동적 로드 + tkuser 관리
- DB: consumable_categories 테이블 생성, ENUM→VARCHAR 변환, 시드 4개
- API: GET/POST/PUT/DEACTIVATE /api/consumable-categories
- 프론트: 3개 JS 하드코딩 CAT_LABELS 제거 → API loadCategories() 동적 로드
- tkuser: 카테고리 관리 섹션 추가, select 옵션 동적 생성
- 별칭 시드 SQL (INSERT IGNORE 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:07:14 +09:00
Hyungi Ahn
118dc29c95 fix(tkuser): 소모품 권한 관리 — 누락 페이지 추가 + 명칭 수정
- purchase.request_mobile(소모품 신청) 누락 → 추가 (def:true)
- purchase.request 명칭 "소모품 신청" → "소모품 구매 관리" 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:03:46 +09:00
Hyungi Ahn
52e6ec16f8 fix(dashboard): workers 없는 사용자 부서 권한 조회 수정
getUserInfo에서 workers.department_id만 사용하여
workers 레코드가 없는 사용자(생산지원팀 등)의 department_id가
NULL이 되어 메뉴가 안 보이던 문제.
COALESCE(w.department_id, u.department_id) fallback 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:01:04 +09:00
Hyungi Ahn
cbddf5a7a4 fix(password): 변경 후 쿠키/토큰 삭제 — 자동 재로그인 방지
clearSSOAuth가 api-base.js에 정의되어 있지만 password.html에서
로드하지 않아 호출 안 됨. doLogout의 쿠키+localStorage 삭제 로직을
직접 사용하여 확실히 세션 제거 후 로그인 페이지로 이동.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:57:04 +09:00
Hyungi Ahn
6e3c5d6748 fix(auth): 로그인 에러 표시 + 비밀번호 변경 모바일 수정
- 로그인: errEl.style.display='' → 'block' (CSS display:none과 충돌 해소)
- 비밀번호 변경: ES모듈→일반 스크립트 전환 (모바일 import 체인 실패 대응)
  - api-config.js/config.js/navigation.js 의존 제거
  - tkfb-core.js 전역 함수(getSSOToken) + fetch 직접 사용
  - TODO: ES모듈 import 체인 실패 원인 별도 조사 필요

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:53:47 +09:00
Hyungi Ahn
35b140aa38 fix(password): 에러 메시지 표시 수정 — result.message 우선 읽기
API 응답이 message 필드를 사용하는데 프론트엔드가 error만 읽어서
비밀번호 틀려도 기본 메시지만 표시되던 문제 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:47:08 +09:00
Hyungi Ahn
7cf0614e3b fix(cache): production-dashboard.js 캐시 버스팅 갱신
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:41:53 +09:00
Hyungi Ahn
ca86dc316c fix(nginx): /api/auth/ 프록시 추가 — 비밀번호 변경 API 라우팅
change-password.js가 /api/auth/change-password로 요청하는데
기존 /api/ location이 system1-api로 보내서 404 발생.
/api/auth/ location을 /api/ 앞에 추가하여 sso-auth로 프록시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:38:50 +09:00
Hyungi Ahn
02e9f8c0ab feat(auth): 비밀번호 변경 API 추가 + 대시보드 바로가기
- POST /api/auth/change-password: 현재 비밀번호 검증 후 변경
- POST /api/auth/check-password-strength: 비밀번호 강도 체크
- 대시보드 프로필 카드에 '비밀번호 변경' 바로가기 링크 추가
- 프론트엔드(password.html + change-password.js)는 이미 구현됨

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:34:49 +09:00
Hyungi Ahn
30222df0ef fix: 바텀시트가 하단 네비에 가려지는 문제 — z-index 1010 + padding-bottom 확대
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:26:47 +09:00
Hyungi Ahn
708beb7fe5 fix(nav): '소모품 구매 관리' 메뉴 항목 제거 — 하단 네비 '소모품 신청'으로 통일
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:22:05 +09:00
Hyungi Ahn
bf11ccebf5 fix(nav): 비관리자 네비에 공개 메뉴 항목 표시 + DB 페이지명 정리
- renderNavbar: admin이 아닌 NAV_MENU 항목은 accessibleKeys 없이도 표시
- DB pages: purchase.request → '소모품 구매 관리'(admin), purchase.analysis → '소모품 분석'(admin)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:11:28 +09:00
Hyungi Ahn
59242177d6 fix: 모바일 본문 하단이 네비에 가려지는 문제 — padding-bottom 확대 (140px)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:50:41 +09:00
Hyungi Ahn
7e78f66838 fix: tkfb.css 캐시버스팅 버전 갱신 (v=2026040105)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:49:08 +09:00
Hyungi Ahn
9f181644f9 fix(css): 하단 네비 스타일을 tkfb.css 공통으로 이동
- tbm-mobile.css에서 .m-bottom-nav, .m-nav-item, .m-nav-label 스타일 제거
- tkfb.css에 동일 스타일 추가 (shared-bottom-nav.js 공통 모듈 대응)
- 소모품 모바일 등 모든 페이지에서 하단 네비 정상 렌더링

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:46:49 +09:00
Hyungi Ahn
6cd613c071 feat(purchase): 소모품 사진 기능 — 검색 썸네일 + 신규/기존 품목 마스터 사진 등록
- 모바일 검색 결과에 품목 사진 썸네일 표시 (photo_path 있으면 이미지, 없으면 아이콘)
- 데스크탑 검색 드롭다운에도 사진 썸네일 추가
- 신규 품목 등록 시 사진 촬영 → consumable_items.photo_path에 저장 (bulk API)
- 기존 품목에 사진 없을 때 장바구니에서 "품목 사진 등록" → PUT /consumable-items/:id/photo
- imageUploadService에 consumables 디렉토리 추가
- HEIC 변환 + 폴백 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:31:05 +09:00
Hyungi Ahn
ba2e3481e9 feat(vacation): 이월연차 만료 시스템 + 대시보드 합산 개선
- createBalance/bulkUpsert에서 CARRY_OVER expires_at 자동 설정
  (carry_over_expiry_month 설정 기반, 기본값 2월말)
- deductByPriority/deductDays 쿼리에 만료 필터 추가
  (expires_at >= CURDATE() 조건, 만료된 이월 차감 제외)
- 대시보드 합산에서 만료된 잔액 제외
- DB 보정 완료: CARRY_OVER 8건 expires_at 설정,
  황인용/최광욱 소급 재계산 (이월 우선 차감 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:27:17 +09:00
Hyungi Ahn
58b756d973 fix(dashboard): 연차 상세 모달 하단 네비 가림 수정
모달 시트 padding-bottom에 하단 네비 높이(70px) + safe-area 반영.
pages 테이블에서 work.nonconformity 레코드 DB 직접 삭제 완료.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:56:19 +09:00
Hyungi Ahn
e9ece8c6f1 refactor: 미사용 로컬 부적합 현황 페이지 삭제
NAV_MENU에서 이미 tkqc.technicalkorea.net으로 외부 링크 설정됨.
로컬 nonconformity.html + tkfb-nonconformity.js는 중복 파일이므로 삭제.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:42:16 +09:00
Hyungi Ahn
39c333bb39 fix(nav): 하단 네비에서 TBM/작업보고 제거, 신고 추가
권한 차등 페이지(TBM, 작업보고)는 공통 하단 네비에 부적합.
신고(tkreport.technicalkorea.net) 링크로 대체.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:24:19 +09:00
Hyungi Ahn
bc92b0d5b0 fix(cache): tkfb-core.js 캐시 버스팅 v=2026040102 일괄 갱신 (35개 HTML)
escHtml 충돌 수정이 브라우저 캐시에 반영되지 않는 문제 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:16:17 +09:00
Hyungi Ahn
9efb8c881a fix(auth): TBM 페이지 권한 제어 — restricted 플래그 추가
NAV_MENU의 비admin 페이지가 모두 publicPageKeys에 포함되어
개별 권한(user_page_permissions) 체크가 우회되던 기존 설계 이슈 수정.
- work.tbm에 restricted: true 추가
- publicPageKeys 생성 시 restricted 항목 제외
- restricted 페이지는 DB 개별 권한으로만 접근 제어

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:12:50 +09:00
Hyungi Ahn
661523e963 fix(core): escHtml const 충돌 해소 — 모바일 페이지 긴급 복구
tkfb-core.js의 const escHtml이 4개 JS 파일의 function escHtml()과
전역 스코프에서 충돌하여 SyntaxError 발생, 해당 페이지 JS 전체 미실행.
- tkfb-core.js에서 const escHtml 제거
- schedule.html에서 escHtml → escapeHtml 직접 호출로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:11:29 +09:00
Hyungi Ahn
7c1369a1be feat(purchase): 구매 취소/반품 + 입고일 기준 월별 분석
- 상태 추가: cancelled(구매취소), returned(반품)
- API: PUT /:id/cancel, /:id/return, /:id/revert-cancel
- 데스크탑: 구매완료→취소 버튼, 입고완료→반품 버튼, 취소→되돌리기
- 분석 페이지: 구매일/입고일 기준 전환 토글, 입고일 기준 월간 분류 집계 + 입고 목록
- Settlement API: GET /received-summary, /received-list (입고일 기준)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:07:19 +09:00
Hyungi Ahn
2c032bd9ea fix(tkeg): Dockerfile에 exports/uploads/excel_exports 디렉토리 추가 생성
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:53:02 +09:00
Hyungi Ahn
2308499668 fix(tkeg): Dockerfile에 logs 디렉토리 생성 추가 (non-root 권한 오류 수정)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:51:46 +09:00
Hyungi Ahn
f09c86ee01 fix(security): CRITICAL 보안 이슈 13건 일괄 수정
- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳)
- SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩
- SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지)
- SEC-39: Python Dockerfile 4개 non-root user + chown
- SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함)
- SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized
- QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑
- SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by
- SEC-33: files.py 17개 미인증 엔드포인트 인증 추가
- SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자)
- SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함
- SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리
- SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가
- SEC-63: proxyInputController 에러 메시지 노출 제거
- QA-103: pageAccessRoutes error→message 통일
- SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation
- QA-99: tbm-mobile/create 캐시 버스팅 갱신
- QA-100,101: ESC 키 리스너 cleanup 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:58 +09:00
Hyungi Ahn
766cb90e8f feat(monthly-comparison): detail 페이지 수정요청 내역 표시 + 승인/거부 UI
관리자가 개인 작업자 detail 페이지에서 수정요청(change_request) 내역을 확인하고
승인/거부할 수 있도록 UI 추가. admin 리스트에도 수정 내역 요약 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:21:50 +09:00
Hyungi Ahn
5832755475 fix(purchase): 모바일 네비 수정 + 권한 자동허용 + 장바구니 다중 품목 신청
- sideNav: TBM 패턴(hidden lg:flex + mobile-open) 적용, 회색 오버레이 버그 수정
- 권한: NAV_MENU 기반 publicPageKeys 추출, admin이 아닌 페이지는 비관리자 자동 접근 허용
- 장바구니: 검색→품목 추가→계속 검색→추가, 동일 품목 수량 합산, 품목별 메모
- bulk API: POST /purchase-requests/bulk (트랜잭션, is_new 품목 마스터 등록 포함)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:21:16 +09:00
Hyungi Ahn
41bb755181 fix(monthly-confirm): showToast 재귀 무한루프 수정
로컬 showToast 제거 → tkfb-core.js 전역 함수 직접 사용.
수정요청/확인 완료 시 토스트 정상 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:59:36 +09:00
Hyungi Ahn
5e22ff75e7 feat(monthly-confirm): 캘린더 셀 수정 + 수정요청 워크플로우
- review_sent 상태: 셀 클릭 → 수정 드롭다운 (정시/연차/반차/반반차/조퇴/휴무)
- 변경 시 셀에 "수정" 뱃지 + pendingChanges 임시 저장
- "수정요청" 버튼: 수정 내역 있을 때만 활성화 → POST change_details
- pending 상태: "관리자 검토 대기" 메시지 (수정 불가)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:56:43 +09:00
Hyungi Ahn
dcd40e692f fix(purchase): 네비 명칭/권한 수정 + 검색 재선택 가능하도록 개선
- 네비: '소모품 관리' → '소모품 구매 관리'(admin only), 일반 사용자는 '소모품 신청'만 표시
- 권한: purchase.request_mobile → purchase.request alias 등록 (비관리자 접근 가능)
- 검색: 품목 선택 후 다시 입력하면 이전 선택 자동 해제 (데스크탑+모바일)
- 모바일: selectSearchItem에서 불필요한 API 재호출 제거, 로컬 캐시 활용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:50:15 +09:00
Hyungi Ahn
798cc38945 fix(monthly-comparison): showToast 재귀 + vacation diff + 주말 0h
- showToast: 로컬 함수 제거 → tkfb-core.js 전역 함수 사용 (재귀 무한루프 수정)
- vacation 상태에서도 hours_diff 표시 (반차 8h/4h → 차이: +4h)
- 주말 + 0h 근태 → 근태 행 숨김 (주말로만 표시)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:30:41 +09:00
Hyungi Ahn
cf75462380 feat(purchase): 소모품 신청 시스템 v2 — 모바일 최적화, 스마트 검색, 그룹화, 입고 알림
- 4단계 상태 플로우: pending → grouped → purchased → received
- 한국어 스마트 검색: 초성 매칭(ㅁㅈㄱ→면장갑), 별칭 테이블, 인메모리 캐시
- 모바일 전용 신청 페이지: 바텀시트 UI, FAB, 카드 리스트, 스크롤 페이지네이션
- 인라인 품목 등록: 미등록 품목 검색→등록→신청 단일 트랜잭션
- 관리자 그룹화: 체크박스 다중 선택, 구매 그룹(batch) 생성/일괄 구매/입고
- 입고 처리: 사진+보관위치 등록, 부분 입고 허용, batch 자동 상태 전환
- 알림: notifyHelper에 target_user_ids 추가, 구매진행중/입고완료 시 신청자 ntfy+push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:21:20 +09:00
Hyungi Ahn
0cc37d7773 fix(monthly-comparison): 근태 인라인 수정 — vacation_type_id 응답 추가 + 조퇴 옵션
- API 응답에 attendance_type_id, vacation_type_id, vacation_days 포함
- 드롭다운에 조퇴(id=10) 옵션 추가
- onVacTypeChange에 조퇴→2시간 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:18:31 +09:00
Hyungi Ahn
a5f96dfe17 fix: monthly-comparison CSS 캐시 버스팅 갱신
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:54:12 +09:00
Hyungi Ahn
fdd28d63b2 fix(monthly-comparison): 버튼 깜빡임 제거 + 뱃지 색상 구별
- bottomActions: HTML에서 기본 hidden (JS 로드 전 깜빡임 방지)
- 검토완료: 초록 배경, 확인완료: 진한 초록, 확인요청: 파랑, 수정요청: 주황

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:51:43 +09:00
Hyungi Ahn
c9249da944 fix(monthly-comparison): 미검토→검토완료 뱃지 + 확인버튼 제거
- admin_checked=1: "미검토" → "검토완료" 뱃지(파란색)로 변경
- "✓검토" 별도 뱃지 제거
- "확인 완료/문제 있음" 하단 버튼 항상 숨김 (관리자 페이지에서 불필요)
- 새 상태 뱃지 CSS: admin_checked, review_sent, change_request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:48:50 +09:00
Hyungi Ahn
80eb018caa fix(monthly-comparison): getAllStatus 응답에 admin_checked + 상태 카운트 추가
API 응답 매핑에서 admin_checked, change_details, vacation_days 누락 수정.
summary에 review_sent, change_request 카운트 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:43:02 +09:00
Hyungi Ahn
4309d308bc feat(monthly-comparison): 검토완료 상단 토글 + 월 유지 + 확인요청 조건
- 검토완료 버튼: 하단 제거 → 헤더 토글 ("검토하기" ↔ "✓ 검토완료")
- 상세→목록 복귀: year/month URL 유지 (4월로 리셋 방지)
- 확인요청: 전원 admin_checked 시만 활성화 (정책 변경)
- Sprint 004 PLAN.md: 정책 변경 이력 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:37:19 +09:00
Hyungi Ahn
4bb4fbd225 feat(monthly-comparison): 상세 뷰 작업자 이름 + 검토완료 버튼
- detail 모드: 제목에 "김두수 근무 비교" 작업자 이름 표시
- 하단에 "검토완료" 버튼 → POST /admin-check → 목록 복귀
- 목록에서 ✓검토 뱃지로 구별

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:27:41 +09:00
Hyungi Ahn
10fd65ba9e fix(monthly-comparison): 0일 0h 수정 + 관리자 검토 태깅
- getAllStatus: daily_attendance_records JOIN으로 실제 근무일/시간 집계
- vacation_days: vacation_types.deduct_days SUM (반차 0.5 정확 반영)
- admin_checked 컬럼 + POST /admin-check API (upsert 패턴)
- 상태 뱃지 라벨: 미검토/확인요청/수정요청/반려/확인완료

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:20:27 +09:00
Hyungi Ahn
65e5530a6a feat(sprint005): 월간 확인 워크플로우 — 관리자 확인요청 + 수정요청
- DB: status ENUM 확장 (review_sent, change_request) + reviewed_by/at, change_details
- API: POST /review-send (일괄 확인요청), POST /review-respond (수정 승인/거부)
- 작업자: pending=검토대기, review_sent=확인/수정요청, rejected=동의(재확인)
- 관리자: 필터 탭 확장 + 확인요청 일괄 발송 버튼
- confirm 상태 전환 검증: pending→confirmed 차단

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:09:36 +09:00
Hyungi Ahn
1340918f8e fix(monthly-confirm): 셀 색상 개선 + 연차일수 버그 + 여백 수정
- 정시=흰색, 연차=노랑, 연장=연보라, 휴무=회색, 특근=주황
- 반차 0.5일/반반차 0.25일 정확히 계산 (fallback deductMap)
- 연차현황 하단 여백 80→160px (버튼 겹침 방지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:42:03 +09:00
Hyungi Ahn
798ccc62ad feat(monthly-confirm): 캘린더 UI + 연차 API 수정 + 요약 카드
- 테이블 → 7열 캘린더 그리드 (모바일 최적화)
- 8h=정시, >8h=+연장h, 연차/반차/휴무 텍스트 표시
- 요약 카드: 근무일/연장근로/연차일수
- vacation-balance API → vacation-balances/worker API (sp_vacation_balances 기반)
- "신규" → "부여" 라벨 변경
- 셀 탭 시 하단 상세 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:28:14 +09:00
Hyungi Ahn
0ebe6e5a31 hotfix: collation 충돌 수정 — user_page_permissions JOIN
utf8mb4_unicode_ci vs utf8mb4_general_ci 충돌으로 전체 페이지 접근 불가.
COLLATE utf8mb4_general_ci 명시로 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:10:10 +09:00
Hyungi Ahn
f7adbabb0f fix(permissions): 개인 권한 테이블 불일치 수정
tkuser는 user_page_permissions에 저장하지만 네비/대시보드는
user_page_access에서 읽던 문제. user_page_permissions 기반으로 통일.

- pageAccessRoutes.js: user_page_access → user_page_permissions JOIN
- dashboardModel.js: 개인 권한 쿼리 page_name 기반으로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:02:56 +09:00
Hyungi Ahn
617b6f5c6f fix(monthly-confirm): 무한 로딩 수정 — window.currentUser → getCurrentUser()
tkfb-core.js의 currentUser는 let 모듈 스코프라 window에 안 붙음.
getCurrentUser() 함수 사용으로 전환.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 06:51:26 +09:00
Hyungi Ahn
ca09f89cda feat(tkuser): 월간 근무 확인 페이지 권한 등록
- permissionModel.js: s1.attendance.my_monthly_confirm 추가 (default_access: true)
- tkuser-users.js: 권한 관리 UI에 "월간 근무 확인" 항목 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:58:13 +09:00
Hyungi Ahn
c37ca24788 fix(tbm): tbm-mobile.js 캐시 버스팅 갱신 (v=2026033102)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:55:31 +09:00
Hyungi Ahn
242dca83b5 fix(tbm): "내 TBM 아님" 근본 수정 — currentUser 로드 + id 호환
- tbm-mobile.js: localStorage('sso_user') → getCurrentUser() 전환
- isMySession: currentUser.id도 비교 (tkfb-core가 id만 설정)
- tbm-create.js: leaderId fallback (allWorkers 미로드 대응 + String 비교)
- 마이그레이션: NULL leader_user_id → created_by로 복구

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:27:38 +09:00
Hyungi Ahn
b855ac973a feat(proxy-input): 부적합 대분류/소분류 선택 추가
- 부적합 시간 > 0 → 대분류/소분류 드롭다운 표시
- issue_report_categories (nonconformity) + issue_report_items 연동
- 저장 시 work_report_defects에 category_id, item_id 포함

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:16:56 +09:00
Hyungi Ahn
c71286b52b fix(proxy-input): 공종 드롭다운 필드명 수정
work_types API: id/name 반환 → work_type_id/work_type_name으로 접근하던 오류 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:08:30 +09:00
Hyungi Ahn
7ccec81615 feat(nav): 하단 네비 JS 컴포넌트 분리 + 4개 페이지 통일
- shared-bottom-nav.js: 네비 HTML 동적 생성 + 현재 경로 active 자동 판별
- tbm-mobile.html: 기존 네비 HTML 제거 → 스크립트 로드
- report-create-mobile, my-vacation-info, dashboard-new: 네비 추가
- 홈 → dashboard-new.html, 작업보고 → report-create-mobile.html

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:06:12 +09:00
Hyungi Ahn
f68c66e696 fix(proxy-input): worker_id→user_id 수정 + 공통 입력 UI로 변경
백엔드:
- proxyInputModel 전체 worker_id→user_id 전환
  (작업보고서/휴가 매핑 실패 → 전부 미입력으로 표시되던 문제)

프론트:
- 개별 입력 → 공통 입력 1개로 전환
  프로젝트/공종/시간/부적합 한번 입력 → 선택된 전원에 적용
- 부서별 그룹핑 표시
- 적용 대상 칩 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:57:30 +09:00
Hyungi Ahn
77b66f49ae fix(tbm): 내 TBM 인식 + admin 편집 + 데스크탑 폭 + 하단네비 정리
- isMySession: admin/system/support_team 바이패스 + 중복 비교 제거
- CSS: max-width 480→768px (데스크탑 너무 좁은 문제)
- 하단 네비: 현황/출근 제거 → 연차관리(my-vacation-info) 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:51:23 +09:00
Hyungi Ahn
3cc38791c8 feat(proxy-input): 대리입력 리뉴얼 — 2단계 UI + UPSERT + 부적합
프론트엔드:
- Step 1: 날짜 선택 → 전체 작업자 목록 (완료/미입력/휴가 구분)
- Step 2: 선택 작업자 일괄 편집 (프로젝트/공종/시간/부적합/비고)
- 연차=선택불가, 반차=4h, 반반차=6h 기본값

백엔드:
- POST /api/proxy-input UPSERT 방식 (409 제거)
- 신규: TBM 세션 자동 생성 + 작업보고서 INSERT
- 기존: 작업보고서 UPDATE
- 부적합: work_report_defects INSERT (기존 defect 있으면 SKIP)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:50:04 +09:00
Hyungi Ahn
b8d3a516e1 feat(tbm): TBM 완료 후 작업보고서 페이지 이동 안내
- 완료 처리 성공 → confirm으로 보고서 작성 페이지 이동 제안
- 세션 날짜를 date 파라미터로 전달 (전날 TBM 지연 완료 대응)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:41:13 +09:00
Hyungi Ahn
972fc07f8d fix(proxy-input): showToast 무한재귀 제거 + tbm_sessions 컬럼 추가
- showToast: proxy-input.js 중복 정의 삭제 (tkfb-core.js 것 사용)
  window.showToast가 자기 자신 → 무한 호출 → stack overflow
- DB: tbm_sessions에 safety_notes, work_location 컬럼 추가
  대리입력 INSERT 시 500 에러 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:22:03 +09:00
Hyungi Ahn
ab9e5a46cc fix(tkfb): 모바일 사이드바 z-index 전역 수정
sideNav.mobile-open z-index:999, mobileOverlay z-index:998.
모든 페이지 콘텐츠(z-1000 이하)보다 사이드바가 항상 위에 표시.
tkfb.css 캐시 버스팅 전체 갱신.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:13:18 +09:00
Hyungi Ahn
1c47505b0d fix(tkfb): 작업보고서 모바일 버전 교체 + TBM z-index 수정
- NAV_MENU: report-create.html → report-create-mobile.html
- pages 테이블: work.report_create 경로 변경
- TBM .m-header z-index: 100 → 30 (사이드바 가림 방지)
- tkfb-core.js 캐시 버스팅 전체 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:01:33 +09:00
Hyungi Ahn
71132a1e8d fix(tkfb): 전체 HTML tkfb-core.js 캐시 버스팅 일괄 갱신 + TBM z-index
- 38개 HTML 파일의 tkfb-core.js?v= → 2026033107 통일
  (구버전 캐시로 사이드 네비 메뉴 업데이트 안 되던 문제 해결)
- TBM 모바일 .m-tabs z-index: 90 → 20 (사이드바 가림 방지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:42:11 +09:00
Hyungi Ahn
f728f84117 feat(attendance): 주말+회사 휴무일 통합 처리
- monthlyComparisonModel: getCompanyHolidays 추가
- monthlyComparisonController: isHoliday에 company_holidays 포함 + holiday_name
- proxyInputModel: getDailyStatus에 is_holiday/holiday_name 추가
- proxy-input.js: 휴무일 배너 + both_missing 작업자 비활성화 (특근자는 활성 유지)
- 마이그레이션: 2026년 공휴일 16건 일괄 INSERT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:29:37 +09:00
Hyungi Ahn
5054398f4f fix(tbm): 진입 경로 tbm.html → tbm-mobile.html 교체
구버전(오렌지 헤더) → 신버전(블루 그라데이션, 모바일 최적화)
NAV_MENU + pages 테이블 page_path 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:26:10 +09:00
Hyungi Ahn
755e4142e1 feat(proxy-input): 연차 정보 연동 — 연차 작업자 비활성화 + 뱃지
- 모델: getDailyStatus에 vacation_type 쿼리 추가
- 프론트: 연차(ANNUAL_FULL) 카드 비활성화 + 선택/일괄설정/저장에서 제외
- 반차/반반차/조퇴: 뱃지 표시 + 근무시간 자동 조정 (4h/6h/2h)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:09:05 +09:00
Hyungi Ahn
492843342a feat(monthly-comparison): detail 모드 근태 인라인 편집
- 각 일별 카드에 편집 버튼 (detail 모드 전용)
- 인라인 폼: 근무시간 + 휴가유형 선택
- 저장 → POST /attendance/records (upsert + vacation balance 자동 연동)
- 휴가유형 선택 시 시간 자동 조정 (연차→0, 반차→4, 반반차→6)
- attendance_type_id 자동 결정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:02:16 +09:00
Hyungi Ahn
c9524d9958 fix(proxy-input): initAuth() 호출 추가 — 빈 페이지 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:57:29 +09:00
Hyungi Ahn
b9e3b868bd fix(tkfb): getCurrentUser() 추가 + monthly-comparison 초기화 수정
- tkfb-core.js: getCurrentUser() 글로벌 함수 추가
- monthly-comparison.js: window.currentUser → getCurrentUser() 전환
- 데이터 미로드 문제 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:50:48 +09:00
Hyungi Ahn
df688879a4 fix(tkfb): 메뉴 정리 — 휴가신청→tksupport, 부적합→tkqc 외부 링크
- 휴가 신청: /pages → tksupport.technicalkorea.net (external)
- 부적합 현황: /pages → tkqc.technicalkorea.net (external)
- 소모품 신청 라벨: "생산 소모품 신청"
- PAGE_ICONS에 attendance.monthly_comparison 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:58:58 +09:00
Hyungi Ahn
76e4224b32 feat(sprint004-b): 작업자 월간 확인 페이지 신규 (모바일 전용)
- my-monthly-confirm.html/js/css: 출근부 형식 1인용 확인 페이지
- monthly-comparison.js: 비관리자 → my-monthly-confirm으로 리다이렉트
- 마이그레이션: pages 테이블에 attendance.my_monthly_confirm 등록

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:27:23 +09:00
Hyungi Ahn
01f27948e4 fix(sprint004-b): initAuth() 추가 + getSSOToken + 캐시 버스팅
- monthly-comparison.html: initAuth() 호출 추가 (인증/사용자 정보 로드)
- monthly-comparison.js: 엑셀 다운로드 토큰을 getSSOToken으로 전환
- 캐시 버스팅: ?v=2026033102

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:00:32 +09:00
Hyungi Ahn
d45466ad77 fix(attendance): 월간 출근부 미입사 표시 — hire_date 참조로 수정
join_date(NULL) 대신 hire_date 사용. 입사일 이전 날짜가
회색(미입사)으로 정상 표시됨.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:44:22 +09:00
Hyungi Ahn
f3b7f1a34f fix(sprint004): 코드 리뷰 반영 — vacation_days 소수 + 이중제출 방지 + deprecated 테이블 전환
- monthlyComparisonModel: vacation_types.deduct_days AS vacation_days 추가
- monthlyComparisonController: vacationDays++ → parseFloat(attend.vacation_days) 소수 지원
- monthly-comparison.js: confirmMonth/submitReject 이중 제출 방지 (isProcessing 플래그)
- vacationBalanceModel: create/update/delete/bulkCreate → sp_vacation_balances + balance_type 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:42:12 +09:00
Hyungi Ahn
1980c83377 fix(attendance): 출퇴근 자동생성 시 입사일 체크
initializeDailyRecords()에서 hire_date <= date 조건 추가.
입사일 이전 출퇴근 기록 자동생성 방지.
기존 잘못된 데이터 1건(조승민 1/2) 삭제.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:40:16 +09:00
Hyungi Ahn
f58dd115c9 feat(dashboard): 연차/연장근로 통합 + 연차 상세 모달
- 백엔드: type_code ANNUAL 매칭 실패 → 전체 합산으로 수정
  details에 balance_type, expires_at 포함
- 프론트: 2열 카드 → 통합 리스트 (연차 탭 + 연장근로 행)
- 연차 행 클릭 → 상세 모달 (이월/정기/장기/경조사 breakdown)
  이월 소진/만료 isExpired() 적용
- 내 메뉴에서 "내 연차 정보" 자동 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:26:54 +09:00
Hyungi Ahn
408bf1af62 feat(vacation): 조퇴 연차 차감 처리 (0.75일 = 반차+반반차)
- EARLY_LEAVE vacation type 추가 (deduct_days=0.75)
- work-status: isLeave=true + 동적 vacation_type_id 조회 + 실패 보호
- annual-overview: 월별 요약 테이블에 조퇴 컬럼 추가 + 편집 드롭다운

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:17:32 +09:00
Hyungi Ahn
ec7699b270 fix(attendance): 조퇴 근무시간 0→2시간 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:08:12 +09:00
Hyungi Ahn
0c8801849c fix(tkfb): 휴가 승인 시 sp_vacation_balances 차감 추가
approveRequest()에서 상태만 변경하고 used_days 차감 누락.
deductByPriority 호출 추가 (특별휴가→이월→기본 순서).
기존 데이터 46건 출퇴근 기록 기반 동기화 완료.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:46:42 +09:00
Hyungi Ahn
d96a75adc2 fix(vacation): 경조사 오분류 수정 — type_code 우선 분류 + DB 정리
- 분류 로직: balance_type 대신 type_code 우선 체크 (LONG_SERVICE 등이 경조사로 분류되는 버그)
- 0일 경조사 레코드 필터링 추가
- 잘못된 COMPANY_GRANT 레코드 DELETE 마이그레이션 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:46:26 +09:00
Hyungi Ahn
9cbf4c98a5 fix(vacation): 배정일수 음수 허용 + 특별휴가 우선 차감
- vbTotalDays min="0" 제거 (보정용 음수 입력 허용)
- deductDays 2단계 차감:
  1단계: vacation_type_id 정확 매칭 잔액 우선 (배우자출산 등)
  2단계: 나머지를 이월→기본→추가→장기→회사 순서로

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:32:51 +09:00
Hyungi Ahn
8016237038 fix(vacation): 마이그레이션 WHERE 조건 수정 (deduct_days → type_code)
DECIMAL 정밀도 변환 후 소수 비교가 매칭되지 않는 문제 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:20:31 +09:00
Hyungi Ahn
d16b2f68ba fix(tkuser): 사용자 select에서 u.id → u.user_id 수정
API가 user_id를 반환하는데 u.id로 접근하여 undefined → NaN.
"사용자, 휴가유형, 연도는 필수입니다" 에러 원인.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:20:05 +09:00
Hyungi Ahn
d7408ce603 fix(tkuser): vacation_type_id 빈값 fallback + getVacTypeId 근본 수정
- getVacTypeId(): vacTypes 미로딩 시 DB 고정 ID fallback
  (ANNUAL_FULL→1, CARRYOVER→6, LONG_SERVICE→7)
- form submit: vacation_type_id NaN이면 balance_type 기반 자동 결정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:17:13 +09:00
Hyungi Ahn
9bd3888738 fix(tkuser): 연차 배정 모달 간소화 — 휴가유형 드롭다운 제거
- "휴가 유형" 드롭다운 → hidden (vacation_type_id 자동 설정)
- "배정 유형"이 메인 셀렉터: 기본연차/이월/장기근속/경조사
- balance_type별 vacation_type_id 자동 매핑:
  AUTO/MANUAL→ANNUAL_FULL, CARRY_OVER→CARRYOVER, LONG_SERVICE→LONG_SERVICE
- 경조사(COMPANY_GRANT) 선택 시 서브 드롭다운 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:12:33 +09:00
Hyungi Ahn
9528a544c6 fix(tkuser): 배정/사용 일수 step 0.5 → 0.25 (반반차 대응)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:00:00 +09:00
Hyungi Ahn
c615d0f121 feat(vacation): 이월 연차 소진/만료 구분 표시
- isExpired() 함수: 만료일 다음날부터 만료 (today > expires_at)
- 이월 셀에 만료 시 "소진 X + 만료 Y" 표시
  - 소진: 초록 (#10b981)
  - 만료: 회색 취소선 (#9ca3af)
- loadData()에 carryoverUsed, carryoverExpiresAt 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:55:30 +09:00
Hyungi Ahn
6a721258b8 fix(tksupport): 휴가 차감 우선순위 적용 (이월→기본→추가→장기→회사)
단순 UPDATE → 트랜잭션 + 우선순위 순차 차감으로 변경.
이월 연차부터 먼저 소진되도록 보장.
복원도 역순(회사→장기→추가→기본→이월) 적용.

참조: system1-factory/api/models/vacationBalanceModel.js deductByPriority

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:53:56 +09:00
Hyungi Ahn
53596ba540 fix(vacation): bulkUpsert 저장 테이블 통일 (sp_vacation_balances)
vacation_balance_details에 쓰고 sp_vacation_balances에서 읽는
테이블 불일치 수정. 경조사 등 특별휴가 저장 후 반영 안 되던 문제 해결.

- bulkUpsert: vacation_balance_details → sp_vacation_balances
- balance_type 전달: CARRY_OVER, AUTO, LONG_SERVICE, COMPANY_GRANT
- 기존 경조사 데이터 21건 sp_vacation_balances로 마이그레이션

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:41:55 +09:00
Hyungi Ahn
b67e8f2c9f feat(tkfb): 연간 연차 현황 — 사용자 클릭 시 월별 세부내역 모달
- 사용자 이름 클릭 → 월별 요약 테이블 (연차/반차/반반차/합계)
- 상세 내역: 일자별 사용 기록
- 기존 /attendance/records API 활용 (별도 백엔드 불필요)
- 경조사 모달과 동일 패턴 (overlay, ESC, 배경 클릭 닫기)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:39:49 +09:00
Hyungi Ahn
b2ce691ef9 fix(tkuser): 장기근속 5년 정확 경과 체크 + 수동부여 연도 입력
- autoGrantLongServiceLeave: 연도 차이 → 정확한 기념일 경과 확인
  (today < anniversaryDate이면 스킵)
- 수동 배정 모달에 "배정 연도" 필드 추가 (탭 연도 의존 제거)
- 장기근속 만료일 = null 유지 (기존 동작 그대로)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:26:43 +09:00
Hyungi Ahn
a30482ec34 fix(tkfb): 연차 현황 balance_type 기반 분류 — LONG_SERVICE 정기연차 혼동 수정
같은 type_code(ANNUAL_FULL)에 balance_type이 AUTO와 LONG_SERVICE로
2행 존재 시, type_name='연차'로 매칭되어 LONG_SERVICE가 정기연차를 덮어쓰는 문제.
balance_type 우선 분류로 변경:
- LONG_SERVICE → 장기근속 컬럼
- AUTO/MANUAL → 정기연차 컬럼
- CARRY_OVER → 이월 컬럼
- 값 누적(+=)으로 같은 type의 여러 행 합산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:25:53 +09:00
Hyungi Ahn
71ef40c26c fix(tkfb): 내 연차 정보 페이지 인증 수정 — 쿠키 우선 읽기
axios 토큰과 사용자 정보를 localStorage에서만 읽어서
쿠키 기반 인증 시 API 호출이 안 되던 문제.
getSSOToken()/getSSOUser() 사용하여 쿠키 우선으로 전환.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:53:16 +09:00
Hyungi Ahn
5dee4fd600 fix(attendance): 월간 근태 설명 텍스트 제거 + 상단 바 개선
- 불필요한 설명 텍스트 제거
- 제목+날짜+조회를 한 줄로 통합
- 조회 버튼에 돋보기 아이콘 + 둥근 모서리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:51:35 +09:00
Hyungi Ahn
3c611daa29 feat(tkfb): 연차 차감/복원을 sp_vacation_balances 정본으로 전환
- deductByPriority/restoreByPriority: vacation_balance_details → sp_vacation_balances
- 트랜잭션 + SELECT FOR UPDATE 적용 (tksupport/tkuser 동시 쓰기 안전)
- balance_type 우선순위: CARRY_OVER → AUTO → MANUAL → LONG_SERVICE → COMPANY_GRANT
- deductDays/restoreDays/getAvailableVacationDays도 sp_vacation_balances로 전환
- DB: sp_vacation_balances DECIMAL(4,1) → DECIMAL(5,2) ALTER 완료 (반반차 0.25 지원)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:49:00 +09:00
Hyungi Ahn
666f0f2df4 fix(attendance): 월간 근태 상단 UI 개선
- 날짜+조회 좌측, 휴무일+Excel 우측 분리 배치
- 보조 버튼에 아이콘 추가 + 크기 축소
- 불필요한 요약 통계 카드 제거 (전체인원/총근무시간 등)
- flex-wrap 적용으로 모바일 대응

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:48:10 +09:00
Hyungi Ahn
2357744b02 feat(tkfb): 연차 데이터 정본 전환 — vacation_balance_details → sp_vacation_balances
대시보드 + 연간 연차 현황 페이지의 읽기를 tksupport 정본 테이블로 전환.
- dashboardModel: getVacationBalance worker_id → user_id, sp_vacation_balances
- dashboardController: worker_id 전달 제거, user_id 직접 사용
- vacationBalanceModel: 읽기 함수 3개 sp_vacation_balances로 전환
  (쓰기 함수 deductByPriority 등은 vacation_balance_details 유지)
- remaining_days: STORED GENERATED 대신 (total_days - used_days) AS 계산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:33:18 +09:00
Hyungi Ahn
4dd39ceab7 fix(tkfb): pageAccessRoutes 레거시 users/roles 테이블 → sso_users 전환
users 테이블과 sso_users 테이블의 user_id가 다른 문제 해결.
- 모든 사용자 조회를 sso_users로 전환
- admin 체크를 req.user.role(JWT)로 간소화 → DB 쿼리 제거
- POST/DELETE에 UPSERT 패턴 적용
- is_admin_only 참조 완전 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:32:16 +09:00
Hyungi Ahn
d3cef659ce fix(tkfb): is_admin_only 레거시 필터 제거
과거 권한 시스템 잔재인 is_admin_only 필터를 모든 런타임 코드에서 제거.
현재 체계: admin=모든 페이지, 일반 사용자=권한 부여된 페이지만.
DB에서도 is_admin_only = 0으로 통일 (22건 갱신).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:17:30 +09:00
Hyungi Ahn
5ac7af7b04 fix(tkfb): 페이지 접근 권한에 부서 권한(department_page_permissions) 반영
- department_page_permissions JOIN 추가 (s1. 접두사 자동 매칭)
- 부서/개인 명시적 권한 있으면 is_admin_only 제한 해제
- 우선순위: 개인 권한 > 부서 권한 > is_default_accessible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:11:25 +09:00
Hyungi Ahn
f434b4d66f fix(tkuser): 권한 관리 UI에 누락 페이지 추가
SYSTEM1_PAGES에 소모품(2), 근태(7), 작업(4), 시스템(2) 항목 추가.
기존 '공장 관리' 그룹에서 근태 항목 분리하여 별도 그룹 구성.
캐시 버스팅 갱신.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:27:43 +09:00
Hyungi Ahn
517fef46a9 fix: proxy_input 마이그레이션 누락 등록
20260330_add_proxy_input_fields.sql이 startup 마이그레이션에
등록 안 되어 tbm_sessions.is_proxy_input 컬럼 없어서 500 에러.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:16:17 +09:00
Hyungi Ahn
31adc39d89 fix(tkuser): 소모품 신청/분석 권한 항목 추가 + pages 키 통일
- permissionModel: s1.purchase.request, s1.purchase.analysis 추가
- pages 테이블: dash→underscore 통일 (report-create→report_create 등)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:14:03 +09:00
Hyungi Ahn
e3b7626e07 fix(dashboard+tkuser): s1. 접두사 매칭 + 페이지 목록 동기화
- dashboardModel: department_page_permissions의 s1. 접두사 제거하여
  pages.page_key와 매칭 (두 테이블 명명규칙 차이 처리)
- permissionModel: 신규 페이지 13개 추가 (공정표, 생산회의록,
  대리입력, 입력현황, 근태관리 7종, 부서/알림 관리)
- pages 테이블: 누락 16개 페이지 INSERT (DB 직접 실행)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:04:33 +09:00
Hyungi Ahn
65b2bbe552 fix(dashboard): 권한 있는 페이지만 표시
기본 접근 페이지 전체 추가 로직 제거. 부서 권한 + 개인 권한에
등록된 페이지만 내 메뉴에 노출.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:48:32 +09:00
Hyungi Ahn
46cd98c6ea feat(dashboard): 상단 헤더 제거 + 프로필 카드 내 로그아웃
상단 헤더 바 제거, 프로필 카드 우측 상단에 로그아웃 버튼 배치.
대시보드 페이지에서 깔끔한 레이아웃 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:45:02 +09:00
Hyungi Ahn
eb9266d83a feat(dashboard): tkfb 헤더 추가 + 오렌지 테마 통일
- 상단 오렌지 헤더 (사용자명/아바타/로그아웃/홈 버튼)
- 프로필 카드 그라데이션 blue→orange (#9a3412→#ea580c)
- 기존 tkfb 페이지들과 일관된 UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:36:24 +09:00
Hyungi Ahn
6b584f9881 fix(dashboard): department_page_permissions 스키마 맞춤
page_id(없음) → page_name으로 조회, pages.page_key로 매칭.
실제 DB 구조와 shared/middleware/pagePermission.js 패턴 일치.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:10:30 +09:00
Hyungi Ahn
0afe864ba3 fix(dashboard): pages 테이블에 없는 icon 컬럼 참조 제거
getQuickAccess 쿼리에서 icon 컬럼 제거. 프론트엔드
PAGE_ICONS 상수로 아이콘 매핑하므로 DB 컬럼 불필요.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:05:08 +09:00
Hyungi Ahn
913ab2fcfd fix(tkfb): config/routes.js에 누락된 라우트 3개 등록
dashboard, proxy-input, monthly-comparison 라우트가
실제 사용되는 config/routes.js가 아닌 루트 routes.js에만
등록되어 있어 404 발생. config/routes.js에 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:01:37 +09:00
Hyungi Ahn
60b2fd1b8d fix(dashboard): api() 헬퍼 사용으로 인증 오류 해결
직접 fetch() + 수동 토큰 조합 대신 tkfb-core.js의 api() 헬퍼 사용.
불필요한 getCookie() 함수 제거.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:38:01 +09:00
Hyungi Ahn
1fd6253fbc feat(sprint-004): 월간 비교·확인·정산 백엔드 (Section A) + Mock 해제
Backend:
- monthly_work_confirmations 테이블 마이그레이션
- monthlyComparisonModel: 비교 쿼리 8개 (보고서/근태/확인 병렬 조회)
- monthlyComparisonController: 5 API (my-records/records/confirm/all-status/export)
- 일별 7상태 판정 (match/mismatch/report_only/attend_only/vacation/holiday/none)
- 확인/반려 UPSERT + 반려 시 알림 (단일 트랜잭션)
- 엑셀 2시트 (exceljs) + 헤더 스타일 + 불일치/휴가 행 색상
- support_team+ 권한 체크 (all-status, export)
- exceljs 의존성 추가

Frontend:
- monthly-comparison.js MOCK_ENABLED = false (API 연결)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:26:25 +09:00
Hyungi Ahn
295928c725 fix(dashboard): 토큰 전달 방식 수정 — tkfb-core.js getToken() 활용
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:22:52 +09:00
Hyungi Ahn
7aaac1e334 feat: Sprint 002 리뷰 수정 + Sprint 003 대시보드 API/UI 구현
Sprint 002:
- proxyInput created_by_name 누락 수정
- tbm-mobile 하단 네비에 현황 탭 추가
- proxy-input 저장 버튼 스피너 추가

Sprint 003:
- GET /api/dashboard/my-summary API (연차/연장근로/페이지 통합)
- 생산팀 대시보드 UI (프로필카드 + 아이콘 그리드)
- dashboard-new.html 교체 (기존 .bak 백업)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:12:56 +09:00
Hyungi Ahn
672a7039df feat(sprint-004): 월간 비교·확인·정산 프론트엔드 (Section B)
- monthly-comparison.html: 작업자 뷰 + 관리자 뷰 통합 페이지
- monthly-comparison.js: 일별 비교 카드(7상태), 확인/반려 워크플로우,
  관리자 진행바+필터+엑셀, Mock 데이터 포함
- monthly-comparison.css: 모바일 우선 스타일
- tkfb-core.js: NAV_MENU에 월간 비교·확인 추가
- 권한: role 기반 mode 자동 결정, 일반 작업자 admin 접근 차단
- 상태 전이: pending→confirmed/rejected, rejected→confirmed 재확인 가능
- 엑셀: pending 0명일 때 활성화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:02:48 +09:00
Hyungi Ahn
8683787a01 fix(tkfb): 연차 테이블 헤더 겹침 해결 — sticky 비활성화
tkfb.css의 .data-table thead th { position: sticky; top: 56px }가
첫 번째 데이터 행을 가리는 원인. 이 페이지에서는 sticky 불필요하므로
position: static !important로 오버라이드.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:18:46 +09:00
Hyungi Ahn
658474af71 fix(tkfb): Tailwind preflight 비활성화 — 테이블 빈 행 근본 해결
preflight의 border-style:solid가 border-collapse 테이블에서
빈 행을 생성하는 문제. preflight:false로 비활성화하고
유틸리티 클래스만 사용. 이전 CSS override(!important) 제거.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:15:33 +09:00
Hyungi Ahn
1040adee10 fix(tkfb): 연차 테이블 헤더 겹침 수정 — Tailwind preflight border 충돌
Tailwind CDN preflight가 table/thead/tbody/tr에 border-style:solid를
넣어 border-collapse 모드에서 보이지 않는 border가 공간을 차지함.
table 구조 요소에 border:none !important로 명시적 제거,
th/td에만 border 적용.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:09:14 +09:00
Hyungi Ahn
822c654ce5 fix(tkfb): 연차 테이블 빈 행 수정 — border/spacing 강제 초기화
- border-collapse/spacing !important 적용
- table-layout: fixed로 컬럼 안정화
- border-top 제거 후 thead 첫 행만 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:06:54 +09:00
Hyungi Ahn
eea99359b5 fix(tkfb): 연차 현황 빈 행 제거 + 입력칸 너비 확대
- card/card-body 래퍼 제거 → 테이블 위 빈 행 해소
- num-input: width:55px → min-width:65px; width:auto (음수 소수점 대응)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:04:28 +09:00
Hyungi Ahn
549e78ba61 fix(tkfb): 연차 소수점 표시 개선 — 정수면 17, 소수면 0.75
- DB: vacation_balance_details DECIMAL(4,1) → DECIMAL(5,2)로 변경 (0.25 단위 지원)
- 표시: fmtNum() — 정수면 소수점 없이 (17), 소수면 2자리 (0.75)
- onblur 포맷팅도 동일 규칙 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:27:53 +09:00
Hyungi Ahn
c769fa040d fix(tkfb): 연간 연차 현황 소수점 2자리 표시 + blur 포맷팅
- input value에 .toFixed(2) 적용 (이월/정기연차/장기근속/경조사)
- onblur 핸들러로 수정 후에도 소수점 2자리 유지
- step 0.5 → 0.25로 변경 (0.25일 단위 입력 가능)
- 총 사용 '-' 표시 → 0.00으로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:45:16 +09:00
Hyungi Ahn
afb63e4e94 fix(tkuser): 마이그레이션 멱등성 수정 — FK 중복 생성 방지
ADD COLUMN IF NOT EXISTS 사용, FK ADD CONSTRAINT 제거
(이전 배포에서 이미 생성됨, 재실행 시 errno 121 발생)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:14:21 +09:00
Hyungi Ahn
4d783e47c9 fix(docker): shared 심링크 /usr/shared 추가 — routes depth 3 대응
routes/ 하위 파일에서 ../../../shared/는 /usr/shared를 참조.
기존 /usr/src/shared 심링크만으로 부족. /usr/shared 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:12:38 +09:00
Hyungi Ahn
07a6253692 fix(docker): shared 모듈 경로 심링크 추가 — 4개 서비스 Dockerfile
shared/middleware/pagePermission.js를 ../../../shared/로 참조하면
Docker 컨테이너 내부에서 /usr/src/shared/를 찾아 MODULE_NOT_FOUND 발생.
ln -s /usr/src/app/shared /usr/src/shared 심링크로 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:09:24 +09:00
Hyungi Ahn
b7771f8232 feat(sprint-002): user-management 나머지 12곳 requirePage 전환 완료
- consumableItemRoutes: requireAdmin → requirePage('tkuser.consumables')
- equipmentRoutes: requireAdmin → requirePage('tkuser.equipments')
- partnerRoutes: requireAdminOrPermission → requirePage('tkuser.partners') + 구문 에러 수정
- vendorRoutes: requireAdmin → requirePage('tkuser.vendors')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:02:44 +09:00
Hyungi Ahn
943ed63d77 feat(sprint-002): tkpurchase+tksafety requirePage 전환 완료
- tkpurchase scheduleRoutes: requireAdmin → requirePage('purchasing_schedule')
- tksafety checklistRoutes: requireAdmin → requirePage('safety_checklist')
- tksafety riskRoutes: requireAdmin → requirePage('safety_risk_assessment')
- tksafety visitRequestRoutes: requireAdmin → requirePage('safety_visit_management')
- visitRequestRoutes import 구문 에러 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:59:45 +09:00
Hyungi Ahn
6411eab210 feat(sprint-002): 대리입력 + 일별 현황 대시보드 (Section A+B)
Section A (Backend):
- POST /api/proxy-input: TBM 세션+팀배정+작업보고서 일괄 생성 (트랜잭션)
- GET /api/proxy-input/daily-status: 일별 TBM/보고서 입력 현황
- GET /api/proxy-input/daily-status/detail: 작업자별 상세
- tbm_sessions에 is_proxy_input, proxy_input_by 컬럼 추가
- system1/system2/tkuser requireMinLevel → shared requirePage 전환
- permissionModel에 factory_proxy_input, factory_daily_status 키 등록

Section B (Frontend):
- daily-status.html: 날짜 네비 + 요약 카드 + 필터 탭 + 작업자 리스트 + 바텀시트
- proxy-input.html: 미입력자 카드 + 확장 폼 + 일괄 설정 + 저장
- tkfb-core.js NAV_MENU에 입력 현황/대리입력 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:40:56 +09:00
Hyungi Ahn
66676ac923 feat: shared requirePage 미들웨어 추가 + tksupport 교체
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:25:23 +09:00
Hyungi Ahn
02e39f1102 fix(tkqc): 데스크톱 인라인 편집 뷰에 프로젝트 셀렉트 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:08:10 +09:00
Hyungi Ahn
ac2a2e7eed feat(tkqc): 관리함 이슈 프로젝트 변경 + cause_person 필드명 버그 수정
- 모바일/데스크톱 관리함에서 이슈 소속 프로젝트 변경 가능
- 프로젝트 변경 시 sequence_no 자동 재계산 (DB 함수 사용)
- in_progress 상태에서만 변경 허용 (프론트+백엔드 이중 제한)
- cause_person → responsible_person_detail 필드명 불일치 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:56:20 +09:00
Hyungi Ahn
ea6f7c3013 test(tkeg): 분류기 테스트 수정 — 변경된 반환 키 대응 (8/8 통과)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:08:17 +09:00
Hyungi Ahn
ce47865890 feat(tkeg): 자재 비교 저장 활성화 + 프로젝트 수정 활동 로그 구현
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:51:26 +09:00
Hyungi Ahn
5cae2362cc feat(schedule): 공정표 제품유형 + 표준공정 자동생성
Backend:
- product_types 참조 테이블 + projects.product_type_id FK (tkuser 마이그레이션)
- schedule_entries에 work_type_id, risk_assessment_id, source 컬럼 추가
- schedule_phases에 product_type_id 추가 (phase 오염 방지)
- generateFromTemplate: tksafety 템플릿 기반 공정 자동 생성 (트랜잭션)
- phase 매칭 3단계 우선순위 (전용→범용→신규)
- 간트 데이터 NULL 날짜 guard 추가
- system1 startup 마이그레이션 러너 추가

Frontend:
- tkuser 프로젝트 추가/수정 폼에 제품유형 드롭다운 추가
- 프로젝트 목록에 제품유형 뱃지 표시
- 공정표 툴바에 "표준공정 생성" 버튼 + 모달 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:51:08 +09:00
424 changed files with 12774 additions and 1957 deletions

View File

@@ -74,7 +74,7 @@
```bash ```bash
# DB 접속 (NAS) # DB 접속 (NAS)
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi" ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p\"\$MYSQL_PASSWORD\" hyungi"
# 로그 확인 (NAS) # 로그 확인 (NAS)
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>" ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"

View File

@@ -0,0 +1,154 @@
# Sprint 004: 월간 근무 비교·확인·정산
> 작성: Cowork | 초안: 2026-03-30 | 갱신: 2026-03-31 (출근부 양식 반영)
## 목표
1. **작업보고서 vs 근태관리 비교 페이지**: 그룹장이 입력한 작업보고서와 근태관리 데이터를 일별로 비교하여 불일치를 시각화
2. **월말 확인 프로세스**: 작업자가 자신의 월간 근무 내역을 확인(승인/반려)하는 워크플로우
3. **출근부 엑셀 다운로드**: 전원 확인 완료 시, 업로드된 양식에 맞는 출근부 엑셀 내보내기
4. **연차 잔액 연동**: sp_vacation_balances 기반 신규/사용/잔여 데이터 엑셀 포함
## 현재 구현 상태 (2026-03-31)
| 항목 | 파일 | 상태 | 비고 |
|------|------|------|------|
| DB 마이그레이션 | `api/db/migrations/20260330_create_monthly_work_confirmations.sql` | ✅ 완료 | |
| 모델 | `api/models/monthlyComparisonModel.js` (232줄) | ✅ 완료 | getExportData 추가됨 |
| 컨트롤러 | `api/controllers/monthlyComparisonController.js` (560줄) | ✅ 완료 | exportExcel 출근부 양식 재작성 |
| 라우트 | `api/routes/monthlyComparisonRoutes.js` (33줄) | ✅ 완료 | |
| 라우트 등록 | `api/routes.js` 157줄 | ✅ 완료 | |
| 프론트엔드 HTML | `web/pages/attendance/monthly-comparison.html` | ✅ 완료 | |
| 프론트엔드 JS | `web/js/monthly-comparison.js` (558줄) | ✅ 완료 | |
| 프론트엔드 CSS | `web/css/monthly-comparison.css` | ✅ 완료 | |
| 대시보드 연동 | `web/js/production-dashboard.js` | ✅ 완료 | PAGE_ICONS 추가 |
> 모든 파일이 존재하고 라우트가 등록되어 있으나, **실 서버 배포 및 통합 테스트 미완료**.
## 배경
- monthly.html에서 그룹장이 등록한 근태정보를 각 작업자가 개별 확인·승인
- 전원 confirmed 완료 시 지원팀이 출근부 엑셀 다운로드 가능
- 출근부 양식: 업로드된 `출근부_2026.02_TK 생산팀.xlsx` 기준
- attendanceService.js에서 연차 자동 차감/복원이 이미 구현되어 있으므로 sp_vacation_balances가 소스 오브 트루스
## 아키텍처
```
┌──────────────────────────────────────────────────────┐
│ monthly.html (그룹장 → 근태 입력) │
│ ↓ │
│ daily_attendance_records 저장 │
│ attendanceService.js → sp_vacation_balances 자동 반영 │
└─────────────────┬────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ monthly-comparison.html │
│ │
│ [작업자 뷰] [관리자 뷰] │
│ ├ 작업보고서 vs 근태 일별 비교 ├ 전체 확인 현황 │
│ ├ 요약 카드 (근무일/시간/연장/휴가) ├ 개별 작업자 상세 │
│ └ 확인(승인) / 반려(사유 입력) └ 엑셀 다운로드 │
│ ↓ (반려 시) │
│ notifications → 지원팀에 알림 │
│ ↓ (전원 확인 완료 시) │
│ 출근부 엑셀 다운로드 │
│ (출근부_YYYY.MM_부서명.xlsx) │
└──────────────────────────────────────────────────────┘
```
## 출근부 엑셀 양식 (신규)
기존 2-시트 구조(월간 근무 현황 + 일별 상세)를 **출근부 단일 시트**로 변경.
```
시트명: {YY}.{M}월 출근부
Row 2: 부서명 | [부서명 (병합)]
Row 3: 근로기간 | [YYYY년 M월 (병합)]
Row 4: [이름] [담당] 1 2 3 ... 31 [총시간] [M월(병합)] [비고(병합)]
Row 5: 일 월 화 ... [ ] 신규 사용 잔여
Row 6+: 김두수 1 0 0 0 ... 휴무 =SUM() 15 2 =AF-AG
셀 값 규칙:
정상출근 → 근무시간(숫자, 0.00)
주말(근무없음) → '휴무'
연차(ANNUAL) → '연차'
반차(HALF_ANNUAL) → '반차'
반반차(ANNUAL_QUARTER) → '반반차'
조퇴(EARLY_LEAVE) → '조퇴'
병가(SICK) → '병가'
경조사(SPECIAL) → '경조사'
스타일:
폰트: 맑은 고딕 12pt, 가운데 정렬
주말 헤더: 빨간 폰트 (FFFF0000)
테두리: thin 전체
헤더 행 높이: 40, 데이터 행 높이: 60
수식: 총시간=SUM(날짜열), 잔여=신규-사용
```
## API 엔드포인트
| Method | Path | 접근권한 | 설명 |
|--------|------|---------|------|
| GET | /api/monthly-comparison/my-records | 인증된 사용자 | 내 일별 비교 데이터 |
| GET | /api/monthly-comparison/records | support_team+ 또는 본인 | 특정 작업자 비교 조회 |
| POST | /api/monthly-comparison/confirm | 인증된 사용자 (본인) | 승인/반려 |
| GET | /api/monthly-comparison/all-status | support_team+ | 전체 확인 현황 |
| GET | /api/monthly-comparison/export | support_team+ | 출근부 엑셀 다운로드 |
## 데이터 소스
| 데이터 | 테이블 | 비고 |
|--------|--------|------|
| 작업보고서 | daily_work_reports | report_date, work_hours, project_id |
| 근태 기록 | daily_attendance_records | record_date, total_work_hours, vacation_type_id |
| 확인 상태 | monthly_work_confirmations | 신규 테이블 (Sprint 004) |
| 연차 잔액 | sp_vacation_balances | year별 SUM(total_days, used_days) |
| 휴가 유형 | vacation_types | type_code → 출근부 텍스트 매핑 |
| 작업자 정보 | workers + departments | worker_id 순 정렬 |
## 작업 분할
| 섹션 | 핵심 작업 | 규모 |
|------|----------|------|
| **A (Backend)** | 모델·컨트롤러 검증 + 출근부 엑셀 엣지케이스 + 배포 | 파일 4개, 검증 위주 |
| **B (Frontend)** | UI 최종 검증 + 모바일 반응형 + 캐시 버스팅 + 배포 | 파일 4개, 검증 위주 |
> 기존 코드가 모두 구현되어 있으므로 **검증·배포 중심**으로 진행.
## 섹션 간 의존성
```
A ──(API 제공)──→ B
GET /api/monthly-comparison/my-records
POST /api/monthly-comparison/confirm
GET /api/monthly-comparison/all-status
GET /api/monthly-comparison/records
GET /api/monthly-comparison/export
```
Section A 배포 완료 후 Section B 프론트엔드가 정상 동작 가능.
## 완료 조건
- [x] 마이그레이션 파일 생성 (20260330_create_monthly_work_confirmations.sql)
- [x] 모든 Backend 파일 생성 (model, controller, routes)
- [x] routes.js에 라우트 등록
- [x] 프론트엔드 페이지 생성 (HTML, JS, CSS)
- [x] 대시보드 아이콘 매핑 추가
- [x] 출근부 양식 엑셀 재작성 (getExportData + exportExcel)
- [ ] 실 서버 마이그레이션 실행
- [ ] Docker 빌드·배포
- [ ] 통합 테스트 (작업자 확인 → 지원팀 엑셀 다운로드 전체 플로우)
- [ ] 모바일 반응형 확인
- [ ] 엣지케이스 확인 (데이터 없는 월, 중도 입사자 등)
## 주요 변경 이력
| 날짜 | 변경 내용 |
|------|----------|
| 2026-03-30 | 초안 작성 + Section A/B 코드 구현 |
| 2026-03-31 | 출근부 양식 반영: getExcelData→getExportData, exportExcel 전면 재작성 |
| 2026-03-31 | 버그 수정: export 조건에 rejected 추가, admin detail 모드 버튼 숨김 |
| 2026-04-01 | 워크플로우 확장: pending→review_sent→confirmed/change_request/rejected 상태 추가 |
| 2026-04-01 | 정책 변경: 확인요청 발송 전 전원 admin_checked 필수 (선택적→필수) |

View File

@@ -99,4 +99,16 @@ OLLAMA_TIMEOUT=120
# tkfb.technicalkorea.net → http://tk-gateway:80 # tkfb.technicalkorea.net → http://tk-gateway:80
# tkreport.technicalkorea.net → http://tk-system2-web:80 # tkreport.technicalkorea.net → http://tk-system2-web:80
# tkqc.technicalkorea.net → http://tk-system3-web:80 # tkqc.technicalkorea.net → http://tk-system3-web:80
# -------------------------------------------------------------------
# ntfy 푸시 알림 서버
# -------------------------------------------------------------------
NTFY_BASE_URL=http://ntfy:80
NTFY_PUBLISH_TOKEN=change_this_ntfy_publish_token
NTFY_EXTERNAL_URL=https://ntfy.technicalkorea.net
NTFY_SUB_PASSWORD=change_this_ntfy_subscriber_password
TKFB_BASE_URL=https://tkfb.technicalkorea.net
# -------------------------------------------------------------------
# Cloudflare Tunnel
# -------------------------------------------------------------------
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here

5
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
# pre-commit hook — 로컬 빠른 피드백
# 역할: 커밋 전 보안 검사 (staged 파일만)
# 우회: git commit --no-verify (서버 pre-receive에서 최종 차단됨)
exec "$(git rev-parse --show-toplevel)/scripts/security-scan.sh" --staged

168
.githooks/pre-receive-server.sh Executable file
View File

@@ -0,0 +1,168 @@
#!/bin/bash
# =============================================================================
# pre-receive-server.sh — Gitea 서버용 보안 게이트
# =============================================================================
# 설치: Gitea 웹 관리자 → 저장소 → Settings → Git Hooks → pre-receive
# 또는: cp pre-receive-server.sh $REPO_PATH/custom/hooks/pre-receive
#
# 동작: push 시 변경 내용을 regex 검사, 위반 시 push 차단
# bypass: 커밋 메시지에 [SECURITY-BYPASS: 사유] 포함 시 통과 + 로그
# =============================================================================
set -uo pipefail
# --- 설정 ---
BYPASS_LOG="/data/gitea/security-bypass.log"
ALLOWED_BYPASS_EMAILS="ahn@hyungi.net hyungi@technicalkorea.net"
MEDIUM_THRESHOLD=5
ZERO_REV="0000000000000000000000000000000000000000"
# --- 검출 규칙 (security-scan.sh와 동일, 자체 내장) ---
RULES=(
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
)
EXCLUDE_PATTERNS="node_modules|\.git|__pycache__|package-lock\.json|\.min\.js|\.min\.css"
# --- 메인 ---
while read -r oldrev newrev refname; do
# 브랜치 삭제 시 스킵
if [[ "$newrev" == "$ZERO_REV" ]]; then
continue
fi
# 신규 브랜치
if [[ "$oldrev" == "$ZERO_REV" ]]; then
# 첫 push: 최근 커밋만 검사 (또는 스킵)
echo "[SECURITY] New branch detected — scanning latest commit only"
oldrev=$(git rev-parse "${newrev}~1" 2>/dev/null || echo "$ZERO_REV")
if [[ "$oldrev" == "$ZERO_REV" ]]; then
continue
fi
fi
# --- bypass 확인 ---
BYPASS_FOUND=false
BYPASS_REASON=""
while IFS= read -r msg; do
if echo "$msg" | grep -qP '\[SECURITY-BYPASS:\s*.+\]'; then
BYPASS_FOUND=true
BYPASS_REASON=$(echo "$msg" | grep -oP '\[SECURITY-BYPASS:\s*\K[^\]]+')
elif echo "$msg" | grep -q '\[SECURITY-BYPASS\]'; then
echo "[SECURITY] ERROR: Bypass requires reason: [SECURITY-BYPASS: hotfix 사유]"
exit 1
fi
done < <(git log --format='%s' "$oldrev".."$newrev" 2>/dev/null)
if [[ "$BYPASS_FOUND" == "true" ]]; then
AUTHOR=$(git log -1 --format='%ae' "$newrev" 2>/dev/null || echo "unknown")
# 사용자 제한
ALLOWED=false
for email in $ALLOWED_BYPASS_EMAILS; do
if [[ "$AUTHOR" == "$email" ]]; then
ALLOWED=true
break
fi
done
if [[ "$ALLOWED" != "true" ]]; then
echo "[SECURITY] Bypass not allowed for: $AUTHOR"
exit 1
fi
# 로그 기록
echo "$(date -Iseconds) | user=$AUTHOR | ref=$refname | commits=$oldrev..$newrev | reason=$BYPASS_REASON | TODO=24h내 수정 필수" \
>> "$BYPASS_LOG" 2>/dev/null || true
echo "[SECURITY] ⚠ Bypass accepted — reason: $BYPASS_REASON (logged, 24h 내 수정 필수)"
continue
fi
# --- diff 기반 보안 검사 ---
VIOLATIONS=0
MEDIUM_COUNT=0
OUTPUT=""
DIFF_OUTPUT=$(git diff -U0 --diff-filter=ACMRT "$oldrev" "$newrev" 2>/dev/null || true)
if [[ -z "$DIFF_OUTPUT" ]]; then
continue
fi
CURRENT_FILE=""
CURRENT_LINE=0
while IFS= read -r line; do
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
CURRENT_FILE="${BASH_REMATCH[2]}"
continue
fi
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
CURRENT_FILE="${BASH_REMATCH[1]}"
continue
fi
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
CURRENT_LINE="${BASH_REMATCH[1]}"
continue
fi
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
local_content="${line:1}"
# 제외 패턴
if echo "$CURRENT_FILE" | grep -qEi "$EXCLUDE_PATTERNS" 2>/dev/null; then
CURRENT_LINE=$((CURRENT_LINE + 1))
continue
fi
# 인라인 ignore 체크 + 규칙 검사
for i in "${!RULES[@]}"; do
IFS='|' read -r r_name r_sev r_desc r_pat <<< "${RULES[$i]}"
if echo "$local_content" | grep -qP "$r_pat" 2>/dev/null; then
# 라인 단위 ignore
if echo "$local_content" | grep -qP "security-ignore:\s*$r_name" 2>/dev/null; then
continue
fi
RNUM=$((i + 1))
TRIMMED=$(echo "$local_content" | sed 's/^[[:space:]]*//' | head -c 100)
if [[ "$r_sev" == "CRITICAL" || "$r_sev" == "HIGH" ]]; then
OUTPUT+="$(printf "\n ✗ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
VIOLATIONS=$((VIOLATIONS + 1))
else
OUTPUT+="$(printf "\n ⚠ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
MEDIUM_COUNT=$((MEDIUM_COUNT + 1))
fi
fi
done
CURRENT_LINE=$((CURRENT_LINE + 1))
fi
done <<< "$DIFF_OUTPUT"
TOTAL=$((VIOLATIONS + MEDIUM_COUNT))
if [[ $TOTAL -gt 0 ]]; then
echo ""
echo "[SECURITY] $TOTAL issue(s) found in push to $refname:"
echo "$OUTPUT"
echo ""
if [[ $MEDIUM_COUNT -gt $MEDIUM_THRESHOLD ]]; then
echo "[SECURITY] MEDIUM violations ($MEDIUM_COUNT) exceed threshold ($MEDIUM_THRESHOLD) — blocking"
VIOLATIONS=$((VIOLATIONS + 1))
fi
if [[ $VIOLATIONS -gt 0 ]]; then
echo "Push rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message."
echo ""
exit 1
else
echo "Warnings only ($MEDIUM_COUNT MEDIUM) — push allowed."
fi
fi
done
exit 0

24
.securityignore Normal file
View File

@@ -0,0 +1,24 @@
# =============================================================================
# .securityignore — 보안 스캔 제외 목록
# =============================================================================
# 규칙:
# - 모든 항목에 사유 주석 필수 (없으면 경고)
# - 월 1회 정기 검토 → 불필요 항목 제거
# - 날짜 표기 권장
# =============================================================================
# 스캔 스크립트 자체 (규칙 패턴 포함)
scripts/security-scan.sh # 규칙 정의 자체 (2026-04-10)
.githooks/pre-receive-server.sh # 규칙 정의 자체 (2026-04-10)
# 환경변수 템플릿 (placeholder만 포함)
.env.example # placeholder 값만 (2026-04-10)
# 보안 감사 보고서 (발견된 패턴 인용)
SECURITY-AUDIT-20260402.md # 감사 보고서 인용 (2026-04-10)
SECURITY-FINDINGS-SUMMARY.txt # 감사 요약 인용 (2026-04-10)
SECURITY-CODE-SNIPPETS.md # 코드 스니펫 인용 (2026-04-10)
# 보안 가이드/체크리스트 (규칙 예시 포함)
SECURITY-CHECKLIST.md # 규칙 참조 예시 (2026-04-10)
docs/SECURITY-GUIDE.md # 가이드 예시 코드 (2026-04-10)

View File

@@ -6,7 +6,7 @@
``` ```
[Cloudflare Tunnel] → tk-cloudflared [Cloudflare Tunnel] → tk-cloudflared
├── tkds.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS) ├── tkfb.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리) ├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고) ├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리) ├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
@@ -56,7 +56,7 @@
## SSO 인증 흐름 ## SSO 인증 흐름
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인 1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
2. 토큰 없음/만료 → `tkds.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함) 2. 토큰 없음/만료 → `tkfb.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급 3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`) 4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시 5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
@@ -76,7 +76,7 @@ Gateway(`/shared/`)에서 서빙:
각 서비스의 core.js에서 동적 로딩: 각 서비스의 core.js에서 동적 로딩:
``` ```
프로덕션: https://tkds.technicalkorea.net/shared/notification-bell.js 프로덕션: https://tkfb.technicalkorea.net/shared/notification-bell.js
로컬: http://localhost:30000/shared/notification-bell.js 로컬: http://localhost:30000/shared/notification-bell.js
``` ```
@@ -89,7 +89,7 @@ Gateway(`/shared/`)에서 서빙:
5. `sso-auth-service/config/`에 새 origin 추가 5. `sso-auth-service/config/`에 새 origin 추가
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시) 6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가 7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
8. 로그인 리다이렉트: `tkds.technicalkorea.net/dashboard?redirect=` 패턴 사용 8. 로그인 리다이렉트: `tkfb.technicalkorea.net/dashboard?redirect=` 패턴 사용
## 배포 절차 ## 배포 절차

View File

@@ -39,3 +39,44 @@ System1에는 FastAPI bridge도 있음 (30008, AI 연동용).
git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build <서비스명>" git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build <서비스명>"
``` ```
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조. 상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
## 개발 주의사항 — 실수 기록
### admin 계정으로만 테스트하지 말 것
admin(role=admin/system)은 대부분의 권한 체크를 건너뜀. 반드시 **일반 사용자(role=user) 계정으로 테스트**할 것.
- `pagePermission.js`: admin은 L18에서 `return next()`로 미들웨어 전체 스킵
- `tkfb-core.js initAuth()`: admin은 `accessibleKeys` 조회 자체를 안 함
- **실제 사례 (2026-04-01)**: `getPool()` async 함수에 `await` 누락 → admin만 통과, 일반 사용자 전원 500 에러. admin 계정으로만 테스트하여 배포 전 미발견
### workers 테이블과 sso_users 테이블 불일치
모든 사용자가 `workers` 테이블에 있지 않음 (생산지원팀 등 사무직). department_id 조회 시 반드시 **sso_users fallback** 사용:
```sql
COALESCE(w.department_id, su.department_id) AS department_id
```
- `dashboardModel.js getUserInfo()` — 수정 완료
- `pageAccessRoutes.js` 부서 조회 — 수정 완료
- **신규 코드 작성 시**: workers JOIN 후 department_id 사용하면 동일 버그 재발
### 전역 스코프 const vs function 충돌
`const`로 선언한 변수명과 다른 스크립트 파일의 `function` 선언이 같은 이름이면 `SyntaxError: Identifier already declared` 발생. 해당 스크립트 **전체**가 실행 안 됨.
- **실제 사례 (2026-04-01)**: `tkfb-core.js``const escHtml = escapeHtml;` 추가 → 4개 JS 파일에서 `function escHtml()` 재선언 → 모바일 전체 미작동
- **규칙**: 전역 alias 추가 시 `grep -r "function 함수명" --include="*.js"` 로 충돌 확인 필수
### 캐시 버스팅 누락
JS/CSS 수정 후 HTML의 `?v=YYYYMMDDNN` 갱신을 빠뜨리면 브라우저 캐시로 구버전 실행됨.
- 특히 `tkfb-core.js`는 **35개 HTML**에서 참조 — 일괄 갱신 필수
- `shared-bottom-nav.js`는 5개 HTML에서 참조
- **규칙**: JS/CSS 수정 시 해당 파일을 참조하는 모든 HTML의 버전 갱신. `grep -r "파일명" --include="*.html"` 로 대상 확인
### nginx 프록시 경로 우선순위
nginx location 블록은 **더 구체적인 경로가 먼저** 매칭됨. `/api/auth/``/api/` 뒤에 넣으면 `/api/`가 먼저 잡힘.
- **실제 사례 (2026-04-01)**: `/api/auth/change-password` 요청이 system1-api로 라우팅되어 404. `/api/auth/` location을 `/api/` 앞에 추가하여 해결
## 멀티 에이전트 워크플로우
이 프로젝트는 Cowork(설계/검토) + Claude Code(코딩) 멀티 에이전트 방식을 지원한다.
- **워크플로우 가이드**: `.cowork/WORKFLOW-GUIDE.md`
- **스프린트 계획/스펙**: `.cowork/sprints/sprint-NNN/`
- **에러 기록**: `.cowork/errors/ERROR-LOG.md`
- **템플릿**: `.cowork/templates/`
Claude Code Worker는 자신의 섹션 스펙(section-*.md)만 읽고 작업할 것. 다른 섹션 파일 수정 금지.

View File

@@ -95,7 +95,7 @@ cat "/volume1/Technicalkorea Document/tkfb-package/.env" | grep MYSQL
cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES
# Cloudflare Tunnel 토큰 # Cloudflare Tunnel 토큰
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN
``` ```
--- ---
@@ -112,12 +112,12 @@ ssh hyungi@192.168.0.3
mkdir -p /volume1/docker/backups/$(date +%Y%m%d) mkdir -p /volume1/docker/backups/$(date +%Y%m%d)
# MariaDB 백업 # MariaDB 백업
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkfb_db \ echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkfb_db \
mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \ mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \
/volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql /volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql
# PostgreSQL 백업 # PostgreSQL 백업
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkqc-db \ echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkqc-db \
pg_dumpall -U mproject > \ pg_dumpall -U mproject > \
/volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql /volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql
@@ -167,11 +167,11 @@ rm -rf ../tk-factory-services.bak
# NAS SSH # NAS SSH
# TK-FB 중지 # TK-FB 중지
cd "/volume1/Technicalkorea Document/tkfb-package" cd "/volume1/Technicalkorea Document/tkfb-package"
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
# TKQC 중지 # TKQC 중지
cd /volume1/docker/tkqc/tkqc-package cd /volume1/docker/tkqc/tkqc-package
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
``` ```
### Step 4: 통합 서비스 기동 ### Step 4: 통합 서비스 기동
@@ -180,10 +180,10 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
cd /volume1/docker/tk-factory-services cd /volume1/docker/tk-factory-services
# Docker 이미지 빌드 + 서비스 기동 # Docker 이미지 빌드 + 서비스 기동
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up --build -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up --build -d
# 로그 확인 # 로그 확인
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --tail=50 echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose logs -f --tail=50
``` ```
### Step 5: DB 마이그레이션 ### Step 5: DB 마이그레이션
@@ -196,7 +196,7 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --ta
# (system3-nonconformance/api/migrations/ → PostgreSQL init) # (system3-nonconformance/api/migrations/ → PostgreSQL init)
# 헬스체크 확인 # 헬스체크 확인
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose ps echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose ps
``` ```
### Step 6: Cloudflare Tunnel 설정 ### Step 6: Cloudflare Tunnel 설정
@@ -291,15 +291,15 @@ git log --oneline -10
```bash ```bash
# 통합 서비스 중지 # 통합 서비스 중지
cd /volume1/docker_1/tk-factory-services cd /volume1/docker_1/tk-factory-services
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
# TK-FB 복원 # TK-FB 복원
cd "/volume1/Technicalkorea Document/tkfb-package" cd "/volume1/Technicalkorea Document/tkfb-package"
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
# TKQC 복원 # TKQC 복원
cd /volume1/docker/tkqc/tkqc-package cd /volume1/docker/tkqc/tkqc-package
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
``` ```
--- ---

View File

@@ -206,7 +206,7 @@ TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독
### NAS (192.168.0.3) ### NAS (192.168.0.3)
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/` - TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/` - TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
- SSH: `hyungi` / `fukdon-riwbaq-fiQfy2` - SSH: `hyungi` / `${SSH_PASSWORD}` (비밀번호는 비밀관리 시스템 참조)
--- ---

39
SECURITY-CHECKLIST.md Normal file
View File

@@ -0,0 +1,39 @@
# 보안 PR 체크리스트 — TK Factory Services
> 공통 원칙: `claude-config/memory/feedback_security_pr_checklist.md`
> 자동 검증: `scripts/security-scan.sh` (pre-commit + pre-receive)
## 체크리스트
| # | 카테고리 | 검증 | 확인 항목 | 참조 파일 |
|---|---------|------|----------|----------|
| 1 | 비밀 정보 | **자동** #1,#2 | 코드/문서에 비밀번호·토큰·API키 하드코딩 없음 | `.env.example` |
| 2 | 인증 | 수동 | 모든 라우트에 `requireAuth` 적용 | `shared/middleware/auth.js` |
| 3 | 권한 RBAC | 수동 | 쓰기(POST/PUT/DELETE)에 `requirePage()` 또는 `requireRole()` | `shared/middleware/pagePermission.js` |
| 4 | 입력 검증 | 수동 | path traversal(`../`), 타입, 길이 검증 | `system1-factory/api/utils/validator.js` |
| 5 | 파일 업로드 | 수동 | magic number + 확장자 + MIME + 크기 제한 | `system1-factory/api/utils/fileUploadSecurity.js` |
| 6 | 네트워크 | **자동** #5 | CORS 와일드카드 없음, rate limiting 적용 | `system1-factory/api/config/cors.js` |
| 7 | DB 쿼리 | **자동** #6 | 파라미터화(`?`), `await`, `COALESCE` 패턴 | CLAUDE.md 주의사항 |
| 8 | 에러/로그 | **자동** #7 | 로그에 비밀정보 없음, 스택트레이스 prod 비노출 | `shared/utils/errors.js` |
| 9 | 보안 헤더 | 수동 | CSP, HSTS, X-Frame-Options | `system1-factory/api/config/security.js` |
| 10 | 자동 검증 | **자동** | pre-commit + pre-receive 통과 | `scripts/security-scan.sh` |
## 자동 검출 규칙
| 규칙# | 이름 | 심각도 | 동작 |
|-------|------|--------|------|
| 1 | SECRET_HARDCODE | CRITICAL | 차단 |
| 2 | SECRET_KNOWN | CRITICAL | 차단 |
| 3 | LOCALSTORAGE_AUTH | HIGH | 차단 |
| 4 | INNERHTML_XSS | HIGH | 차단 |
| 5 | CORS_WILDCARD | HIGH | 차단 |
| 6 | SQL_INTERPOLATION | HIGH | 차단 |
| 7 | LOG_SECRET | MEDIUM | 경고 (5개 초과 시 차단) |
| 8 | ENV_HARDCODE | MEDIUM | 경고 (5개 초과 시 차단) |
## 수동 확인 필요 항목 (자동화 한계)
- RBAC 설계 오류 / 인증 흐름
- 비즈니스 로직 / race condition
- third-party dependency 취약점 (`npm audit`)
- 환경변수 값 강도

View File

@@ -3,7 +3,9 @@ WORKDIR /app
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN mkdir -p /app/data COPY --chown=appuser:appuser . .
RUN mkdir -p /app/data && chown appuser:appuser /app/data
EXPOSE 8000 EXPOSE 8000
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -2,6 +2,13 @@ import json
from services.ollama_client import ollama_client from services.ollama_client import ollama_client
def sanitize_user_input(text: str, max_length: int = 500) -> str:
"""사용자 입력 길이 제한 및 정리"""
if not text:
return ""
return str(text)[:max_length].strip()
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다. ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다. 사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats] cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n" category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
safe_text = sanitize_user_input(user_text)
prompt = f"""카테고리 목록: prompt = f"""카테고리 목록:
{category_context} {category_context}
사용자 입력: "{user_text}" 사용자 입력:
<user_input>{safe_text}</user_input>
위 카테고리 목록을 참고하여 JSON으로 응답하세요.""" 위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
async def summarize_report(data: dict) -> dict: async def summarize_report(data: dict) -> dict:
"""최종 신고 내용을 요약""" """최종 신고 내용을 요약"""
prompt = f"""신고 정보: prompt = f"""신고 정보:
- 설명: {data.get('description', '')} <user_input>
- 유형: {data.get('type', '')} - 설명: {sanitize_user_input(data.get('description', ''))}
- 카테고리: {data.get('category', '')} - 유형: {sanitize_user_input(data.get('type', ''))}
- 항목: {data.get('item', '')} - 카테고리: {sanitize_user_input(data.get('category', ''))}
- 위치: {data.get('location', '')} - 항목: {sanitize_user_input(data.get('item', ''))}
- 프로젝트: {data.get('project', '')} - 위치: {sanitize_user_input(data.get('location', ''))}
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
</user_input>
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요.""" 위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""

View File

@@ -19,7 +19,7 @@ services:
- mariadb_data:/var/lib/mysql - mariadb_data:/var/lib/mysql
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql - ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
ports: ports:
- "30306:3306" - "127.0.0.1:30306:3306"
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
timeout: 20s timeout: 20s
@@ -136,7 +136,7 @@ services:
ports: ports:
- "30080:80" - "30080:80"
volumes: volumes:
- ./system1-factory/web:/usr/share/nginx/html:ro - ./system1-factory/web/public:/usr/share/nginx/html:ro
depends_on: depends_on:
system1-api: system1-api:
condition: service_healthy condition: service_healthy
@@ -309,6 +309,7 @@ services:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80} - NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN} - NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net} - NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
- NTFY_SUB_PASSWORD=${NTFY_SUB_PASSWORD}
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net} - TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
@@ -608,7 +609,7 @@ services:
container_name: tk-phpmyadmin container_name: tk-phpmyadmin
restart: unless-stopped restart: unless-stopped
ports: ports:
- "30880:80" - "127.0.0.1:30880:80"
environment: environment:
- PMA_HOST=mariadb - PMA_HOST=mariadb
- PMA_USER=${PMA_USER:-root} - PMA_USER=${PMA_USER:-root}

157
docs/SECURITY-GUIDE.md Normal file
View File

@@ -0,0 +1,157 @@
# 보안 시스템 운영 가이드
## 개요
TK Factory Services에는 2계층 보안 검사 시스템이 적용되어 있습니다.
| 계층 | 위치 | 역할 | 우회 가능 |
|------|------|------|----------|
| pre-commit | 로컬 (개발자 PC) | 빠른 피드백 | `--no-verify` |
| pre-receive | Gitea 서버 | 최종 차단 | `[SECURITY-BYPASS: 사유]`만 |
## 개발 워크플로우
```
코드 작성 → git add → git commit
pre-commit hook
(security-scan.sh --staged)
위반 있으면 → 커밋 차단 + 상세 출력
위반 없으면 → 커밋 성공
git push
pre-receive hook (서버)
(diff 기반 검사)
위반 있으면 → push 차단
위반 없으면 → push 성공
```
## 위반 발생 시 대처
### 에러 메시지 읽기
```
[SECURITY] 2 issue(s) found:
✗ [CRITICAL] #1 SECRET_HARDCODE — 비밀정보 하드코딩
→ src/controllers/auth.js:64
password: 'my-secret-123'
```
- `[CRITICAL]` / `[HIGH]` → 차단됨, 반드시 수정
- `[MEDIUM]` → 경고, 5개 초과 시 차단
- `→ 파일:라인번호` → 수정할 위치
- 아래 줄 → 문제가 된 코드
### 수정 방법 (규칙별)
| 규칙 | 수정 방법 |
|------|----------|
| SECRET_HARDCODE | `process.env.변수명`으로 이동, `.env`에 추가 |
| LOCALSTORAGE_AUTH | HttpOnly 쿠키 또는 Authorization 헤더 사용 |
| INNERHTML_XSS | `textContent` 사용 또는 DOMPurify 적용 |
| CORS_WILDCARD | 허용 도메인을 명시적으로 나열 |
| SQL_INTERPOLATION | 파라미터화 쿼리(`?` placeholder) 사용 |
| LOG_SECRET | 로그에서 비밀정보 제거 |
## bypass 사용법 (긴급 시)
### 형식
```
git commit -m "fix: 긴급 장애 대응 [SECURITY-BYPASS: prod 서비스 다운 긴급 핫픽스]"
```
### 규칙
- **사유 필수**: `[SECURITY-BYPASS]`만으로는 거부됨
- **허용 사용자만**: 운영담당자(ahn@hyungi.net)만 bypass 가능
- **24시간 내 수정**: bypass 후 반드시 보안 이슈 수정 PR 제출
- **로그 기록**: 모든 bypass는 서버에 자동 기록됨
### bypass 후 조치
1. bypass한 코드의 보안 이슈 파악
2. 24시간 내 수정 커밋
3. `security-scan.sh --all`로 전체 검증
## 규칙 추가/수정 방법
### 새 규칙 추가
`scripts/security-scan.sh`의 RULES 배열에 추가:
```bash
'RULE_NAME|SEVERITY|설명|REGEX_PATTERN'
```
예시:
```bash
'EVAL_USAGE|HIGH|eval 사용 위험|eval\s*\('
```
### 같은 규칙을 서버에도 반영
`.githooks/pre-receive-server.sh`의 RULES 배열에도 동일하게 추가.
Gitea 서버의 hook 파일도 업데이트 필요.
## false positive 등록
### 파일 단위 제외
`.securityignore`에 추가 (주석 필수):
```
path/to/file.js # 사유 설명 (날짜)
```
### 라인 단위 제외
소스 코드에 인라인 주석:
```javascript
const pattern = /password/; // security-ignore: SECRET_HARDCODE — regex 패턴 정의
```
### 주의
- 주석 없는 항목은 스캔 시 경고
- 월 1회 `.securityignore` 검토하여 불필요 항목 제거
## 수동 검사
### 전체 프로젝트 스캔
```bash
./scripts/security-scan.sh --all
```
### 엄격 모드 (MEDIUM도 차단)
```bash
./scripts/security-scan.sh --all --strict
```
### 두 커밋 간 비교
```bash
./scripts/security-scan.sh --diff HEAD~5 HEAD
```
## 초기 설정 (새 머신)
```bash
# 1. git hooks 경로 설정
git config core.hooksPath .githooks
# 2. 전체 스캔 확인
./scripts/security-scan.sh --all
# 3. 테스트 (선택)
echo "password: 'test'" >> /tmp/test.js
git add /tmp/test.js
git commit -m "test" # → 차단되어야 함
```
## FAQ
**Q: pre-commit이 너무 느리다**
A: staged 파일만 검사하므로 보통 1초 이내. 파일이 많으면 `--no-verify`로 우회 후 push 시 서버에서 검사.
**Q: false positive가 계속 뜬다**
A: `.securityignore`에 등록하거나 라인에 `// security-ignore: RULE_NAME` 추가.
**Q: 규칙을 비활성화하고 싶다**
A: RULES 배열에서 해당 규칙을 주석 처리. 단, CRITICAL 규칙 비활성화는 비권장.
**Q: 새 서비스 추가 시**
A: 추가 설정 불필요. `.securityignore`에 제외할 파일이 있으면 등록.

View File

@@ -489,7 +489,7 @@
// ===== Card Definitions ===== // ===== Card Definitions =====
var SYSTEM_CARDS = [ var SYSTEM_CARDS = [
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 'dashboard', color: '#1a56db' }, { id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard-new.html', pageKey: 'dashboard', color: '#1a56db' },
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' }, { id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' }, { id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' }, { id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
@@ -781,13 +781,14 @@
var redirect = new URLSearchParams(location.search).get('redirect'); var redirect = new URLSearchParams(location.search).get('redirect');
if (redirect && isSafeRedirect(redirect)) { if (redirect && isSafeRedirect(redirect)) {
window.location.href = redirect; var sep = redirect.indexOf('#') === -1 ? '#' : '&';
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(data.access_token);
} else { } else {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
} }
} catch (err) { } catch (err) {
errEl.textContent = err.message; errEl.textContent = err.message;
errEl.style.display = ''; errEl.style.display = 'block';
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '\uB85C\uADF8\uC778'; btn.textContent = '\uB85C\uADF8\uC778';
@@ -840,7 +841,8 @@
// Already logged in + redirect param // Already logged in + redirect param
var redirect = params.get('redirect'); var redirect = params.get('redirect');
if (redirect && isSafeRedirect(redirect)) { if (redirect && isSafeRedirect(redirect)) {
window.location.href = redirect; var sep = redirect.indexOf('#') === -1 ? '#' : '&';
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(token);
return; return;
} }

View File

@@ -62,7 +62,7 @@
var loginUrl; var loginUrl;
if (hostname.includes('technicalkorea.net')) { if (hostname.includes('technicalkorea.net')) {
loginUrl = window.location.protocol + '//tkds.technicalkorea.net/dashboard'; loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
} else { } else {
// 개발 환경: tkds 포트 (30780) // 개발 환경: tkds 포트 (30780)
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard'; loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';

View File

@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
# =============================================================================
# 웹루트 보안 검증 스크립트
# 배포 후 실행: docker 이미지 내 /usr/share/nginx/html에 허용된 파일만 있는지 확인
# 화이트리스트 방식 — 허용되지 않은 파일이 있으면 FAIL
# =============================================================================
SERVICES=("system1-web" "system2-web" "system3-web")
# 허용 목록 (줄바꿈 구분 — 공백 파일명 안전)
ALLOWED_system1_web="index.html
manifest.json
sw.js
logo.png
components
css
img
js
pages
static"
ALLOWED_system2_web="push-sw.js
css
img
js
pages"
ALLOWED_system3_web="ai-assistant.html
app.html
favicon.ico
issue-view.html
issues-archive.html
issues-dashboard.html
issues-inbox.html
issues-management.html
m
push-sw.js
reports-daily.html
reports-monthly.html
reports-weekly.html
reports.html
static
sw.js
uploads"
FAIL=0
for service in "${SERVICES[@]}"; do
varname="ALLOWED_${service//-/_}"
allowed="${!varname}"
echo "Checking $service..."
# 컨테이너 생성만 (실행 안 함) → exec으로 검사 → 제거
docker compose create --no-deps "$service" >/dev/null 2>&1
container=$(docker compose ps -q "$service" | head -n1)
if [ -z "$container" ]; then
echo " FAIL: container not found for $service"
FAIL=1; continue
fi
entries=$(docker exec "$container" \
find /usr/share/nginx/html -maxdepth 1 -mindepth 1 -printf '%f\n' 2>/dev/null || true)
docker compose rm -f "$service" >/dev/null 2>&1
# 빈 webroot 체크 (COPY public/ 실패 감지)
if [ -z "$entries" ]; then
echo " FAIL: $service webroot is empty"
FAIL=1; continue
fi
while IFS= read -r f; do
[ -z "$f" ] && continue
# -xF: 정확히 일치하는 줄만 (substring 매칭 방지)
if ! echo "$allowed" | grep -qxF "$f"; then
echo " FAIL: unexpected file in webroot → $f"
FAIL=1
fi
done <<< "$entries"
if [ $FAIL -eq 0 ]; then
echo " OK"
fi
done
echo ""
if [ $FAIL -eq 0 ]; then
echo "✓ All web roots clean"
else
echo "✗ Security check FAILED — fix before deploying"
fi
exit $FAIL

View File

@@ -1,208 +0,0 @@
#!/bin/bash
# ===================================================================
# TK Factory Services - 원격 배포 스크립트 (맥북에서 실행)
# ===================================================================
# 사용법: ./scripts/deploy-remote.sh
# 설정: ~/.tk-deploy-config
# ===================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$HOME/.tk-deploy-config"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# === 설정 로드 ===
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
cat <<'EXAMPLE'
다음 내용으로 생성하세요:
NAS_HOST=100.71.132.52
NAS_USER=hyungi
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
NAS_SUDO_PASS=<sudo 비밀번호>
EXAMPLE
exit 1
fi
source "$CONFIG_FILE"
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
if [ -z "${!var}" ]; then
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
exit 1
fi
done
DOCKER="/usr/local/bin/docker"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
# === 헬퍼 함수 ===
ssh_cmd() {
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
}
nas_docker() {
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
}
# === Phase 1: Pre-flight 체크 ===
echo "=== TK Factory Services - 원격 배포 ==="
echo ""
echo -e "${CYAN}[1/5] Pre-flight 체크${NC}"
cd "$PROJECT_DIR"
# Working tree clean 확인
if [ -n "$(git status --porcelain)" ]; then
echo -e "${RED}ERROR: 로컬에 커밋되지 않은 변경사항이 있습니다${NC}"
echo ""
git status --short
echo ""
echo "먼저 커밋하거나 stash하세요."
exit 1
fi
# 로컬 커밋 정보
LOCAL_HASH=$(git rev-parse HEAD)
LOCAL_SHORT=$(git rev-parse --short HEAD)
LOCAL_MSG=$(git log -1 --format='%s')
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# origin 동기화 확인
git fetch origin --quiet
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_HASH" != "$ORIGIN_HASH" ]; then
echo -e "${RED}ERROR: 로컬 커밋이 origin에 push되지 않았습니다${NC}"
echo " 로컬: ${LOCAL_SHORT} (${LOCAL_MSG})"
echo " 원격: $(git rev-parse --short "origin/${LOCAL_BRANCH}" 2>/dev/null || echo 'N/A')"
echo ""
echo "먼저 push하세요: git push origin ${LOCAL_BRANCH}"
exit 1
fi
echo -e " 로컬 HEAD: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
echo -e " 브랜치: ${LOCAL_BRANCH}"
# === Phase 2: NAS 상태 비교 ===
echo ""
echo -e "${CYAN}[2/5] NAS 배포 상태 확인${NC}"
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
if [ -z "$NAS_HASH" ]; then
echo -e "${RED}ERROR: NAS에서 git 정보를 가져올 수 없습니다${NC}"
echo " 경로: ${NAS_DEPLOY_PATH}"
echo " NAS에 git clone이 완료되었는지 확인하세요."
exit 1
fi
NAS_SHORT="${NAS_HASH:0:7}"
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
if [ "$LOCAL_HASH" = "$NAS_HASH" ]; then
echo -e " ${GREEN}이미 최신 버전입니다!${NC} (${NAS_SHORT} - ${NAS_MSG})"
exit 0
fi
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
echo -e " 배포 대상: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
# 배포될 커밋 목록
COMMIT_COUNT=$(git log "${NAS_HASH}..${LOCAL_HASH}" --oneline | wc -l | tr -d ' ')
echo ""
echo "=== 배포될 커밋 (${COMMIT_COUNT}개) ==="
git log "${NAS_HASH}..${LOCAL_HASH}" --oneline --no-decorate
echo ""
read -p "배포를 진행하시겠습니까? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "배포가 취소되었습니다."
exit 0
fi
# === Phase 3: 배포 실행 ===
echo ""
echo -e "${CYAN}[3/5] NAS 코드 업데이트${NC}"
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git reset --hard origin/${DEPLOY_BRANCH}"
UPDATED_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H %s'" 2>/dev/null)
echo -e " ${GREEN}완료${NC}: ${UPDATED_HASH}"
echo ""
echo -e "${CYAN}[4/5] Docker 컨테이너 빌드 및 재시작${NC}"
echo " (빌드에 시간이 걸릴 수 있습니다...)"
echo ""
nas_docker "compose up -d --build"
echo ""
echo " nginx 프록시 컨테이너 재시작 (IP 캐시 갱신)..."
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
# === Phase 4: 배포 후 검증 ===
echo ""
echo -e "${CYAN}[5/5] 배포 검증${NC} (15초 대기 후 health check)"
sleep 15
echo ""
echo "=== Container Status ==="
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
echo ""
echo "=== HTTP Health Check ==="
HEALTH_PASS=0
HEALTH_FAIL=0
check_remote() {
local name="$1"
local path="$2"
local status
status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://localhost:${path}" 2>/dev/null || echo "000")
if [ "$status" -ge 200 ] 2>/dev/null && [ "$status" -lt 400 ] 2>/dev/null; then
printf " %-25s ${GREEN}OK${NC} (%s)\n" "$name" "$status"
((HEALTH_PASS++))
else
printf " %-25s ${RED}FAIL${NC} (%s)\n" "$name" "$status"
((HEALTH_FAIL++))
fi
}
check_remote "Gateway" "30000/"
check_remote "SSO Auth" "30050/health"
check_remote "System 1 API" "30005/api/health"
check_remote "System 1 Web" "30080/"
check_remote "System 1 FastAPI" "30008/health"
check_remote "System 2 API" "30105/api/health"
check_remote "System 2 Web" "30180/"
check_remote "System 3 API" "30200/api/health"
check_remote "System 3 Web" "30280/"
check_remote "tkuser API" "30300/api/health"
check_remote "tkuser Web" "30380/"
check_remote "phpMyAdmin" "30880/"
echo ""
echo " Health: PASS=${HEALTH_PASS} FAIL=${HEALTH_FAIL}"
# === Phase 5: 배포 로그 기록 ===
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
ssh_cmd "echo '${TIMESTAMP} | ${LOCAL_SHORT} | ${LOCAL_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
echo ""
if [ "$HEALTH_FAIL" -gt 0 ]; then
echo -e "${YELLOW}배포 완료 (일부 서비스 health check 실패)${NC}"
echo " 로그 확인: ssh ${NAS_USER}@${NAS_HOST} \"cd ${NAS_DEPLOY_PATH} && echo '...' | sudo -S ${DOCKER} compose logs --tail=50\""
else
echo -e "${GREEN}배포 완료!${NC}"
fi
echo " 버전: ${LOCAL_SHORT} - ${LOCAL_MSG}"

355
scripts/security-scan.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/bin/bash
# =============================================================================
# security-scan.sh — TK Factory Services 보안 스캔 엔진
# =============================================================================
# 용도: pre-commit hook, pre-receive hook, 수동 전체 검사
# 모드:
# --staged staged 파일만 검사 (pre-commit 기본)
# --all 프로젝트 전체 파일 검사
# --diff OLD NEW 두 커밋 간 변경 검사 (pre-receive용)
# --strict MEDIUM도 차단
#
# 커버리지 한계 (PR 리뷰에서 수동):
# - RBAC 설계 오류 / 인증 흐름
# - 비즈니스 로직 / race condition
# - third-party dependency (npm audit 영역)
# - 환경변수 값 강도
# =============================================================================
set -euo pipefail
# --- 색상 ---
RED='\033[0;31m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# --- 설정 ---
MEDIUM_THRESHOLD=5
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IGNORE_FILE="$PROJECT_ROOT/.securityignore"
# --- 검출 규칙: NAME|SEVERITY|설명|PATTERN ---
RULES=(
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
)
# --- 제외 패턴 ---
EXCLUDE_DIRS="node_modules|\.git|__pycache__|\.next|dist|build|coverage"
EXCLUDE_FILES="package-lock\.json|yarn\.lock|\.min\.js|\.min\.css|\.map"
# --- 파싱 함수 ---
parse_rule() {
local rule="$1"
RULE_NAME=$(echo "$rule" | cut -d'|' -f1)
RULE_SEVERITY=$(echo "$rule" | cut -d'|' -f2)
RULE_DESC=$(echo "$rule" | cut -d'|' -f3)
RULE_PATTERN=$(echo "$rule" | cut -d'|' -f4-)
}
# --- .securityignore 로드 ---
load_ignore_list() {
IGNORED_FILES=()
if [[ -f "$IGNORE_FILE" ]]; then
while IFS= read -r line; do
# 빈 줄, 순수 주석 스킵
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# 파일명 추출 (주석 앞부분)
local filepath
filepath=$(echo "$line" | sed 's/#.*$//' | xargs)
[[ -z "$filepath" ]] && continue
# 주석 없는 항목 경고
if ! echo "$line" | grep -q '#'; then
echo -e "${YELLOW}[WARN] .securityignore: '$filepath' 에 사유 주석이 없습니다${NC}" >&2
fi
IGNORED_FILES+=("$filepath")
done < "$IGNORE_FILE"
fi
}
is_ignored_file() {
local file="$1"
for ignored in "${IGNORED_FILES[@]}"; do
[[ "$file" == "$ignored" || "$file" == *"/$ignored" ]] && return 0
done
return 1
}
is_line_ignored() {
local line_content="$1"
local rule_name="$2"
echo "$line_content" | grep -qP "security-ignore:\s*$rule_name" && return 0
return 1
}
# --- diff 파싱 + 검사 ---
scan_diff() {
local diff_input="$1"
local violations=0
local medium_count=0
local current_file=""
local current_line=0
local results=""
while IFS= read -r line; do
# 파일명 추출
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
current_file="${BASH_REMATCH[2]}"
continue
fi
# +++ b/filename
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
current_file="${BASH_REMATCH[1]}"
continue
fi
# hunk header → 라인 번호
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
current_line="${BASH_REMATCH[1]}"
continue
fi
# 추가된 라인만 검사
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
local content="${line:1}" # + 제거
current_line=$((current_line))
# 제외 디렉토리/파일 체크
if echo "$current_file" | grep -qEi "($EXCLUDE_DIRS)" 2>/dev/null; then
current_line=$((current_line + 1))
continue
fi
if echo "$current_file" | grep -qEi "($EXCLUDE_FILES)" 2>/dev/null; then
current_line=$((current_line + 1))
continue
fi
# .securityignore 체크
if is_ignored_file "$current_file"; then
current_line=$((current_line + 1))
continue
fi
# 각 규칙 검사
for i in "${!RULES[@]}"; do
parse_rule "${RULES[$i]}"
if echo "$content" | grep -qP "$RULE_PATTERN" 2>/dev/null; then
# 라인 단위 ignore 체크
if is_line_ignored "$content" "$RULE_NAME"; then
continue
fi
local rule_num=$((i + 1))
local trimmed
trimmed=$(echo "$content" | sed 's/^[[:space:]]*//' | head -c 100)
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$current_file" "$current_line" "$trimmed")"
violations=$((violations + 1))
else
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$current_file" "$current_line" "$trimmed")"
medium_count=$((medium_count + 1))
fi
fi
done
current_line=$((current_line + 1))
fi
done <<< "$diff_input"
# 결과 출력
local total=$((violations + medium_count))
if [[ $total -gt 0 ]]; then
echo ""
echo -e "${BOLD}[SECURITY] ${total} issue(s) found:${NC}"
echo -e "$results"
echo ""
# MEDIUM 임계값 체크
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD) — blocking${NC}"
violations=$((violations + 1))
fi
# strict 모드
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also block${NC}"
violations=$((violations + 1))
fi
if [[ $violations -gt 0 ]]; then
echo -e "${RED}Push/commit rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message.${NC}"
echo ""
return 1
else
echo -e "${YELLOW}Warnings only — commit/push allowed.${NC}"
echo ""
return 0
fi
fi
return 0
}
# --- 전체 파일 검사 (--all 모드) ---
scan_all() {
local violations=0
local medium_count=0
local results=""
load_ignore_list
local files
files=$(find "$PROJECT_ROOT" -type f \
\( -name "*.js" -o -name "*.py" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \
-o -name "*.md" -o -name "*.sql" -o -name "*.yml" -o -name "*.yaml" \
-o -name "*.json" -o -name "*.sh" -o -name "*.html" \) \
! -path "*/node_modules/*" \
! -path "*/.git/*" \
! -path "*/__pycache__/*" \
! -path "*/dist/*" \
! -path "*/build/*" \
! -path "*/coverage/*" \
! -path "*/.claude/worktrees/*" \
! -name "package-lock.json" \
! -name "*.min.js" \
! -name "*.min.css" \
2>/dev/null || true)
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
local relpath="${filepath#$PROJECT_ROOT/}"
# .securityignore 체크
if is_ignored_file "$relpath"; then
continue
fi
for i in "${!RULES[@]}"; do
parse_rule "${RULES[$i]}"
local matches
matches=$(grep -nP "$RULE_PATTERN" "$filepath" 2>/dev/null || true)
[[ -z "$matches" ]] && continue
while IFS= read -r match; do
local linenum content
linenum=$(echo "$match" | cut -d: -f1)
content=$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 100)
# 라인 단위 ignore
local full_line
full_line=$(sed -n "${linenum}p" "$filepath" 2>/dev/null || true)
if is_line_ignored "$full_line" "$RULE_NAME"; then
continue
fi
local rule_num=$((i + 1))
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$relpath" "$linenum" "$content")"
violations=$((violations + 1))
else
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$relpath" "$linenum" "$content")"
medium_count=$((medium_count + 1))
fi
done <<< "$matches"
done
done <<< "$files"
# 결과 출력
local total=$((violations + medium_count))
if [[ $total -gt 0 ]]; then
echo ""
echo -e "${BOLD}[SECURITY] Full scan: ${total} issue(s) found:${NC}"
echo -e "$results"
echo ""
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD)${NC}"
violations=$((violations + 1))
fi
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also count${NC}"
violations=$((violations + 1))
fi
if [[ $violations -gt 0 ]]; then
echo -e "${RED}${violations} blocking violation(s) found.${NC}"
return 1
else
echo -e "${YELLOW}Warnings only (${medium_count} MEDIUM).${NC}"
return 0
fi
else
echo -e "${GREEN}[SECURITY] Full scan: 0 violations found.${NC}"
return 0
fi
}
# --- 메인 ---
main() {
local mode="staged"
local old_rev="" new_rev=""
STRICT_MODE="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--staged) mode="staged"; shift ;;
--all) mode="all"; shift ;;
--diff) mode="diff"; old_rev="$2"; new_rev="$3"; shift 3 ;;
--strict) STRICT_MODE="true"; shift ;;
-h|--help)
echo "Usage: security-scan.sh [--staged|--all|--diff OLD NEW] [--strict]"
echo " --staged Check staged files (default, for pre-commit)"
echo " --all Scan entire project"
echo " --diff Check changes between two commits (for pre-receive)"
echo " --strict Block MEDIUM violations too"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
load_ignore_list
case "$mode" in
staged)
local diff_output
diff_output=$(git diff --cached -U0 --diff-filter=ACMRT 2>/dev/null || true)
if [[ -z "$diff_output" ]]; then
echo -e "${GREEN}[SECURITY] No staged changes to scan.${NC}"
exit 0
fi
scan_diff "$diff_output"
;;
all)
scan_all
;;
diff)
if [[ -z "$old_rev" || -z "$new_rev" ]]; then
echo "Error: --diff requires OLD and NEW revisions"
exit 1
fi
local diff_output
diff_output=$(git diff -U0 --diff-filter=ACMRT "$old_rev" "$new_rev" 2>/dev/null || true)
if [[ -z "$diff_output" ]]; then
echo -e "${GREEN}[SECURITY] No changes to scan.${NC}"
exit 0
fi
scan_diff "$diff_output"
;;
esac
}
main "$@"

View File

@@ -0,0 +1,39 @@
/**
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
*
* Canonical source: shared/frontend/sso-relay.js
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
* system1-factory/web/js/sso-relay.js
* system2-report/web/js/sso-relay.js
* system3-nonconformance/web/static/js/sso-relay.js
* user-management/web/static/js/sso-relay.js
* tkpurchase/web/static/js/sso-relay.js
* tksafety/web/static/js/sso-relay.js
* tksupport/web/static/js/sso-relay.js
*
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
*/
(function() {
var hash = location.hash;
if (!hash || hash.indexOf('_sso=') === -1) return;
var match = hash.match(/[#&]_sso=([^&]*)/);
if (!match) return;
var token = decodeURIComponent(match[1]);
if (!token) return;
// 로컬(1st-party) 쿠키 설정
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
// localStorage 폴백
try { localStorage.setItem('sso_token', token); } catch (e) {}
// URL에서 hash 제거
history.replaceState(null, '', location.pathname + location.search);
})();

View File

@@ -0,0 +1,61 @@
/**
* 페이지 권한 미들웨어 (shared)
* admin/system 역할은 자동 통과, 일반 사용자는 개인/부서 권한 체크
*
* 사용법:
* const { createRequirePage } = require('../../shared/middleware/pagePermission');
* const requirePage = createRequirePage(() => getPool());
* router.get('/some', requirePage('page_name'), controller.handler);
*/
function createRequirePage(getPool) {
return function requirePage(pageName) {
return async (req, res, next) => {
const userId = req.user.user_id || req.user.id;
const role = (req.user.role || '').toLowerCase();
// admin/system 자동 통과
if (role === 'admin' || role === 'system') return next();
try {
const db = typeof getPool === 'function' ? await getPool() : getPool;
// 1. 개인 권한 체크
const [rows] = await db.query(
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
[userId, pageName]
);
if (rows.length > 0) {
return rows[0].can_access
? next()
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
}
// 2. 부서 권한 체크
const [userRows] = await db.query(
'SELECT department_id FROM sso_users WHERE user_id = ?',
[userId]
);
if (userRows.length > 0 && userRows[0].department_id) {
const [deptRows] = await db.query(
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
[userRows[0].department_id, pageName]
);
if (deptRows.length > 0) {
return deptRows[0].can_access
? next()
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
}
}
// 3. 권한 레코드 없음 → 거부
return res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
} catch (err) {
console.error('Permission check error:', err);
return res.status(500).json({ success: false, error: '권한 확인 실패' });
}
};
};
}
module.exports = { createRequirePage };

View File

@@ -9,13 +9,14 @@ const notifyHelper = {
/** /**
* 알림 전송 * 알림 전송
* @param {Object} opts * @param {Object} opts
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system) * @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system, purchase)
* @param {string} opts.title - 알림 제목 * @param {string} opts.title - 알림 제목
* @param {string} [opts.message] - 알림 내용 * @param {string} [opts.message] - 알림 내용
* @param {string} [opts.link_url] - 클릭 시 이동 URL * @param {string} [opts.link_url] - 클릭 시 이동 URL
* @param {string} [opts.reference_type] - 연관 테이블명 * @param {string} [opts.reference_type] - 연관 테이블명
* @param {number} [opts.reference_id] - 연관 레코드 ID * @param {number} [opts.reference_id] - 연관 레코드 ID
* @param {number} [opts.created_by] - 생성자 user_id * @param {number} [opts.created_by] - 생성자 user_id
* @param {number[]} [opts.target_user_ids] - 특정 사용자 직접 알림 (생략 시 type 기반 브로드캐스트)
*/ */
async send(opts) { async send(opts) {
try { try {

View File

@@ -83,11 +83,11 @@ async function login(req, res, next) {
await userModel.updateLastLogin(user.user_id); await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user); const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
const refresh_token = jwt.sign( const refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' }, { user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET, JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN } { expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
); );
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정 // SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
await userModel.updateLastLogin(user.user_id); await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user); const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
res.json({ res.json({
access_token, access_token,
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
return res.status(401).json({ success: false, error: '토큰이 필요합니다' }); return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
} }
const decoded = jwt.verify(token, JWT_SECRET); // TODO: issuer/audience 클레임 검증 추가 검토
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const user = await userModel.findById(decoded.user_id || decoded.id); const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) { if (!user || !user.is_active) {
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' }); return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
@@ -229,7 +230,7 @@ async function me(req, res, next) {
return res.status(401).json({ detail: 'Not authenticated' }); return res.status(401).json({ detail: 'Not authenticated' });
} }
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const user = await userModel.findById(decoded.user_id || decoded.id); const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) { if (!user || !user.is_active) {
return res.status(401).json({ detail: 'User not found or inactive' }); return res.status(401).json({ detail: 'User not found or inactive' });
@@ -261,7 +262,7 @@ async function refresh(req, res, next) {
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' }); return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
} }
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET); const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
if (decoded.type !== 'refresh') { if (decoded.type !== 'refresh') {
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' }); return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
} }
@@ -272,11 +273,11 @@ async function refresh(req, res, next) {
} }
const payload = createTokenPayload(user); const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
const new_refresh_token = jwt.sign( const new_refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' }, { user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET, JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN } { expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
); );
res.json({ res.json({
@@ -365,6 +366,65 @@ async function deleteUser(req, res, next) {
} }
} }
/**
* POST /api/auth/change-password — 본인 비밀번호 변경
*/
async function changePassword(req, res, next) {
try {
const token = extractToken(req);
if (!token) return res.status(401).json({ success: false, message: '인증이 필요합니다' });
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const userId = decoded.user_id || decoded.id;
const user = await userModel.findById(userId);
if (!user || !user.is_active) {
return res.status(401).json({ success: false, message: '유효하지 않은 사용자입니다' });
}
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ success: false, message: '현재 비밀번호와 새 비밀번호를 모두 입력해주세요' });
}
if (newPassword.length < 6) {
return res.status(400).json({ success: false, message: '새 비밀번호는 6자 이상이어야 합니다' });
}
if (currentPassword === newPassword) {
return res.status(400).json({ success: false, message: '새 비밀번호는 현재 비밀번호와 달라야 합니다' });
}
const isValid = await userModel.verifyPassword(currentPassword, user.password_hash);
if (!isValid) {
return res.status(400).json({ success: false, message: '현재 비밀번호가 올바르지 않습니다' });
}
await userModel.update(userId, { password: newPassword });
res.json({ success: true, message: '비밀번호가 변경되었습니다. 다시 로그인해주세요.' });
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, message: '인증이 만료되었습니다' });
}
next(err);
}
}
/**
* POST /api/auth/check-password-strength — 비밀번호 강도 체크
*/
async function checkPasswordStrength(req, res) {
const { password } = req.body;
if (!password) return res.json({ success: true, data: { score: 0, level: 'weak' } });
let score = 0;
if (password.length >= 6) score++;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
const level = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong';
res.json({ success: true, data: { score, level } });
}
/** /**
* Bearer 토큰 또는 쿠키에서 토큰 추출 * Bearer 토큰 또는 쿠키에서 토큰 추출
*/ */
@@ -387,5 +447,7 @@ module.exports = {
getUsers, getUsers,
createUser, createUser,
updateUser, updateUser,
deleteUser deleteUser,
changePassword,
checkPasswordStrength
}; };

View File

@@ -23,15 +23,15 @@ const allowedOrigins = [
'https://tkpurchase.technicalkorea.net', 'https://tkpurchase.technicalkorea.net',
'https://tksafety.technicalkorea.net', 'https://tksafety.technicalkorea.net',
'https://tksupport.technicalkorea.net', 'https://tksupport.technicalkorea.net',
'https://tkds.technicalkorea.net', 'https://tkfb.technicalkorea.net',
]; ];
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380'); allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
} }
app.use(cors({ app.use(cors({
origin: function(origin, cb) { origin: function(origin, cb) {
if (!origin || allowedOrigins.includes(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true); if (!origin || allowedOrigins.includes(origin) || /^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
cb(new Error('CORS blocked: ' + origin)); cb(null, false);
}, },
credentials: true credentials: true
})); }));

View File

@@ -33,6 +33,10 @@ router.get('/me', authController.me);
router.post('/refresh', authController.refresh); router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
// 인증 사용자 엔드포인트
router.post('/change-password', authController.changePassword);
router.post('/check-password-strength', authController.checkPasswordStrength);
// 관리자 엔드포인트 // 관리자 엔드포인트
router.get('/users', requireAdmin, authController.getUsers); router.get('/users', requireAdmin, authController.getUsers);
router.post('/users', requireAdmin, authController.createUser); router.post('/users', requireAdmin, authController.createUser);

View File

@@ -14,6 +14,9 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
# 앱 소스 복사 # 앱 소스 복사
COPY system1-factory/api/ ./ COPY system1-factory/api/ ./
# shared 모듈 심링크 (routes에서 ../../../shared/ 경로 호환)
RUN ln -s /usr/src/app/shared /usr/src/shared && ln -s /usr/src/app/shared /usr/shared
# 로그/업로드 디렉토리 생성 # 로그/업로드 디렉토리 생성
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
RUN chown -R node:node /usr/src/app RUN chown -R node:node /usr/src/app

View File

@@ -14,7 +14,7 @@ const logger = require('../utils/logger');
*/ */
const allowedOrigins = [ const allowedOrigins = [
'https://tkfb.technicalkorea.net', // System 1 (공장관리) 'https://tkfb.technicalkorea.net', // System 1 (공장관리)
'https://tkds.technicalkorea.net', // Gateway/Dashboard 'https://tkfb.technicalkorea.net', // Gateway/Dashboard
'https://tkreport.technicalkorea.net', // System 2 'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3 'https://tkqc.technicalkorea.net', // System 3
'https://tkuser.technicalkorea.net', // User Management 'https://tkuser.technicalkorea.net', // User Management
@@ -50,6 +50,12 @@ const corsOptions = {
return callback(null, true); return callback(null, true);
} }
// *.technicalkorea.net 서브도메인 허용 (인앱 브라우저 대응)
if (/^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin)) {
logger.debug('CORS: technicalkorea.net 서브도메인 허용', { origin });
return callback(null, true);
}
// 개발 환경에서는 모든 localhost 허용 // 개발 환경에서는 모든 localhost 허용
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
if (origin.includes('localhost') || origin.includes('127.0.0.1')) { if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
@@ -64,9 +70,9 @@ const corsOptions = {
return callback(null, true); return callback(null, true);
} }
// 차단 // 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
logger.warn('CORS: 차단된 Origin', { origin }); logger.warn('CORS: 차단된 Origin', { origin });
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`)); callback(null, false);
}, },
/** /**

View File

@@ -53,8 +53,14 @@ function setupRoutes(app) {
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes'); const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const purchaseRoutes = require('../routes/purchaseRoutes'); const purchaseRoutes = require('../routes/purchaseRoutes');
const settlementRoutes = require('../routes/settlementRoutes'); const settlementRoutes = require('../routes/settlementRoutes');
const itemAliasRoutes = require('../routes/itemAliasRoutes');
const purchaseBatchRoutes = require('../routes/purchaseBatchRoutes');
const consumableCategoryRoutes = require('../routes/consumableCategoryRoutes');
const scheduleRoutes = require('../routes/scheduleRoutes'); const scheduleRoutes = require('../routes/scheduleRoutes');
const meetingRoutes = require('../routes/meetingRoutes'); const meetingRoutes = require('../routes/meetingRoutes');
const proxyInputRoutes = require('../routes/proxyInputRoutes');
const monthlyComparisonRoutes = require('../routes/monthlyComparisonRoutes');
const dashboardRoutes = require('../routes/dashboardRoutes');
// Rate Limiters 설정 // Rate Limiters 설정
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
@@ -163,8 +169,14 @@ function setupRoutes(app) {
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청 app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
app.use('/api/purchases', purchaseRoutes); // 구매 내역 app.use('/api/purchases', purchaseRoutes); // 구매 내역
app.use('/api/settlements', settlementRoutes); // 월간 정산 app.use('/api/settlements', settlementRoutes); // 월간 정산
app.use('/api/item-aliases', itemAliasRoutes); // 품목 별칭
app.use('/api/purchase-batches', purchaseBatchRoutes); // 구매 그룹
app.use('/api/consumable-categories', consumableCategoryRoutes); // 소모품 카테고리
app.use('/api/schedule', scheduleRoutes); // 공정표 app.use('/api/schedule', scheduleRoutes); // 공정표
app.use('/api/meetings', meetingRoutes); // 생산회의록 app.use('/api/meetings', meetingRoutes); // 생산회의록
app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황
app.use('/api/monthly-comparison', monthlyComparisonRoutes); // 월간 비교·확인·정산
app.use('/api/dashboard', dashboardRoutes); // 대시보드 개인 요약
app.use('/api', uploadBgRoutes); app.use('/api', uploadBgRoutes);
// Swagger API 문서 // Swagger API 문서

View File

@@ -0,0 +1,62 @@
const ConsumableCategoryModel = require('../models/consumableCategoryModel');
const logger = require('../utils/logger');
const ConsumableCategoryController = {
getAll: async (req, res) => {
try {
const activeOnly = req.query.all !== '1';
const rows = await ConsumableCategoryModel.getAll(activeOnly);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('ConsumableCategory getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
create: async (req, res) => {
try {
const { category_code, category_name, icon, color_bg, color_fg, sort_order } = req.body;
if (!category_code || !category_name) {
return res.status(400).json({ success: false, message: '코드와 이름을 입력해주세요.' });
}
const cat = await ConsumableCategoryModel.create({
categoryCode: category_code, categoryName: category_name,
icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
});
res.status(201).json({ success: true, data: cat, message: '카테고리가 추가되었습니다.' });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ success: false, message: '이미 존재하는 코드입니다.' });
}
logger.error('ConsumableCategory create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
update: async (req, res) => {
try {
const { category_name, icon, color_bg, color_fg, sort_order } = req.body;
const cat = await ConsumableCategoryModel.update(req.params.id, {
categoryName: category_name, icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
});
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
res.json({ success: true, data: cat, message: '카테고리가 수정되었습니다.' });
} catch (err) {
logger.error('ConsumableCategory update error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deactivate: async (req, res) => {
try {
const cat = await ConsumableCategoryModel.deactivate(req.params.id);
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
res.json({ success: true, data: cat, message: '카테고리가 비활성화되었습니다.' });
} catch (err) {
logger.error('ConsumableCategory deactivate error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = ConsumableCategoryController;

View File

@@ -0,0 +1,92 @@
/**
* 대시보드 컨트롤러
* Sprint 003 — 개인 요약 API
*/
const DashboardModel = require('../models/dashboardModel');
const logger = require('../../shared/utils/logger');
const DashboardController = {
/**
* GET /api/dashboard/my-summary
* 연차 잔여 + 월간 연장근로 + 접근 가능 페이지
*/
getMySummary: async (req, res) => {
try {
const userId = req.user.user_id || req.user.id;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// 1단계: 사용자 정보 먼저 조회 (worker_id 필요)
const userInfo = await DashboardModel.getUserInfo(userId);
if (!userInfo) {
return res.status(404).json({ success: false, message: '사용자 정보를 찾을 수 없습니다.' });
}
const departmentId = userInfo.department_id;
const role = userInfo.role;
// 2단계: 나머지 3개 병렬 조회 (연차: sp_vacation_balances from user_id)
const [vacationRows, overtime, quickAccess] = await Promise.all([
DashboardModel.getVacationBalance(userId, year),
DashboardModel.getMonthlyOvertime(userId, year, month),
DashboardModel.getQuickAccess(userId, departmentId, role)
]);
// 연차 응답 가공
const details = vacationRows.map(v => ({
type_name: v.type_name,
type_code: v.type_code,
balance_type: v.balance_type || 'AUTO',
expires_at: v.expires_at || null,
total: parseFloat(v.total_days) || 0,
used: parseFloat(v.used_days) || 0,
remaining: parseFloat(v.remaining_days) || 0
}));
// 만료되지 않은 balance만 합산 (만료된 이월연차 제외)
const today = new Date().toISOString().substring(0, 10);
const activeRows = vacationRows.filter(v => !v.expires_at || v.expires_at >= today);
const totalDays = activeRows.reduce((s, v) => s + (parseFloat(v.total_days) || 0), 0);
const usedDays = activeRows.reduce((s, v) => s + (parseFloat(v.used_days) || 0), 0);
const remainingDays = totalDays - usedDays;
res.json({
success: true,
data: {
user: {
user_id: userInfo.user_id,
name: userInfo.name,
worker_name: userInfo.worker_name || userInfo.name,
job_type: userInfo.job_type || '',
department_name: userInfo.department_name,
department_id: userInfo.department_id,
role: userInfo.role
},
vacation: {
year,
total_days: totalDays,
used_days: usedDays,
remaining_days: remainingDays,
details
},
overtime: {
year,
month,
total_overtime_hours: parseFloat(overtime.total_overtime_hours) || 0,
overtime_days: parseInt(overtime.overtime_days) || 0,
total_work_days: parseInt(overtime.total_work_days) || 0,
total_work_hours: parseFloat(overtime.total_work_hours) || 0,
avg_daily_hours: parseFloat(parseFloat(overtime.avg_daily_hours || 0).toFixed(1))
},
quick_access: quickAccess
}
});
} catch (err) {
logger.error('대시보드 요약 조회 오류:', err);
res.status(500).json({ success: false, message: '대시보드 데이터 조회 중 오류가 발생했습니다.', error: err.message });
}
}
};
module.exports = DashboardController;

View File

@@ -0,0 +1,47 @@
const ItemAliasModel = require('../models/itemAliasModel');
const koreanSearch = require('../utils/koreanSearch');
const logger = require('../utils/logger');
const ItemAliasController = {
getAll: async (req, res) => {
try {
const rows = await ItemAliasModel.getAll();
res.json({ success: true, data: rows });
} catch (err) {
logger.error('ItemAlias getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
create: async (req, res) => {
try {
const { item_id, alias_name } = req.body;
if (!item_id || !alias_name || !alias_name.trim()) {
return res.status(400).json({ success: false, message: '품목 ID와 별칭을 입력해주세요.' });
}
const id = await ItemAliasModel.create(item_id, alias_name);
koreanSearch.clearCache();
res.status(201).json({ success: true, data: { alias_id: id }, message: '별칭이 등록되었습니다.' });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ success: false, message: '이미 등록된 별칭입니다.' });
}
logger.error('ItemAlias create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
delete: async (req, res) => {
try {
const deleted = await ItemAliasModel.delete(req.params.id);
if (!deleted) return res.status(404).json({ success: false, message: '별칭을 찾을 수 없습니다.' });
koreanSearch.clearCache();
res.json({ success: true, message: '별칭이 삭제되었습니다.' });
} catch (err) {
logger.error('ItemAlias delete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = ItemAliasController;

View File

@@ -0,0 +1,624 @@
// controllers/monthlyComparisonController.js — 월간 비교·확인·정산
const Model = require('../models/monthlyComparisonModel');
const logger = require('../utils/logger');
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
// 일별 비교 상태 판정
function determineStatus(report, attendance, isHoliday) {
const hasReport = report && report.total_hours > 0;
const hasAttendance = attendance && attendance.total_work_hours > 0;
const isVacation = attendance && attendance.vacation_type_id;
if (isHoliday && !hasReport && !hasAttendance) return 'holiday';
if (isVacation) return 'vacation';
if (!hasReport && !hasAttendance) return 'none';
if (hasReport && !hasAttendance) return 'report_only';
if (!hasReport && hasAttendance) return 'attend_only';
const diff = Math.abs(report.total_hours - attendance.total_work_hours);
return diff <= 0.5 ? 'match' : 'mismatch';
}
// 날짜별 비교 데이터 생성
async function buildComparisonData(userId, year, month) {
const [reports, attendances, confirmation, holidays] = await Promise.all([
Model.getWorkReports(userId, year, month),
Model.getAttendanceRecords(userId, year, month),
Model.getConfirmation(userId, year, month),
Model.getCompanyHolidays(year, month)
]);
// 날짜 맵 생성
const reportMap = {};
reports.forEach(r => {
const key = r.report_date instanceof Date
? r.report_date.toISOString().split('T')[0]
: String(r.report_date).split('T')[0];
reportMap[key] = r;
});
const attendMap = {};
attendances.forEach(a => {
const key = a.record_date instanceof Date
? a.record_date.toISOString().split('T')[0]
: String(a.record_date).split('T')[0];
attendMap[key] = a;
});
// 해당 월의 모든 날짜 생성
const daysInMonth = new Date(year, month, 0).getDate();
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
const dailyRecords = [];
let totalWorkDays = 0, totalWorkHours = 0, totalOvertimeHours = 0;
let vacationDays = 0, mismatchCount = 0;
const mismatchDetails = { hours_diff: 0, missing_report: 0, missing_attendance: 0 };
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayOfWeek = date.getDay();
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6 || holidays.dateSet.has(dateStr);
const report = reportMap[dateStr] || null;
const attend = attendMap[dateStr] || null;
const status = determineStatus(report, attend, isHoliday);
let hoursDiff = 0;
if (report && attend && report.total_hours && attend.total_work_hours) {
hoursDiff = parseFloat((report.total_hours - attend.total_work_hours).toFixed(2));
}
// 통계
if (status === 'match' || status === 'mismatch') {
totalWorkDays++;
totalWorkHours += parseFloat(attend?.total_work_hours || report?.total_hours || 0);
}
if (status === 'report_only') { totalWorkDays++; totalWorkHours += parseFloat(report.total_hours || 0); }
if (status === 'attend_only') { totalWorkDays++; totalWorkHours += parseFloat(attend.total_work_hours || 0); }
if (status === 'vacation' && attend) { vacationDays += parseFloat(attend.vacation_days) || 1; }
if (status === 'mismatch') { mismatchCount++; mismatchDetails.hours_diff++; }
if (status === 'report_only') { mismatchCount++; mismatchDetails.missing_attendance++; }
if (status === 'attend_only') { mismatchCount++; mismatchDetails.missing_report++; }
// 연장근로: 8h 초과분
if (attend && attend.total_work_hours > 8) {
totalOvertimeHours += parseFloat(attend.total_work_hours) - 8;
}
dailyRecords.push({
date: dateStr,
day_of_week: DAYS_KR[dayOfWeek],
is_holiday: isHoliday,
holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null),
work_report: report ? {
total_hours: parseFloat(report.total_hours),
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
} : null,
attendance: attend ? {
total_work_hours: parseFloat(attend.total_work_hours),
attendance_type: attend.attendance_type_name || '',
attendance_type_id: attend.attendance_type_id || null,
vacation_type: attend.vacation_type_name || null,
vacation_type_id: attend.vacation_type_id || null,
vacation_days: attend.vacation_days ? parseFloat(attend.vacation_days) : null
} : null,
status,
hours_diff: hoursDiff
});
}
return {
summary: {
total_work_days: totalWorkDays,
total_work_hours: parseFloat(totalWorkHours.toFixed(2)),
total_overtime_hours: parseFloat(totalOvertimeHours.toFixed(2)),
vacation_days: vacationDays,
mismatch_count: mismatchCount,
mismatch_details: mismatchDetails
},
confirmation: confirmation ? {
status: confirmation.status,
confirmed_at: confirmation.confirmed_at,
rejected_at: confirmation.rejected_at,
reject_reason: confirmation.reject_reason,
change_details: confirmation.change_details || null,
admin_checked: confirmation.admin_checked || 0
} : { status: 'pending', confirmed_at: null, reject_reason: null, change_details: null, admin_checked: 0 },
daily_records: dailyRecords
};
}
const MonthlyComparisonController = {
// GET /my-records
getMyRecords: async (req, res) => {
try {
const userId = req.user.user_id || req.user.id;
const { year, month } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const worker = await Model.getWorkerInfo(userId);
const data = await buildComparisonData(userId, parseInt(year), parseInt(month));
res.json({
success: true,
data: {
user: worker || { user_id: userId },
period: { year: parseInt(year), month: parseInt(month) },
...data
}
});
} catch (err) {
logger.error('monthlyComparison getMyRecords error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /records (관리자용)
getRecords: async (req, res) => {
try {
const { year, month, user_id } = req.query;
if (!year || !month || !user_id) return res.status(400).json({ success: false, message: 'year, month, user_id 필수' });
const reqUserId = req.user.user_id || req.user.id;
const targetUserId = parseInt(user_id);
// 본인 아니면 support_team 이상 필요
if (targetUserId !== reqUserId && !ADMIN_ROLES.includes(req.user.role)) {
return res.status(403).json({ success: false, message: '접근 권한이 없습니다.' });
}
const worker = await Model.getWorkerInfo(targetUserId);
const data = await buildComparisonData(targetUserId, parseInt(year), parseInt(month));
res.json({
success: true,
data: {
user: worker || { user_id: targetUserId },
period: { year: parseInt(year), month: parseInt(month) },
...data
}
});
} catch (err) {
logger.error('monthlyComparison getRecords error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /confirm
confirm: async (req, res) => {
try {
const userId = req.user.user_id || req.user.id;
const { year, month, status, reject_reason } = req.body;
if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' });
if (!['confirmed', 'change_request'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'change_request'만 허용" });
const change_details = req.body.change_details || null;
if (status === 'change_request' && !change_details) {
return res.status(400).json({ success: false, message: '수정 내용을 입력해주세요.' });
}
// 요약 통계 계산
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
let notificationData = null;
if (status === 'change_request') {
const worker = await Model.getWorkerInfo(userId);
const recipients = await Model.getSupportTeamUsers();
notificationData = {
recipients,
title: '월간 근무 수정요청',
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}${month}월 근무 내역 수정을 요청했습니다.`,
linkUrl: `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${year}&month=${month}`,
createdBy: userId
};
}
const result = await Model.upsertConfirmation({
user_id: userId, year: parseInt(year), month: parseInt(month), status,
reject_reason: null,
change_details: change_details ? JSON.stringify(change_details) : null,
...compData.summary
}, notificationData);
if (result.error) return res.status(409).json({ success: false, message: result.error });
const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '수정요청이 접수되었습니다. 관리자에게 알림이 전달됩니다.';
res.json({ success: true, data: result, message: msg });
} catch (err) {
logger.error('monthlyComparison confirm error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /review-send (관리자 → 확인요청 발송)
reviewSend: async (req, res) => {
try {
const { year, month, user_id } = req.body;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const reviewedBy = req.user.user_id || req.user.id;
const userIds = user_id ? [parseInt(user_id)] : null;
const result = await Model.bulkReviewSend(parseInt(year), parseInt(month), userIds, reviewedBy);
if (result.error) return res.status(400).json({ success: false, message: result.error });
res.json({ success: true, data: result, message: `${result.count}명에게 확인요청을 발송했습니다.` });
} catch (err) {
logger.error('monthlyComparison reviewSend error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /review-respond (관리자 → 수정요청 응답)
reviewRespond: async (req, res) => {
try {
const { user_id, year, month, action, reject_reason } = req.body;
if (!user_id || !year || !month || !action) return res.status(400).json({ success: false, message: 'user_id, year, month, action 필수' });
if (!['approve', 'reject'].includes(action)) return res.status(400).json({ success: false, message: "action은 'approve' 또는 'reject'" });
if (action === 'reject' && (!reject_reason || !reject_reason.trim())) {
return res.status(400).json({ success: false, message: '거부 사유를 입력해주세요.' });
}
const respondedBy = req.user.user_id || req.user.id;
const result = await Model.reviewRespond(parseInt(user_id), parseInt(year), parseInt(month), action, reject_reason, respondedBy);
if (result.error) return res.status(409).json({ success: false, message: result.error });
const msg = action === 'approve' ? '수정 승인 완료. 작업자에게 재확인 요청됩니다.' : '수정요청이 거부되었습니다.';
res.json({ success: true, data: result, message: msg });
} catch (err) {
logger.error('monthlyComparison reviewRespond error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /admin-check (관리자 개별 검토 태깅)
adminCheck: async (req, res) => {
try {
const { user_id, year, month, checked } = req.body;
if (!user_id || !year || !month) return res.status(400).json({ success: false, message: 'user_id, year, month 필수' });
const checkedBy = req.user.user_id || req.user.id;
const result = await Model.adminCheck(parseInt(user_id), parseInt(year), parseInt(month), !!checked, checkedBy);
res.json({ success: true, data: result, message: checked ? '검토완료 표시' : '검토 해제' });
} catch (err) {
logger.error('monthlyComparison adminCheck error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /all-status (support_team+)
getAllStatus: async (req, res) => {
try {
const { year, month, department_id } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const workers = await Model.getAllStatus(parseInt(year), parseInt(month), department_id ? parseInt(department_id) : null);
let confirmed = 0, pending = 0, rejected = 0, review_sent = 0, change_request = 0;
workers.forEach(w => {
if (w.status === 'confirmed') confirmed++;
else if (w.status === 'rejected') rejected++;
else if (w.status === 'review_sent') review_sent++;
else if (w.status === 'change_request') change_request++;
else pending++;
});
res.json({
success: true,
data: {
period: { year: parseInt(year), month: parseInt(month) },
summary: { total_workers: workers.length, confirmed, pending, rejected, review_sent, change_request },
workers: workers.map(w => ({
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
department_name: w.department_name, status: w.status || 'pending',
confirmed_at: w.confirmed_at, reject_reason: w.reject_reason,
change_details: w.change_details || null,
admin_checked: w.admin_checked || 0,
total_work_days: w.total_work_days || 0,
total_work_hours: parseFloat(w.total_work_hours || 0),
total_overtime_hours: parseFloat(w.total_overtime_hours || 0),
vacation_days: parseFloat(w.vacation_days || 0),
mismatch_count: w.mismatch_count || 0
}))
}
});
} catch (err) {
logger.error('monthlyComparison getAllStatus error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /export (support_team+, 전원 confirmed일 때만)
// 출근부 양식 — 업로드된 템플릿 매칭
exportExcel: async (req, res) => {
try {
const { year, month } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const y = parseInt(year), m = parseInt(month);
// 전원 confirmed 체크
const statusList = await Model.getAllStatus(y, m);
const notConfirmed = statusList.filter(w => !w.status || w.status !== 'confirmed');
if (notConfirmed.length > 0) {
const pendingCount = notConfirmed.filter(w => !w.status || w.status === 'pending').length;
const rejectedCount = notConfirmed.filter(w => w.status === 'rejected').length;
const parts = [];
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
return res.status(403).json({ success: false, message: `${parts.join(', ')} 상태입니다. 전원 확인 완료 후 다운로드 가능합니다.` });
}
// 데이터 조회
const { workers, attendance, vacations } = await Model.getExportData(y, m);
if (workers.length === 0) {
return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' });
}
// 근태 맵 구성: { user_id -> { day -> record } }
const attendMap = {};
attendance.forEach(a => {
const uid = a.user_id;
if (!attendMap[uid]) attendMap[uid] = {};
const d = a.record_date instanceof Date ? a.record_date : new Date(a.record_date);
const day = d.getDate();
attendMap[uid][day] = a;
});
// 연차 맵: { user_id -> { total, used, remaining } }
const vacMap = {};
vacations.forEach(v => { vacMap[v.user_id] = v; });
// 월 정보
const daysInMonth = new Date(y, m, 0).getDate();
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
const yy = String(y).slice(-2);
const deptName = workers[0]?.department_name || '생산팀';
// 휴가 유형 → 출근부 텍스트 매핑
const VAC_TEXT = {
'ANNUAL': '연차', 'HALF_ANNUAL': '반차', 'ANNUAL_QUARTER': '반반차',
'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SPECIAL': '경조사'
};
const ExcelJS = require('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`${yy}.${m}월 출근부`);
// ── 기본 스타일 ──
const FONT = { name: '맑은 고딕', size: 12 };
const CENTER = { horizontal: 'center', vertical: 'middle', wrapText: true };
const THIN_BORDER = {
top: { style: 'thin' }, bottom: { style: 'thin' },
left: { style: 'thin' }, right: { style: 'thin' }
};
const RED_FONT = { ...FONT, color: { argb: 'FFFF0000' } };
// ── 열 폭 설정 ──
// A=이름(10), B=담당(6), C~(daysInMonth)=날짜열(5), 총시간(8), 신규(8), 사용(8), 잔여(8), 비고(14)
const dayColStart = 3; // C열 = col 3
const dayColEnd = dayColStart + daysInMonth - 1;
const colTotal = dayColEnd + 1; // 총시간
const colNewVac = colTotal + 1; // N월 신규
const colUsedVac = colNewVac + 1; // N월 사용
const colRemVac = colUsedVac + 1; // N월 잔여
const colNote1 = colRemVac + 1; // 비고 (2열 병합)
const colNote2 = colNote1 + 1;
const lastCol = colNote2;
ws.getColumn(1).width = 10; // A 이름
ws.getColumn(2).width = 6; // B 담당
for (let c = dayColStart; c <= dayColEnd; c++) ws.getColumn(c).width = 5;
ws.getColumn(colTotal).width = 8;
ws.getColumn(colNewVac).width = 8;
ws.getColumn(colUsedVac).width = 8;
ws.getColumn(colRemVac).width = 8;
ws.getColumn(colNote1).width = 7;
ws.getColumn(colNote2).width = 7;
// ── Row 1: 빈 행 (여백) ──
ws.getRow(1).height = 10;
// ── Row 2: 부서명 ──
ws.getRow(2).height = 30;
ws.getCell(2, 1).value = '부서명';
ws.getCell(2, 1).font = { ...FONT, bold: true };
ws.getCell(2, 1).alignment = CENTER;
ws.mergeCells(2, 2, 2, 5);
ws.getCell(2, 2).value = deptName;
ws.getCell(2, 2).font = FONT;
ws.getCell(2, 2).alignment = { ...CENTER, horizontal: 'left' };
// ── Row 3: 근로기간 ──
ws.getRow(3).height = 30;
ws.getCell(3, 1).value = '근로기간';
ws.getCell(3, 1).font = { ...FONT, bold: true };
ws.getCell(3, 1).alignment = CENTER;
ws.mergeCells(3, 2, 3, 5);
ws.getCell(3, 2).value = `${y}${m}`;
ws.getCell(3, 2).font = FONT;
ws.getCell(3, 2).alignment = { ...CENTER, horizontal: 'left' };
// ── Row 4-5: 헤더 (병합) ──
ws.getRow(4).height = 40;
ws.getRow(5).height = 40;
// A4:A5 이름
ws.mergeCells(4, 1, 5, 1);
ws.getCell(4, 1).value = '이름';
ws.getCell(4, 1).font = { ...FONT, bold: true };
ws.getCell(4, 1).alignment = CENTER;
ws.getCell(4, 1).border = THIN_BORDER;
// B4:B5 담당
ws.mergeCells(4, 2, 5, 2);
ws.getCell(4, 2).value = '담당';
ws.getCell(4, 2).font = { ...FONT, bold: true };
ws.getCell(4, 2).alignment = CENTER;
ws.getCell(4, 2).border = THIN_BORDER;
// 날짜 헤더: Row4=일자, Row5=요일
for (let day = 1; day <= daysInMonth; day++) {
const col = dayColStart + day - 1;
const date = new Date(y, m - 1, day);
const dow = date.getDay();
const isWeekend = dow === 0 || dow === 6;
// Row 4: 날짜 숫자
const cell4 = ws.getCell(4, col);
cell4.value = day;
cell4.font = isWeekend ? { ...FONT, bold: true, color: { argb: 'FFFF0000' } } : { ...FONT, bold: true };
cell4.alignment = CENTER;
cell4.border = THIN_BORDER;
// Row 5: 요일
const cell5 = ws.getCell(5, col);
cell5.value = DAYS_KR[dow];
cell5.font = isWeekend ? RED_FONT : FONT;
cell5.alignment = CENTER;
cell5.border = THIN_BORDER;
}
// 총시간 헤더 (4:5 병합)
ws.mergeCells(4, colTotal, 5, colTotal);
ws.getCell(4, colTotal).value = '총시간';
ws.getCell(4, colTotal).font = { ...FONT, bold: true };
ws.getCell(4, colTotal).alignment = CENTER;
ws.getCell(4, colTotal).border = THIN_BORDER;
// 연차 헤더: Row4 = "N월" 병합, Row5 = 신규/사용/잔여
ws.mergeCells(4, colNewVac, 4, colRemVac);
ws.getCell(4, colNewVac).value = `${m}`;
ws.getCell(4, colNewVac).font = { ...FONT, bold: true };
ws.getCell(4, colNewVac).alignment = CENTER;
ws.getCell(4, colNewVac).border = THIN_BORDER;
ws.getCell(5, colNewVac).value = '신규';
ws.getCell(5, colNewVac).font = { ...FONT, bold: true };
ws.getCell(5, colNewVac).alignment = CENTER;
ws.getCell(5, colNewVac).border = THIN_BORDER;
ws.getCell(5, colUsedVac).value = '사용';
ws.getCell(5, colUsedVac).font = { ...FONT, bold: true };
ws.getCell(5, colUsedVac).alignment = CENTER;
ws.getCell(5, colUsedVac).border = THIN_BORDER;
ws.getCell(5, colRemVac).value = '잔여';
ws.getCell(5, colRemVac).font = { ...FONT, bold: true };
ws.getCell(5, colRemVac).alignment = CENTER;
ws.getCell(5, colRemVac).border = THIN_BORDER;
// 비고 헤더 (4:5, 2열 병합)
ws.mergeCells(4, colNote1, 5, colNote2);
ws.getCell(4, colNote1).value = '비고';
ws.getCell(4, colNote1).font = { ...FONT, bold: true };
ws.getCell(4, colNote1).alignment = CENTER;
ws.getCell(4, colNote1).border = THIN_BORDER;
// ── 데이터 행 ──
const dataStartRow = 6;
workers.forEach((worker, idx) => {
const row = dataStartRow + idx;
ws.getRow(row).height = 60;
const userAttend = attendMap[worker.user_id] || {};
const userVac = vacMap[worker.user_id] || { total_days: 0, used_days: 0, remaining_days: 0 };
// A: 이름
const cellName = ws.getCell(row, 1);
cellName.value = worker.worker_name;
cellName.font = FONT;
cellName.alignment = CENTER;
cellName.border = THIN_BORDER;
// B: 직종
const cellJob = ws.getCell(row, 2);
cellJob.value = worker.job_type || '';
cellJob.font = FONT;
cellJob.alignment = CENTER;
cellJob.border = THIN_BORDER;
// C~: 일별 데이터
for (let day = 1; day <= daysInMonth; day++) {
const col = dayColStart + day - 1;
const cell = ws.getCell(row, col);
const date = new Date(y, m - 1, day);
const dow = date.getDay();
const isWeekend = dow === 0 || dow === 6;
const rec = userAttend[day];
if (rec && rec.vacation_type_code) {
// 휴가
cell.value = VAC_TEXT[rec.vacation_type_code] || rec.vacation_type_name || '휴가';
cell.font = FONT;
} else if (isWeekend && (!rec || !rec.total_work_hours || parseFloat(rec.total_work_hours) === 0)) {
// 주말 + 근무 없음 → 휴무
cell.value = '휴무';
cell.font = FONT;
} else if (rec && parseFloat(rec.total_work_hours) > 0) {
// 정상 출근 → 근무시간(숫자)
cell.value = parseFloat(rec.total_work_hours);
cell.numFmt = '0.00';
cell.font = FONT;
} else {
// 데이터 없음 (평일 미출근 등)
cell.value = '';
cell.font = FONT;
}
cell.alignment = CENTER;
cell.border = THIN_BORDER;
}
// 총시간 = SUM 수식 (C열~마지막 날짜열)
const firstDayCol = ws.getColumn(dayColStart).letter;
const lastDayCol = ws.getColumn(dayColEnd).letter;
const cellTotal = ws.getCell(row, colTotal);
cellTotal.value = { formula: `SUM(${firstDayCol}${row}:${lastDayCol}${row})` };
cellTotal.numFmt = '0.00';
cellTotal.font = { ...FONT, bold: true };
cellTotal.alignment = CENTER;
cellTotal.border = THIN_BORDER;
// 연차 신규
const cellNew = ws.getCell(row, colNewVac);
cellNew.value = parseFloat(userVac.total_days) || 0;
cellNew.numFmt = '0.0';
cellNew.font = FONT;
cellNew.alignment = CENTER;
cellNew.border = THIN_BORDER;
// 연차 사용
const cellUsed = ws.getCell(row, colUsedVac);
cellUsed.value = parseFloat(userVac.used_days) || 0;
cellUsed.numFmt = '0.0';
cellUsed.font = FONT;
cellUsed.alignment = CENTER;
cellUsed.border = THIN_BORDER;
// 연차 잔여 = 수식(신규 - 사용)
const newCol = ws.getColumn(colNewVac).letter;
const usedCol = ws.getColumn(colUsedVac).letter;
const cellRem = ws.getCell(row, colRemVac);
cellRem.value = { formula: `${newCol}${row}-${usedCol}${row}` };
cellRem.numFmt = '0.0';
cellRem.font = { ...FONT, bold: true };
cellRem.alignment = CENTER;
cellRem.border = THIN_BORDER;
// 비고 (병합)
ws.mergeCells(row, colNote1, row, colNote2);
const cellNote = ws.getCell(row, colNote1);
cellNote.value = '';
cellNote.font = FONT;
cellNote.alignment = CENTER;
cellNote.border = THIN_BORDER;
});
// ── 응답 ──
const filename = encodeURIComponent(`출근부_${y}.${String(m).padStart(2, '0')}_${deptName}.xlsx`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
} catch (err) {
logger.error('monthlyComparison exportExcel error:', err);
res.status(500).json({ success: false, message: '엑셀 생성 실패' });
}
}
};
module.exports = MonthlyComparisonController;

View File

@@ -0,0 +1,210 @@
/**
* 대리입력 + 일별 현황 컨트롤러
*/
const ProxyInputModel = require('../models/proxyInputModel');
const { getDb } = require('../dbPool');
const logger = require('../../shared/utils/logger');
const ProxyInputController = {
/**
* POST /api/proxy-input — 대리입력 (단일 트랜잭션)
*/
proxyInput: async (req, res) => {
const { session_date, leader_id, entries, safety_notes, work_location } = req.body;
const userId = req.user.user_id || req.user.id;
// 유효성 검사
if (!session_date) {
return res.status(400).json({ success: false, message: '날짜는 필수입니다.' });
}
if (!entries || !Array.isArray(entries) || entries.length === 0) {
return res.status(400).json({ success: false, message: '작업자 정보는 최소 1명 필요합니다.' });
}
if (entries.length > 30) {
return res.status(400).json({ success: false, message: '한 번에 30명까지 입력 가능합니다.' });
}
// 날짜 유효성 (과거 30일 ~ 오늘)
const today = new Date();
today.setHours(0, 0, 0, 0);
const inputDate = new Date(session_date);
const diffDays = Math.floor((today - inputDate) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return res.status(400).json({ success: false, message: '미래 날짜는 입력할 수 없습니다.' });
}
if (diffDays > 30) {
return res.status(400).json({ success: false, message: '30일 이내 날짜만 입력 가능합니다.' });
}
// entries 필수 필드 검사
for (const entry of entries) {
if (!entry.user_id || !entry.project_id || !entry.work_type_id || !entry.work_hours) {
return res.status(400).json({ success: false, message: '각 작업자의 user_id, project_id, work_type_id, work_hours는 필수입니다.' });
}
if (entry.work_hours <= 0 || entry.work_hours > 24) {
return res.status(400).json({ success: false, message: '근무 시간은 0 초과 24 이하여야 합니다.' });
}
}
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const userIds = entries.map(e => e.user_id);
// 1. 기존 보고서 확인 (UPSERT 분기)
const [existingReports] = await conn.query(
`SELECT id, user_id FROM daily_work_reports WHERE report_date = ? AND user_id IN (${userIds.map(() => '?').join(',')})`,
[session_date, ...userIds]
);
const existingMap = {};
existingReports.forEach(r => { existingMap[r.user_id] = r.id; });
// 2. 신규 작업자용 TBM 세션 생성 (기존 있으면 재사용)
let sessionId = null;
const newUserIds = userIds.filter(id => !existingMap[id]);
if (newUserIds.length > 0) {
const sessionResult = await ProxyInputModel.createProxySession(conn, {
session_date,
leader_id: leader_id || userId,
proxy_input_by: userId,
created_by: userId,
safety_notes: safety_notes || '',
work_location: work_location || ''
});
sessionId = sessionResult.insertId;
}
// 3. 각 entry 처리 (UPSERT)
const createdWorkers = [];
for (const entry of entries) {
const existingReportId = existingMap[entry.user_id];
if (existingReportId) {
// UPDATE 기존 보고서
await conn.query(`
UPDATE daily_work_reports SET
project_id = ?, work_type_id = ?, work_hours = ?,
work_status_id = ?, start_time = ?, end_time = ?, note = ?,
updated_at = NOW()
WHERE id = ?
`, [entry.project_id, entry.work_type_id, entry.work_hours,
entry.work_status_id || 1, entry.start_time || null, entry.end_time || null,
entry.note || '', existingReportId]);
createdWorkers.push({ user_id: entry.user_id, report_id: existingReportId, action: 'updated' });
} else {
// INSERT 신규 — TBM 배정 + 작업보고서
const assignResult = await ProxyInputModel.createTeamAssignment(conn, {
session_id: sessionId,
user_id: entry.user_id,
project_id: entry.project_id,
work_type_id: entry.work_type_id,
task_id: entry.task_id || null,
workplace_id: entry.workplace_id || null,
work_hours: entry.work_hours
});
const assignmentId = assignResult.insertId;
const reportResult = await ProxyInputModel.createWorkReport(conn, {
report_date: session_date,
user_id: entry.user_id,
project_id: entry.project_id,
work_type_id: entry.work_type_id,
task_id: entry.task_id || null,
work_status_id: entry.work_status_id || 1,
work_hours: entry.work_hours,
start_time: entry.start_time || null,
end_time: entry.end_time || null,
note: entry.note || '',
tbm_session_id: sessionId,
tbm_assignment_id: assignmentId,
created_by: userId,
created_by_name: req.user.name || req.user.username || ''
});
createdWorkers.push({ user_id: entry.user_id, report_id: reportResult.insertId, action: 'created' });
}
// 부적합 처리 (defect_hours > 0 && 기존 defect 없을 때만)
const defectHours = parseFloat(entry.defect_hours) || 0;
const reportId = existingReportId || createdWorkers[createdWorkers.length - 1].report_id;
if (defectHours > 0) {
const [existingDefects] = await conn.query(
'SELECT defect_id FROM work_report_defects WHERE report_id = ?', [reportId]
);
if (existingDefects.length === 0) {
await conn.query(
`INSERT INTO work_report_defects (report_id, defect_hours, category_id, item_id, note) VALUES (?, ?, ?, ?, '대리입력')`,
[reportId, defectHours, entry.defect_category_id || null, entry.defect_item_id || null]
);
await conn.query(
'UPDATE daily_work_reports SET error_hours = ?, work_status_id = 2 WHERE id = ?',
[defectHours, reportId]
);
}
}
}
await conn.commit();
res.status(201).json({
success: true,
message: `${entries.length}명의 대리입력이 완료되었습니다.`,
data: {
session_id: sessionId,
is_proxy_input: true,
created_reports: entries.length,
workers: createdWorkers
}
});
} catch (err) {
try { await conn.rollback(); } catch (e) {}
logger.error('대리입력 오류:', err);
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
} finally {
conn.release();
}
},
/**
* GET /api/proxy-input/daily-status — 일별 현황
*/
getDailyStatus: async (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ success: false, message: '날짜(date) 파라미터는 필수입니다.' });
}
const data = await ProxyInputModel.getDailyStatus(date);
res.json({ success: true, data });
} catch (err) {
logger.error('일별 현황 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
},
/**
* GET /api/proxy-input/daily-status/detail — 작업자별 상세
*/
getDailyStatusDetail: async (req, res) => {
try {
const { date, user_id } = req.query;
if (!date || !user_id) {
return res.status(400).json({ success: false, message: 'date와 user_id 파라미터는 필수입니다.' });
}
const data = await ProxyInputModel.getDailyStatusDetail(date, parseInt(user_id));
if (!data.worker) {
return res.status(404).json({ success: false, message: '작업자를 찾을 수 없습니다.' });
}
res.json({ success: true, data });
} catch (err) {
logger.error('일별 상세 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
}
};
module.exports = ProxyInputController;

View File

@@ -0,0 +1,175 @@
const PurchaseBatchModel = require('../models/purchaseBatchModel');
const PurchaseRequestModel = require('../models/purchaseRequestModel');
const { saveBase64Image } = require('../services/imageUploadService');
const logger = require('../utils/logger');
const notifyHelper = require('../../../shared/utils/notifyHelper');
const PurchaseBatchController = {
getAll: async (req, res) => {
try {
const { status } = req.query;
const rows = await PurchaseBatchModel.getAll({ status });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('PurchaseBatch getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
getById: async (req, res) => {
try {
const batch = await PurchaseBatchModel.getById(req.params.id);
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
// 포함된 요청 목록도 함께 반환
const requests = await PurchaseRequestModel.getAll({ batch_id: req.params.id });
res.json({ success: true, data: { ...batch, requests } });
} catch (err) {
logger.error('PurchaseBatch getById error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 그룹 생성 + 요청 포함
create: async (req, res) => {
try {
const { batch_name, category, vendor_id, notes, request_ids } = req.body;
if (!request_ids || !request_ids.length) {
return res.status(400).json({ success: false, message: '그룹에 포함할 신청 건을 선택해주세요.' });
}
const batchId = await PurchaseBatchModel.create({
batchName: batch_name,
category,
vendorId: vendor_id,
notes,
createdBy: req.user.id
});
await PurchaseBatchModel.addRequests(batchId, request_ids);
// 신청자들에게 알림
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(batchId);
if (requesterIds.length > 0) {
notifyHelper.send({
type: 'purchase',
title: '구매 진행 안내',
message: '신청하신 소모품 구매가 진행됩니다.',
link_url: '/pages/purchase/request-mobile.html',
target_user_ids: requesterIds,
created_by: req.user.id
}).catch(() => {});
}
const batch = await PurchaseBatchModel.getById(batchId);
res.status(201).json({ success: true, data: batch, message: '그룹이 생성되었습니다.' });
} catch (err) {
logger.error('PurchaseBatch create error:', err);
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
}
},
update: async (req, res) => {
try {
const { batch_name, category, vendor_id, notes, add_request_ids, remove_request_ids } = req.body;
const batch = await PurchaseBatchModel.getById(req.params.id);
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
if (batch_name !== undefined || category !== undefined || vendor_id !== undefined || notes !== undefined) {
await PurchaseBatchModel.update(req.params.id, {
batchName: batch_name !== undefined ? batch_name : batch.batch_name,
category: category !== undefined ? category : batch.category,
vendorId: vendor_id !== undefined ? vendor_id : batch.vendor_id,
notes: notes !== undefined ? notes : batch.notes
});
}
if (add_request_ids && add_request_ids.length) {
await PurchaseBatchModel.addRequests(req.params.id, add_request_ids);
}
if (remove_request_ids && remove_request_ids.length) {
await PurchaseBatchModel.removeRequests(req.params.id, remove_request_ids);
}
const updated = await PurchaseBatchModel.getById(req.params.id);
res.json({ success: true, data: updated, message: '그룹이 수정되었습니다.' });
} catch (err) {
logger.error('PurchaseBatch update error:', err);
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
}
},
delete: async (req, res) => {
try {
const deleted = await PurchaseBatchModel.delete(req.params.id);
if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 그룹만 삭제할 수 있습니다.' });
res.json({ success: true, message: '그룹이 삭제되었습니다.' });
} catch (err) {
logger.error('PurchaseBatch delete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 그룹 일괄 구매 처리
purchase: async (req, res) => {
try {
const batch = await PurchaseBatchModel.getById(req.params.id);
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
if (batch.status !== 'pending') {
return res.status(400).json({ success: false, message: '대기 상태의 그룹만 구매 처리할 수 있습니다.' });
}
// batch 내 모든 요청 purchased 전환
await PurchaseRequestModel.markBatchPurchased(req.params.id);
await PurchaseBatchModel.markPurchased(req.params.id, req.user.id);
res.json({ success: true, message: '일괄 구매 처리가 완료되었습니다.' });
} catch (err) {
logger.error('PurchaseBatch purchase error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 그룹 일괄 입고 처리
receive: async (req, res) => {
try {
const batch = await PurchaseBatchModel.getById(req.params.id);
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
if (batch.status !== 'purchased') {
return res.status(400).json({ success: false, message: '구매완료 상태의 그룹만 입고 처리할 수 있습니다.' });
}
const { received_location, photo } = req.body;
let receivedPhotoPath = null;
if (photo) {
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
}
await PurchaseRequestModel.receiveBatch(req.params.id, {
receivedPhotoPath,
receivedLocation: received_location,
receivedBy: req.user.id
});
await PurchaseBatchModel.markReceived(req.params.id, req.user.id);
// 신청자들에게 입고 알림
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(req.params.id);
if (requesterIds.length > 0) {
notifyHelper.send({
type: 'purchase',
title: '소모품 입고 완료',
message: `소모품이 입고되었습니다.${received_location ? ' 보관위치: ' + received_location : ''}`,
link_url: '/pages/purchase/request-mobile.html',
target_user_ids: requesterIds,
created_by: req.user.id
}).catch(() => {});
}
res.json({ success: true, message: '일괄 입고 처리가 완료되었습니다.' });
} catch (err) {
logger.error('PurchaseBatch receive error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = PurchaseBatchController;

View File

@@ -2,14 +2,16 @@ const PurchaseRequestModel = require('../models/purchaseRequestModel');
const PurchaseModel = require('../models/purchaseModel'); const PurchaseModel = require('../models/purchaseModel');
const { saveBase64Image } = require('../services/imageUploadService'); const { saveBase64Image } = require('../services/imageUploadService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const notifyHelper = require('../../../shared/utils/notifyHelper');
const koreanSearch = require('../utils/koreanSearch');
const PurchaseRequestController = { const PurchaseRequestController = {
// 구매신청 목록 // 구매신청 목록
getAll: async (req, res) => { getAll: async (req, res) => {
try { try {
const { status, category, from_date, to_date } = req.query; const { status, category, from_date, to_date, batch_id } = req.query;
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level); const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
const filters = { status, category, from_date, to_date }; const filters = { status, category, from_date, to_date, batch_id };
if (!isAdmin) filters.requester_id = req.user.id; if (!isAdmin) filters.requester_id = req.user.id;
const rows = await PurchaseRequestModel.getAll(filters); const rows = await PurchaseRequestModel.getAll(filters);
res.json({ success: true, data: rows }); res.json({ success: true, data: rows });
@@ -113,6 +115,188 @@ const PurchaseRequestController = {
} }
}, },
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
registerAndRequest: async (req, res) => {
const { getDb } = require('../dbPool');
let conn;
try {
const { item_name, spec, maker, category, quantity, notes, photo } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' });
}
if (!quantity || quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
// 1. 소모품 마스터 등록 (중복 확인)
const [existing] = await conn.query(
`SELECT item_id FROM consumable_items
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
[item_name.trim(), spec || null, spec || null, maker || null, maker || null]
);
let itemId;
if (existing.length > 0) {
itemId = existing[0].item_id;
} else {
const [insertResult] = await conn.query(
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
[item_name.trim(), spec || null, maker || null, category || 'consumable']
);
itemId = insertResult.insertId;
}
// 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에)
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
// 3. 구매 신청 생성
const [reqResult] = await conn.query(
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path)
VALUES (?, ?, ?, CURDATE(), ?, ?)`,
[itemId, quantity, req.user.id, notes || null, photo_path]
);
await conn.commit();
// 검색 캐시 무효화
koreanSearch.clearCache();
const request = await PurchaseRequestModel.getById(reqResult.insertId);
res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' });
} catch (err) {
if (conn) await conn.rollback().catch(() => {});
logger.error('registerAndRequest error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} finally {
if (conn) conn.release();
}
},
// 일괄 신청 (장바구니, 트랜잭션)
bulkCreate: async (req, res) => {
const { getDb } = require('../dbPool');
let conn;
try {
const { items, photo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
}
for (const item of items) {
if (!item.item_id && !item.item_name) {
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
}
if (!item.quantity || item.quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
}
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
const createdIds = [];
let newItemRegistered = false;
for (const item of items) {
let itemId = item.item_id || null;
// 신규 품목 등록 (is_new)
if (item.is_new && item.item_name) {
const [existing] = await conn.query(
`SELECT item_id FROM consumable_items
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
);
if (existing.length > 0) {
itemId = existing[0].item_id;
} else {
// 신규 품목 사진 저장 (마스터에)
let itemPhotoPath = null;
if (item.item_photo) {
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
}
const [ins] = await conn.query(
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
);
itemId = ins.insertId;
newItemRegistered = true;
}
}
// purchase_request 생성
const [result] = await conn.query(
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
item.quantity, req.user.id, item.notes || null, photo_path]
);
createdIds.push(result.insertId);
}
await conn.commit();
if (newItemRegistered) koreanSearch.clearCache();
res.status(201).json({
success: true,
data: { request_ids: createdIds, count: createdIds.length },
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
});
} catch (err) {
if (conn) await conn.rollback().catch(() => {});
logger.error('bulkCreate error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} finally {
if (conn) conn.release();
}
},
// 품목 마스터 사진 등록/업데이트
updateItemPhoto: async (req, res) => {
try {
const { photo } = req.body;
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
const { getDb } = require('../dbPool');
const db = await getDb();
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
koreanSearch.clearCache();
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
} catch (err) {
logger.error('updateItemPhoto error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 스마트 검색 (초성 + 별칭 + substring)
search: async (req, res) => {
try {
const { q } = req.query;
const results = await koreanSearch.search(q || '');
res.json({ success: true, data: results });
} catch (err) {
logger.error('PurchaseRequest search error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 소모품 목록 (select용) // 소모품 목록 (select용)
getConsumableItems: async (req, res) => { getConsumableItems: async (req, res) => {
try { try {
@@ -133,6 +317,138 @@ const PurchaseRequestController = {
logger.error('Vendors get error:', err); logger.error('Vendors get error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} }
},
// 내 신청 목록 (모바일용, 페이지네이션)
getMyRequests: async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const { status } = req.query;
const result = await PurchaseRequestModel.getMyRequests(req.user.id, { page, limit, status });
res.json({ success: true, ...result });
} catch (err) {
logger.error('PurchaseRequest getMyRequests error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 개별 입고 처리 (admin)
receive: async (req, res) => {
try {
const existing = await PurchaseRequestModel.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
if (existing.status !== 'purchased') {
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 입고 처리할 수 있습니다.' });
}
const { received_location, photo } = req.body;
let receivedPhotoPath = null;
if (photo) {
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
}
const updated = await PurchaseRequestModel.receive(req.params.id, {
receivedPhotoPath,
receivedLocation: received_location || null,
receivedBy: req.user.id
});
// batch 내 전체 입고 완료 시 batch.status 자동 전환
if (existing.batch_id) {
const allReceived = await PurchaseRequestModel.checkBatchAllReceived(existing.batch_id);
if (allReceived) {
const { getDb } = require('../dbPool');
const db = await getDb();
await db.query(
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ? WHERE batch_id = ?`,
[req.user.id, existing.batch_id]
);
}
}
// 신청자에게 입고 알림
notifyHelper.send({
type: 'purchase',
title: '소모품 입고 완료',
message: `${existing.item_name || existing.custom_item_name} 입고 완료${received_location ? '. 보관위치: ' + received_location : ''}`,
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
target_user_ids: [existing.requester_id],
created_by: req.user.id
}).catch(() => {});
res.json({ success: true, data: updated, message: '입고 처리가 완료되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest receive error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구매 취소 (purchased → cancelled)
cancel: async (req, res) => {
try {
const existing = await PurchaseRequestModel.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
if (existing.status !== 'purchased') {
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 취소할 수 있습니다.' });
}
const { cancel_reason } = req.body;
const updated = await PurchaseRequestModel.cancelPurchase(req.params.id, {
cancelledBy: req.user.id,
cancelReason: cancel_reason
});
res.json({ success: true, data: updated, message: '구매가 취소되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest cancel error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 반품 (received → returned)
returnItem: async (req, res) => {
try {
const existing = await PurchaseRequestModel.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
if (existing.status !== 'received') {
return res.status(400).json({ success: false, message: '입고완료 상태의 신청만 반품할 수 있습니다.' });
}
const { cancel_reason } = req.body;
const updated = await PurchaseRequestModel.returnItem(req.params.id, {
cancelledBy: req.user.id,
cancelReason: cancel_reason
});
// 신청자에게 반품 알림
notifyHelper.send({
type: 'purchase',
title: '소모품 반품 처리',
message: `${existing.item_name || existing.custom_item_name} 반품 처리되었습니다.${cancel_reason ? ' 사유: ' + cancel_reason : ''}`,
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
target_user_ids: [existing.requester_id],
created_by: req.user.id
}).catch(() => {});
res.json({ success: true, data: updated, message: '반품 처리되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest return error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 취소 → 대기로 되돌리기
revertCancel: async (req, res) => {
try {
const existing = await PurchaseRequestModel.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
if (existing.status !== 'cancelled') {
return res.status(400).json({ success: false, message: '취소 상태의 신청만 되돌릴 수 있습니다.' });
}
const updated = await PurchaseRequestModel.revertCancel(req.params.id);
res.json({ success: true, data: updated, message: '대기 상태로 되돌렸습니다.' });
} catch (err) {
logger.error('PurchaseRequest revertCancel error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
} }
}; };

View File

@@ -46,6 +46,32 @@ const SettlementController = {
} }
}, },
// 입고일 기준 월간 요약
getMonthlyReceivedSummary: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const categorySummary = await SettlementModel.getCategorySummaryByReceived(year_month);
res.json({ success: true, data: { categorySummary } });
} catch (err) {
logger.error('Settlement received summary error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 입고일 기준 월간 상세 목록
getMonthlyReceivedList: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const rows = await SettlementModel.getMonthlyReceived(year_month);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Settlement received list error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 정산 완료 // 정산 완료
complete: async (req, res) => { complete: async (req, res) => {
try { try {

View File

@@ -213,7 +213,7 @@ const vacationBalanceController = {
let errorCount = 0; let errorCount = 0;
for (const balance of balances) { for (const balance of balances) {
const { user_id, vacation_type_id, year, total_days, notes } = balance; const { user_id, vacation_type_id, year, total_days, notes, balance_type } = balance;
if (!user_id || !vacation_type_id || !year || total_days === undefined) { if (!user_id || !vacation_type_id || !year || total_days === undefined) {
errorCount++; errorCount++;
@@ -221,17 +221,17 @@ const vacationBalanceController = {
} }
try { try {
const btype = balance_type || 'AUTO';
const query = ` const query = `
INSERT INTO vacation_balance_details INSERT INTO sp_vacation_balances
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by) (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
VALUES (?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, 0, ?, ?, ?, NULL)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
total_days = VALUES(total_days), total_days = VALUES(total_days),
notes = VALUES(notes), notes = VALUES(notes)
updated_at = NOW()
`; `;
await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by]); await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by, btype]);
successCount++; successCount++;
} catch (err) { } catch (err) {
logger.error('휴가 잔액 저장 오류:', err); logger.error('휴가 잔액 저장 오류:', err);

View File

@@ -201,6 +201,15 @@ const vacationRequestController = {
return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' }); return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' });
} }
// sp_vacation_balances 차감 (특별휴가 우선 → 이월 → 기본 순서)
const request = results[0];
const year = new Date(request.start_date).getFullYear();
const daysUsed = parseFloat(request.days_used) || 0;
if (daysUsed > 0) {
const vacationBalanceModel = require('../models/vacationBalanceModel');
await vacationBalanceModel.deductByPriority(request.user_id, year, daysUsed);
}
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note }); await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
res.json({ success: true, message: '휴가 신청이 승인되었습니다' }); res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
} catch (error) { } catch (error) {

View File

@@ -36,7 +36,7 @@ exports.up = async function(knex) {
table.increments('id').primary(); table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('휴가 코드'); table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
table.string('type_name', 50).notNullable().comment('휴가 이름'); table.string('type_name', 50).notNullable().comment('휴가 이름');
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수'); table.decimal('deduct_days', 4, 2).defaultTo(1.00).comment('차감 일수');
table.boolean('is_active').defaultTo(true).comment('활성 여부'); table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()); table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now()); table.timestamp('updated_at').defaultTo(knex.fn.now());

View File

@@ -1,5 +1,5 @@
-- Push 구독 테이블 생성 -- Push 구독 테이블 생성
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi < db/migrations/20260313001000_create_push_subscriptions.sql -- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p"$MYSQL_PASSWORD" hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE TABLE IF NOT EXISTS push_subscriptions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,

View File

@@ -0,0 +1,3 @@
-- 대리입력 식별 컬럼 추가
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS is_proxy_input TINYINT(1) DEFAULT 0 COMMENT '대리입력 여부';
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS proxy_input_by INT NULL COMMENT '대리입력자 sso_users.user_id (앱 레벨 참조)';

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS monthly_work_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '작업자 user_id (workers.user_id)',
year INT NOT NULL,
month INT NOT NULL,
status ENUM('pending', 'confirmed', 'rejected') NOT NULL DEFAULT 'pending',
total_work_days INT DEFAULT 0 COMMENT '총 근무일수',
total_work_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 근무시간',
total_overtime_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 연장근로시간',
vacation_days DECIMAL(4,2) DEFAULT 0 COMMENT '휴가 일수',
mismatch_count INT DEFAULT 0 COMMENT '불일치 건수',
reject_reason TEXT NULL COMMENT '반려 사유',
confirmed_at TIMESTAMP NULL COMMENT '확인 일시',
rejected_at TIMESTAMP NULL COMMENT '반려 일시',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_year_month (user_id, year, month),
KEY idx_year_month (year, month),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='월간 근무 확인 (승인/반려)'

View File

@@ -0,0 +1,34 @@
-- vacation_types.deduct_days 정밀도 수정: DECIMAL(3,1) → DECIMAL(4,2)
-- 0.25(반반차)가 0.3으로 반올림되는 문제 해결
ALTER TABLE vacation_types MODIFY deduct_days DECIMAL(4,2) DEFAULT 1.00;
UPDATE vacation_types SET deduct_days = 0.25 WHERE type_code = 'ANNUAL_QUARTER';
-- type_code가 표준 유형인데 balance_type이 COMPANY_GRANT인 잘못된 레코드 삭제
DELETE svb FROM sp_vacation_balances svb
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.balance_type = 'COMPANY_GRANT'
AND vt.type_code IN ('CARRYOVER', 'LONG_SERVICE', 'ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER');
-- 조퇴 휴가 유형 추가 (0.75일 = 반차+반반차)
INSERT IGNORE INTO vacation_types (type_code, type_name, deduct_days, is_active, priority)
VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10);
-- 작업자 월간 확인 페이지 등록
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order)
VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25);
-- 2026년 법정 공휴일 + 대체공휴일 일괄 등록
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-01-01', '신정', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-16', '설날 연휴', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-17', '설날', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-18', '설날 연휴', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-01', '삼일절', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-02', '대체공휴일(삼일절)', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-05', '어린이날', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-24', '석가탄신일', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-06-06', '현충일', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-08-15', '광복절', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-24', '추석 연휴', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-25', '추석', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-26', '추석 연휴', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-03', '개천절', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-09', '한글날', 'PAID', 1);
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-12-25', '크리스마스', 'PAID', 1);
-- NULL leader_user_id 복구 (created_by로 채움)
UPDATE tbm_sessions SET leader_user_id = created_by WHERE leader_user_id IS NULL;

View File

@@ -0,0 +1,26 @@
-- 소모품 카테고리 테이블 분리 (ENUM → 마스터 테이블)
-- 1단계: 카테고리 마스터 테이블 생성
CREATE TABLE IF NOT EXISTS consumable_categories (
category_id INT AUTO_INCREMENT PRIMARY KEY,
category_code VARCHAR(30) NOT NULL UNIQUE COMMENT '코드 (consumable, safety 등)',
category_name VARCHAR(50) NOT NULL COMMENT '표시명',
icon VARCHAR(30) DEFAULT 'fa-box' COMMENT 'Font Awesome 아이콘',
color_bg VARCHAR(30) DEFAULT '#dbeafe' COMMENT '배경색',
color_fg VARCHAR(30) DEFAULT '#1e40af' COMMENT '글자색',
sort_order INT DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 2단계: 기존 4개 시드
INSERT IGNORE INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order) VALUES
('consumable', '소모품', 'fa-box', '#dbeafe', '#1e40af', 1),
('safety', '안전용품', 'fa-hard-hat', '#dcfce7', '#166534', 2),
('repair', '수선비', 'fa-wrench', '#fef3c7', '#92400e', 3),
('equipment', '설비', 'fa-cogs', '#f3e8ff', '#7e22ce', 4);
-- 3단계: ENUM → VARCHAR 변환
ALTER TABLE consumable_items MODIFY COLUMN category VARCHAR(30) DEFAULT 'consumable';
ALTER TABLE purchase_requests MODIFY COLUMN custom_category VARCHAR(30) NULL;
ALTER TABLE purchase_batches MODIFY COLUMN category VARCHAR(30) NULL;

View File

@@ -0,0 +1,7 @@
-- 월간 확인 워크플로우 확장: pending → review_sent → confirmed/change_request/rejected
ALTER TABLE monthly_work_confirmations
MODIFY status ENUM('pending','review_sent','confirmed','change_request','rejected') DEFAULT 'pending';
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_by INT NULL;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP NULL;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS change_details TEXT NULL;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS admin_checked TINYINT(1) DEFAULT 0;

View File

@@ -0,0 +1,12 @@
-- 소모품 구매 취소/반품 지원 + 입고일 관리
-- 1. purchase_requests.status ENUM에 cancelled, returned 추가
ALTER TABLE purchase_requests
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','cancelled','returned','hold') DEFAULT 'pending'
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 취소, 반품, 보류';
-- 2. 취소/반품 관련 컬럼 추가
ALTER TABLE purchase_requests
ADD COLUMN cancelled_at TIMESTAMP NULL COMMENT '취소 시각' AFTER received_by,
ADD COLUMN cancelled_by INT NULL COMMENT '취소 처리자' AFTER cancelled_at,
ADD COLUMN cancel_reason TEXT NULL COMMENT '취소/반품 사유' AFTER cancelled_by;

View File

@@ -0,0 +1,63 @@
-- 소모품 구매 관리 시스템 v2: 상태 확장 + 그룹화 + 별칭 + 입고
-- 1. purchase_requests.status ENUM 확장
ALTER TABLE purchase_requests
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','hold') DEFAULT 'pending'
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 보류';
-- 2. 입고/그룹 관련 컬럼 추가
ALTER TABLE purchase_requests
ADD COLUMN batch_id INT NULL COMMENT '구매 묶음 ID' AFTER photo_path,
ADD COLUMN received_photo_path VARCHAR(255) NULL COMMENT '입고 사진' AFTER batch_id,
ADD COLUMN received_location VARCHAR(200) NULL COMMENT '입고 보관 위치' AFTER received_photo_path,
ADD COLUMN received_at TIMESTAMP NULL COMMENT '입고 확인 시각' AFTER received_location,
ADD COLUMN received_by INT NULL COMMENT '입고 확인자' AFTER received_at,
ADD CONSTRAINT fk_pr_received_by FOREIGN KEY (received_by) REFERENCES sso_users(user_id);
-- 3. 구매 묶음(그룹) 테이블
CREATE TABLE IF NOT EXISTS purchase_batches (
batch_id INT AUTO_INCREMENT PRIMARY KEY,
batch_name VARCHAR(100) COMMENT '묶음 이름',
category ENUM('consumable','safety','repair','equipment') NULL COMMENT '분류',
vendor_id INT NULL COMMENT '예정 업체',
status ENUM('pending','purchased','received') DEFAULT 'pending'
COMMENT '진행중, 구매완료, 입고완료',
notes TEXT,
created_by INT NOT NULL COMMENT '생성자',
purchased_at TIMESTAMP NULL COMMENT '구매 처리 시점',
purchased_by INT NULL COMMENT '구매 처리자',
received_at TIMESTAMP NULL COMMENT '입고 확인 시점',
received_by INT NULL COMMENT '입고 확인자',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
FOREIGN KEY (created_by) REFERENCES sso_users(user_id),
FOREIGN KEY (purchased_by) REFERENCES sso_users(user_id),
FOREIGN KEY (received_by) REFERENCES sso_users(user_id)
);
-- 4. batch FK
ALTER TABLE purchase_requests
ADD CONSTRAINT fk_pr_batch FOREIGN KEY (batch_id)
REFERENCES purchase_batches(batch_id) ON DELETE SET NULL;
-- 5. 품목 별칭 테이블 (한국어 동의어/약어 매핑)
CREATE TABLE IF NOT EXISTS item_aliases (
alias_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL COMMENT 'FK → consumable_items',
alias_name VARCHAR(100) NOT NULL COMMENT '별칭/축약어',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id) ON DELETE CASCADE,
UNIQUE KEY uq_item_alias (item_id, alias_name),
INDEX idx_alias_name (alias_name)
);
-- 6. notification_recipients ENUM에 'purchase' 추가
ALTER TABLE notification_recipients
MODIFY COLUMN notification_type
ENUM('repair','safety','nonconformity','equipment','maintenance','system','purchase')
NOT NULL COMMENT '알림 유형';
-- 7. 페이지 키 등록
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, is_admin_only, display_order) VALUES
('purchase.request_mobile', '소모품 신청 (모바일)', '/pages/purchase/request-mobile.html', 'purchase', 0, 42);

View File

@@ -0,0 +1,27 @@
-- 소모품 별칭 시드 데이터 (item_name LIKE 매칭, 데이터 없으면 무시)
-- 장갑류
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '목장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
-- 테이프류
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '전기테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
-- 연마류
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '사포' FROM consumable_items WHERE item_name LIKE '%연마지%' OR item_name LIKE '%연마석%';
-- 마스크
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '마스크' FROM consumable_items WHERE item_name LIKE '%방진마스크%' OR item_name LIKE '%방독마스크%';
-- 안전화
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '작업화' FROM consumable_items WHERE item_name LIKE '%안전화%';
INSERT IGNORE INTO item_aliases (item_id, alias_name)
SELECT item_id, '신발' FROM consumable_items WHERE item_name LIKE '%안전화%';

View File

@@ -48,7 +48,7 @@ async function runStartupMigrations() {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const db = await getDb(); const db = await getDb();
const migrationFiles = ['20260326_schedule_extensions.sql']; const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql', '20260401_monthly_confirm_workflow.sql'];
for (const file of migrationFiles) { for (const file of migrationFiles) {
const sqlPath = path.join(__dirname, 'db', 'migrations', file); const sqlPath = path.join(__dirname, 'db', 'migrations', file);
if (!fs.existsSync(sqlPath)) continue; if (!fs.existsSync(sqlPath)) continue;

View File

@@ -150,9 +150,10 @@ class AttendanceModel {
static async initializeDailyRecords(date, createdBy) { static async initializeDailyRecords(date, createdBy) {
const db = await getDb(); const db = await getDb();
// 1. 활성 작업자 조회 // 1. 활성 작업자 조회 (입사일 이전 제외)
const [workers] = await db.execute( const [workers] = await db.execute(
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL' 'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL AND (hire_date IS NULL OR hire_date <= ?)',
[date]
); );
if (workers.length === 0) return { inserted: 0 }; if (workers.length === 0) return { inserted: 0 };

View File

@@ -0,0 +1,47 @@
// models/consumableCategoryModel.js
const { getDb } = require('../dbPool');
const ConsumableCategoryModel = {
async getAll(activeOnly = true) {
const db = await getDb();
let sql = 'SELECT * FROM consumable_categories';
if (activeOnly) sql += ' WHERE is_active = 1';
sql += ' ORDER BY sort_order, category_name';
const [rows] = await db.query(sql);
return rows;
},
async getById(id) {
const db = await getDb();
const [rows] = await db.query('SELECT * FROM consumable_categories WHERE category_id = ?', [id]);
return rows[0] || null;
},
async create({ categoryCode, categoryName, icon, colorBg, colorFg, sortOrder }) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`,
[categoryCode, categoryName, icon || 'fa-box', colorBg || '#dbeafe', colorFg || '#1e40af', sortOrder || 0]
);
return this.getById(result.insertId);
},
async update(id, { categoryName, icon, colorBg, colorFg, sortOrder }) {
const db = await getDb();
await db.query(
`UPDATE consumable_categories SET category_name = ?, icon = ?, color_bg = ?, color_fg = ?, sort_order = ?
WHERE category_id = ?`,
[categoryName, icon, colorBg, colorFg, sortOrder, id]
);
return this.getById(id);
},
async deactivate(id) {
const db = await getDb();
await db.query('UPDATE consumable_categories SET is_active = 0 WHERE category_id = ?', [id]);
return this.getById(id);
}
};
module.exports = ConsumableCategoryModel;

View File

@@ -0,0 +1,148 @@
/**
* 대시보드 개인 요약 모델
* Sprint 003 — 연차/연장근로/접근 페이지 통합 조회
*/
const { getDb } = require('../config/database');
const OVERTIME_THRESHOLD = 8; // 연장근로 기준 시간
const DashboardModel = {
/**
* 사용자 정보 조회 (쿼리 1 — 먼저 실행)
*/
getUserInfo: async (userId) => {
const db = await getDb();
const [rows] = await db.execute(`
SELECT u.user_id, u.name, u.role,
w.worker_id, w.worker_name, w.job_type,
COALESCE(w.department_id, u.department_id) AS department_id,
COALESCE(d.department_name, d2.department_name, '미배정') AS department_name
FROM sso_users u
LEFT JOIN workers w ON u.user_id = w.user_id
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN departments d2 ON u.department_id = d2.department_id
WHERE u.user_id = ?
`, [userId]);
return rows[0] || null;
},
/**
* 연차 현황 조회 (쿼리 2)
*/
getVacationBalance: async (userId, year) => {
if (!userId) return [];
const db = await getDb();
const [rows] = await db.execute(`
SELECT svb.vacation_type_id, svb.total_days, svb.used_days,
(svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type, svb.expires_at,
vt.type_name, vt.type_code
FROM sp_vacation_balances svb
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.user_id = ? AND svb.year = ?
ORDER BY vt.priority
`, [userId, year]);
return rows;
},
/**
* 월간 연장근로 조회 (쿼리 3)
*/
getMonthlyOvertime: async (userId, year, month) => {
const db = await getDb();
const [rows] = await db.execute(`
SELECT
COUNT(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN 1 END) AS overtime_days,
COALESCE(SUM(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN dar.total_work_hours - ${OVERTIME_THRESHOLD} ELSE 0 END), 0) AS total_overtime_hours,
COUNT(*) AS total_work_days,
COALESCE(SUM(dar.total_work_hours), 0) AS total_work_hours,
COALESCE(AVG(dar.total_work_hours), 0) AS avg_daily_hours
FROM daily_attendance_records dar
WHERE dar.user_id = ? AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
AND dar.total_work_hours > 0
`, [userId, year, month]);
return rows[0] || { overtime_days: 0, total_overtime_hours: 0, total_work_days: 0, total_work_hours: 0, avg_daily_hours: 0 };
},
/**
* 접근 가능 페이지 조회 (쿼리 4)
*/
getQuickAccess: async (userId, departmentId, role) => {
const db = await getDb();
const isAdmin = ['admin', 'system'].includes((role || '').toLowerCase());
// 모든 페이지 조회
const [allPages] = await db.execute(`
SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages
ORDER BY display_order, page_name
`);
if (isAdmin) {
return {
department_pages: allPages.map(formatPage),
personal_pages: [],
admin_pages: []
};
}
// 부서 권한 페이지
// department_page_permissions.page_name은 's1.work.tbm' 형식 (시스템 접두사 포함)
// pages.page_key는 'work.tbm' 형식 (접두사 없음)
// → 's1.' 접두사를 제거하여 매칭
let deptPageKeys = new Set();
if (departmentId) {
const [deptRows] = await db.execute(`
SELECT dpp.page_name
FROM department_page_permissions dpp
WHERE dpp.department_id = ? AND dpp.can_access = 1
`, [departmentId]);
deptRows.forEach(r => {
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
deptPageKeys.add(key);
});
}
// 개인 권한 페이지 (user_page_permissions.page_name 기반)
const [personalRows] = await db.execute(`
SELECT upp.page_name
FROM user_page_permissions upp
WHERE upp.user_id = ? AND upp.can_access = 1
`, [userId]);
const personalPageKeys = new Set();
personalRows.forEach(r => {
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
personalPageKeys.add(key);
});
// 분류 (부서 우선, 중복 없음 — 권한 있는 페이지만)
const departmentPages = [];
const personalPages = [];
for (const page of allPages) {
if (deptPageKeys.has(page.page_key)) {
departmentPages.push(formatPage(page));
} else if (personalPageKeys.has(page.page_key)) {
personalPages.push(formatPage(page));
}
}
return {
department_pages: departmentPages,
personal_pages: personalPages,
admin_pages: []
};
}
};
function formatPage(page) {
return {
page_key: page.page_key,
page_name: page.page_name,
page_path: page.page_path,
icon: '',
category: page.category || ''
};
}
module.exports = DashboardModel;

View File

@@ -0,0 +1,35 @@
// models/itemAliasModel.js
const { getDb } = require('../dbPool');
const ItemAliasModel = {
async getAll() {
const db = await getDb();
const [rows] = await db.query(
`SELECT ia.*, ci.item_name, ci.spec, ci.maker, ci.category
FROM item_aliases ia
JOIN consumable_items ci ON ia.item_id = ci.item_id
ORDER BY ci.item_name, ia.alias_name`
);
return rows;
},
async create(itemId, aliasName) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO item_aliases (item_id, alias_name) VALUES (?, ?)`,
[itemId, aliasName.trim()]
);
return result.insertId;
},
async delete(aliasId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM item_aliases WHERE alias_id = ?`,
[aliasId]
);
return result.affectedRows > 0;
}
};
module.exports = ItemAliasModel;

View File

@@ -0,0 +1,387 @@
// models/monthlyComparisonModel.js — 월간 비교·확인·정산
const { getDb } = require('../dbPool');
const MonthlyComparisonModel = {
// 0. 해당 월의 회사 휴무일 조회
async getCompanyHolidays(year, month) {
const db = await getDb();
const [rows] = await db.query(
`SELECT holiday_date, holiday_name FROM company_holidays
WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?`,
[year, month]
);
const dateSet = new Set();
const nameMap = {};
rows.forEach(r => {
const d = r.holiday_date instanceof Date
? r.holiday_date.toISOString().split('T')[0]
: String(r.holiday_date).split('T')[0];
dateSet.add(d);
nameMap[d] = r.holiday_name;
});
return { dateSet, nameMap };
},
// 1. 작업보고서 일별 합산
async getWorkReports(userId, year, month) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
dwr.report_date,
SUM(dwr.work_hours) AS total_hours,
GROUP_CONCAT(DISTINCT p.project_name SEPARATOR ', ') AS project_names,
GROUP_CONCAT(DISTINCT wt.name SEPARATOR ', ') AS work_type_names
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE dwr.user_id = ?
AND YEAR(dwr.report_date) = ?
AND MONTH(dwr.report_date) = ?
GROUP BY dwr.report_date
ORDER BY dwr.report_date
`, [userId, year, month]);
return rows;
},
// 2. 근태관리 일별 기록
async getAttendanceRecords(userId, year, month) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
dar.record_date,
dar.total_work_hours,
dar.attendance_type_id,
dar.vacation_type_id,
dar.status,
dar.is_present,
dar.notes,
wat.type_name AS attendance_type_name,
vt.type_name AS vacation_type_name,
vt.deduct_days AS vacation_days
FROM daily_attendance_records dar
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.user_id = ?
AND YEAR(dar.record_date) = ?
AND MONTH(dar.record_date) = ?
ORDER BY dar.record_date
`, [userId, year, month]);
return rows;
},
// 3. 확인 상태 조회
async getConfirmation(userId, year, month) {
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
[userId, year, month]
);
return rows[0] || null;
},
// 4. 확인 UPSERT + 반려 시 알림 (단일 트랜잭션)
async upsertConfirmation(data, notificationData) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 기존 상태 체크 + 전환 검증
const [existing] = await conn.query(
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ? FOR UPDATE',
[data.user_id, data.year, data.month]
);
const currentStatus = existing.length > 0 ? existing[0].status : null;
if (currentStatus === 'confirmed') {
await conn.rollback();
return { error: '이미 확인된 내역은 변경할 수 없습니다.' };
}
// 작업자 확인: review_sent 또는 rejected 상태에서만 가능
if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') {
await conn.rollback();
return { error: '관리자 확인요청 후에 확인할 수 있습니다.' };
}
// 작업자 수정요청: review_sent 상태에서만 가능
if (data.status === 'change_request' && currentStatus !== 'review_sent') {
await conn.rollback();
return { error: '확인요청 상태에서만 수정요청이 가능합니다.' };
}
// UPSERT
const [result] = await conn.query(`
INSERT INTO monthly_work_confirmations
(user_id, year, month, status, total_work_days, total_work_hours,
total_overtime_hours, vacation_days, mismatch_count, reject_reason,
confirmed_at, rejected_at, change_details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
total_work_days = VALUES(total_work_days),
total_work_hours = VALUES(total_work_hours),
total_overtime_hours = VALUES(total_overtime_hours),
vacation_days = VALUES(vacation_days),
mismatch_count = VALUES(mismatch_count),
reject_reason = VALUES(reject_reason),
confirmed_at = VALUES(confirmed_at),
rejected_at = VALUES(rejected_at),
change_details = VALUES(change_details)
`, [
data.user_id, data.year, data.month, data.status,
data.total_work_days || 0, data.total_work_hours || 0,
data.total_overtime_hours || 0, data.vacation_days || 0,
data.mismatch_count || 0, data.reject_reason || null,
data.status === 'confirmed' ? new Date() : null,
data.status === 'rejected' ? new Date() : null,
data.change_details || null
]);
const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null);
// 알림 생성 (반려 또는 수정요청)
if (notificationData && confirmationId) {
const { recipients, title, message, linkUrl, createdBy } = notificationData;
for (const recipientId of recipients) {
await conn.query(`
INSERT INTO notifications
(user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)
`, [recipientId, title, message, linkUrl, confirmationId, createdBy]);
}
}
await conn.commit();
return { id: confirmationId, status: data.status };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// 관리자: 확인요청 발송 (pending → review_sent)
async bulkReviewSend(year, month, userIds, reviewedBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체)
let targetIds = userIds || [];
if (!targetIds.length) {
const [pendingRows] = await conn.query(
`SELECT DISTINCT w.user_id FROM workers w
LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
WHERE w.status = 'active' AND w.user_id IS NOT NULL
AND (mwc.status IS NULL OR mwc.status = 'pending')`,
[year, month]
);
targetIds = pendingRows.map(r => r.user_id);
}
if (!targetIds.length) {
await conn.rollback();
return { count: 0, message: '대상 작업자가 없습니다.' };
}
// 상태 전환 + 알림 생성
for (const uid of targetIds) {
await conn.query(
`INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at)
VALUES (?, ?, ?, 'review_sent', ?, NOW())
ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`,
[uid, year, month, reviewedBy, reviewedBy]
);
await conn.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by)
VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`,
[uid, `${year}${month}월 근무 내역을 확인해주세요.`, reviewedBy]
);
}
await conn.commit();
return { count: targetIds.length };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
async reviewRespond(userId, year, month, action, rejectReason, respondedBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [existing] = await conn.query(
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
[userId, year, month]
);
if (!existing.length || existing[0].status !== 'change_request') {
await conn.rollback();
return { error: '수정요청 상태가 아닙니다.' };
}
var newStatus = action === 'approve' ? 'review_sent' : 'rejected';
await conn.query(
`UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(),
reject_reason = ?, change_details = NULL WHERE id = ?`,
[newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id]
);
// 작업자에게 알림
var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부';
var message = action === 'approve'
? `${year}${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.`
: `${year}${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`;
await conn.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)`,
[userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy]
);
await conn.commit();
return { status: newStatus };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// 5. 전체 작업자 확인 현황 (실제 근태 데이터 집계 포함)
async getAllStatus(year, month, departmentId) {
const db = await getDb();
let sql = `
SELECT
w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name,
COALESCE(mwc.status, 'pending') AS status,
mwc.confirmed_at, mwc.rejected_at, mwc.reject_reason,
mwc.change_details, COALESCE(mwc.admin_checked, 0) AS admin_checked,
COALESCE(att.work_days, 0) AS total_work_days,
COALESCE(att.work_hours, 0) AS total_work_hours,
COALESCE(att.overtime_hours, 0) AS total_overtime_hours,
COALESCE(att.vac_days, 0) AS vacation_days
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN monthly_work_confirmations mwc
ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
LEFT JOIN (
SELECT dar.user_id,
COUNT(CASE WHEN dar.total_work_hours > 0 THEN 1 END) AS work_days,
COALESCE(SUM(dar.total_work_hours), 0) AS work_hours,
COALESCE(SUM(CASE WHEN dar.total_work_hours > 8 THEN dar.total_work_hours - 8 ELSE 0 END), 0) AS overtime_hours,
COALESCE(SUM(CASE WHEN vt.deduct_days IS NOT NULL THEN vt.deduct_days ELSE 0 END), 0) AS vac_days
FROM daily_attendance_records dar
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
GROUP BY dar.user_id
) att ON w.user_id = att.user_id
WHERE w.status = 'active' AND w.user_id IS NOT NULL
`;
const params = [year, month, year, month];
if (departmentId) {
sql += ' AND w.department_id = ?';
params.push(departmentId);
}
sql += ' ORDER BY d.department_name, w.worker_name';
const [rows] = await db.query(sql, params);
return rows;
},
// 5b. 관리자 개별 검토 태깅
async adminCheck(userId, year, month, checked, checkedBy) {
const db = await getDb();
await db.query(`
INSERT INTO monthly_work_confirmations (user_id, year, month, status, admin_checked)
VALUES (?, ?, ?, 'pending', ?)
ON DUPLICATE KEY UPDATE admin_checked = ?
`, [userId, year, month, checked ? 1 : 0, checked ? 1 : 0]);
return { admin_checked: checked };
},
// 6. 지원팀 사용자 목록 (알림 수신자)
async getSupportTeamUsers() {
const db = await getDb();
const [rows] = await db.query(
"SELECT user_id FROM sso_users WHERE role IN ('support_team', 'admin', 'system') AND is_active = 1"
);
return rows.map(r => r.user_id);
},
// 7. 출근부 엑셀용 — 작업자 목록 + 일별 근태 + 연차잔액
async getExportData(year, month, departmentId) {
const db = await getDb();
// (a) 해당 부서 활성 작업자 (worker_id 순)
let workerSql = `
SELECT w.user_id, w.worker_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.status = 'active'
`;
const workerParams = [];
if (departmentId) { workerSql += ' AND w.department_id = ?'; workerParams.push(departmentId); }
workerSql += ' ORDER BY w.worker_id';
const [workers] = await db.query(workerSql, workerParams);
if (workers.length === 0) return { workers: [], attendance: [], vacations: [] };
const userIds = workers.map(w => w.user_id);
const placeholders = userIds.map(() => '?').join(',');
// (b) 일별 근태 기록
const [attendance] = await db.query(`
SELECT dar.user_id, dar.record_date,
dar.total_work_hours,
dar.attendance_type_id,
dar.vacation_type_id,
vt.type_code AS vacation_type_code,
vt.type_name AS vacation_type_name,
vt.deduct_days
FROM daily_attendance_records dar
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.user_id IN (${placeholders})
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
ORDER BY dar.user_id, dar.record_date
`, [...userIds, year, month]);
// (c) 연차 잔액 (sp_vacation_balances)
const [vacations] = await db.query(`
SELECT svb.user_id,
SUM(svb.total_days) AS total_days,
SUM(svb.used_days) AS used_days,
SUM(svb.total_days - svb.used_days) AS remaining_days
FROM sp_vacation_balances svb
WHERE svb.user_id IN (${placeholders}) AND svb.year = ?
GROUP BY svb.user_id
`, [...userIds, year]);
return { workers, attendance, vacations };
},
// 8. 작업자 정보
async getWorkerInfo(userId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.user_id = ?
`, [userId]);
return rows[0] || null;
}
};
module.exports = MonthlyComparisonModel;

View File

@@ -19,7 +19,6 @@ const PageAccessModel = {
FROM pages p FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users granter ON upa.granted_by = granter.user_id LEFT JOIN users granter ON upa.granted_by = granter.user_id
WHERE p.is_admin_only = 0
ORDER BY p.category, p.display_order ORDER BY p.category, p.display_order
`; `;
@@ -39,7 +38,6 @@ const PageAccessModel = {
is_admin_only, is_admin_only,
display_order display_order
FROM pages FROM pages
WHERE is_admin_only = 0
ORDER BY category, display_order ORDER BY category, display_order
`; `;

View File

@@ -0,0 +1,242 @@
/**
* 대리입력 + 일별 현황 모델
*/
const { getDb } = require('../dbPool');
const ProxyInputModel = {
/**
* 중복 배정 체크 (같은 날짜 + 같은 작업자)
*/
checkDuplicateAssignments: async (conn, sessionDate, userIds) => {
if (!userIds.length) return [];
const placeholders = userIds.map(() => '?').join(',');
const [rows] = await conn.query(`
SELECT ta.user_id, w.worker_name, ta.session_id
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
JOIN workers w ON ta.user_id = w.user_id
WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled'
`, [sessionDate, ...userIds]);
return rows;
},
/**
* 작업자 존재 여부 체크
*/
validateWorkers: async (conn, userIds) => {
if (!userIds.length) return [];
const placeholders = userIds.map(() => '?').join(',');
const [rows] = await conn.query(`
SELECT user_id FROM workers WHERE user_id IN (${placeholders}) AND status = 'active'
`, [...userIds]);
return rows.map(r => r.user_id);
},
/**
* TBM 세션 생성 (대리입력)
*/
createProxySession: async (conn, data) => {
const [result] = await conn.query(`
INSERT INTO tbm_sessions (session_date, leader_user_id, status, is_proxy_input, proxy_input_by, created_by, safety_notes, work_location)
VALUES (?, ?, 'completed', 1, ?, ?, ?, ?)
`, [data.session_date, data.leader_id, data.proxy_input_by, data.created_by, data.safety_notes || '', data.work_location || '']);
return result;
},
/**
* 팀 배정 생성
*/
createTeamAssignment: async (conn, data) => {
const [result] = await conn.query(`
INSERT INTO tbm_team_assignments (session_id, user_id, project_id, work_type_id, task_id, workplace_id, work_hours, is_present)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`, [data.session_id, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.workplace_id || null, data.work_hours]);
return result;
},
/**
* 작업보고서 생성 (accumulative)
*/
createWorkReport: async (conn, data) => {
const [result] = await conn.query(`
INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_by_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by, data.created_by_name || '']);
return result;
},
/**
* 일별 현황 조회
*/
getDailyStatus: async (date) => {
const db = await getDb();
// 1. 활성 작업자
const [workers] = await db.query(`
SELECT w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.status = 'active' AND w.user_id IS NOT NULL
ORDER BY w.worker_name
`);
// 2. TBM 배정 현황
const [tbmAssignments] = await db.query(`
SELECT ta.user_id, ta.session_id, s.leader_user_id,
lu.worker_name AS leader_name, s.is_proxy_input
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
WHERE s.session_date = ? AND s.status != 'cancelled'
`, [date]);
// 3. 작업보고서 현황
const [reports] = await db.query(`
SELECT dwr.user_id, SUM(dwr.work_hours) AS total_hours, COUNT(*) AS entry_count
FROM daily_work_reports dwr
WHERE dwr.report_date = ?
GROUP BY dwr.user_id
`, [date]);
// 4. 해당 날짜의 연차 기록
const [vacationRecords] = await db.query(`
SELECT dar.user_id, dar.vacation_type_id,
vt.type_code AS vacation_type_code,
vt.type_name AS vacation_type_name,
vt.deduct_days
FROM daily_attendance_records dar
JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.record_date = ? AND dar.vacation_type_id IS NOT NULL
`, [date]);
// 5. 해당 날짜가 회사 휴무일인지 확인
const [holidayRows] = await db.query(
`SELECT holiday_date, holiday_name FROM company_holidays WHERE holiday_date = ?`,
[date]
);
const isCompanyHoliday = holidayRows.length > 0;
const holidayName = isCompanyHoliday ? holidayRows[0].holiday_name : null;
const dateObj = new Date(date);
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
// 메모리에서 조합
const tbmMap = {};
tbmAssignments.forEach(ta => {
if (!tbmMap[ta.user_id]) tbmMap[ta.user_id] = [];
tbmMap[ta.user_id].push(ta);
});
const reportMap = {};
reports.forEach(r => { reportMap[r.user_id] = r; });
const vacMap = {};
vacationRecords.forEach(v => { vacMap[v.user_id] = v; });
let tbmCompleted = 0, reportCompleted = 0, bothCompleted = 0, bothMissing = 0;
const workerList = workers.map(w => {
const hasTbm = !!tbmMap[w.user_id];
const hasReport = !!reportMap[w.user_id];
const tbmSessions = (tbmMap[w.user_id] || []).map(ta => ({
session_id: ta.session_id,
leader_name: ta.leader_name,
is_proxy_input: !!ta.is_proxy_input
}));
const totalReportHours = reportMap[w.user_id]?.total_hours || 0;
const vac = vacMap[w.user_id] || null;
let status = 'both_missing';
if (hasTbm && hasReport) { status = 'complete'; bothCompleted++; }
else if (hasTbm && !hasReport) { status = 'tbm_only'; }
else if (!hasTbm && hasReport) { status = 'report_only'; }
else { bothMissing++; }
if (hasTbm) tbmCompleted++;
if (hasReport) reportCompleted++;
return {
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
department_name: w.department_name, has_tbm: hasTbm, has_report: hasReport,
tbm_sessions: tbmSessions, total_report_hours: totalReportHours, status,
vacation_type_id: vac ? vac.vacation_type_id : null,
vacation_type_code: vac ? vac.vacation_type_code : null,
vacation_type_name: vac ? vac.vacation_type_name : null,
vacation_hours: vac ? (8 - parseFloat(vac.deduct_days) * 8) : null
};
});
return {
date,
is_holiday: isWeekend || isCompanyHoliday,
holiday_name: isCompanyHoliday ? holidayName : (isWeekend ? '주말' : null),
summary: {
total_active_workers: workers.length,
tbm_completed: tbmCompleted,
tbm_missing: workers.length - tbmCompleted,
report_completed: reportCompleted,
report_missing: workers.length - reportCompleted,
both_completed: bothCompleted,
both_missing: bothMissing
},
workers: workerList
};
},
/**
* 작업자별 상세 조회
*/
getDailyStatusDetail: async (date, userId) => {
const db = await getDb();
// 작업자 정보
const [workerRows] = await db.query(`
SELECT w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.user_id = ?
`, [userId]);
// TBM 세션
const [tbmSessions] = await db.query(`
SELECT ta.session_id, s.status, s.is_proxy_input,
lu.worker_name AS leader_name,
pu.name AS proxy_input_by_name,
p.project_name, wt.work_type_name, ta.work_hours
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id
WHERE s.session_date = ? AND ta.user_id = ? AND s.status != 'cancelled'
`, [date, userId]);
// 작업보고서
const [workReports] = await db.query(`
SELECT dwr.report_id, dwr.work_hours, dwr.created_at, dwr.created_by,
cu.name AS created_by_name,
p.project_name, wt.work_type_name, t.task_name,
ws.status_name AS work_status,
s.is_proxy_input
FROM daily_work_reports dwr
LEFT JOIN sso_users cu ON dwr.created_by = cu.user_id
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.work_type_id
LEFT JOIN tasks t ON dwr.task_id = t.task_id
LEFT JOIN work_statuses ws ON dwr.work_status_id = ws.work_status_id
LEFT JOIN tbm_sessions s ON dwr.tbm_session_id = s.session_id
WHERE dwr.report_date = ? AND dwr.user_id = ?
ORDER BY dwr.created_at
`, [date, userId]);
return {
worker: workerRows[0] || null,
tbm_sessions: tbmSessions,
work_reports: workReports
};
}
};
module.exports = ProxyInputModel;

View File

@@ -0,0 +1,117 @@
// models/purchaseBatchModel.js
const { getDb } = require('../dbPool');
const PurchaseBatchModel = {
async getAll(filters = {}) {
const db = await getDb();
let sql = `
SELECT pb.*, su.name AS created_by_name,
v.vendor_name,
(SELECT COUNT(*) FROM purchase_requests WHERE batch_id = pb.batch_id) AS request_count
FROM purchase_batches pb
LEFT JOIN sso_users su ON pb.created_by = su.user_id
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
WHERE 1=1
`;
const params = [];
if (filters.status) { sql += ' AND pb.status = ?'; params.push(filters.status); }
sql += ' ORDER BY pb.created_at DESC';
const [rows] = await db.query(sql, params);
return rows;
},
async getById(batchId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT pb.*, su.name AS created_by_name, v.vendor_name
FROM purchase_batches pb
LEFT JOIN sso_users su ON pb.created_by = su.user_id
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
WHERE pb.batch_id = ?
`, [batchId]);
return rows[0] || null;
},
async create({ batchName, category, vendorId, notes, createdBy }) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO purchase_batches (batch_name, category, vendor_id, notes, created_by)
VALUES (?, ?, ?, ?, ?)`,
[batchName || null, category || null, vendorId || null, notes || null, createdBy]
);
return result.insertId;
},
async update(batchId, { batchName, category, vendorId, notes }) {
const db = await getDb();
await db.query(
`UPDATE purchase_batches SET batch_name = ?, category = ?, vendor_id = ?, notes = ?
WHERE batch_id = ? AND status = 'pending'`,
[batchName || null, category || null, vendorId || null, notes || null, batchId]
);
return this.getById(batchId);
},
async delete(batchId) {
const db = await getDb();
// pending 상태만 삭제 가능
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
if (!batch.length || batch[0].status !== 'pending') return false;
// 포함된 요청 복원
await db.query(
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending' WHERE batch_id = ?`,
[batchId]
);
await db.query('DELETE FROM purchase_batches WHERE batch_id = ?', [batchId]);
return true;
},
async markPurchased(batchId, purchasedBy) {
const db = await getDb();
await db.query(
`UPDATE purchase_batches SET status = 'purchased', purchased_at = NOW(), purchased_by = ?
WHERE batch_id = ? AND status = 'pending'`,
[purchasedBy, batchId]
);
},
async markReceived(batchId, receivedBy) {
const db = await getDb();
await db.query(
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ?
WHERE batch_id = ? AND status = 'purchased'`,
[receivedBy, batchId]
);
},
// batch에 요청 추가 (검증: pending이고 다른 batch에 속하지 않음)
async addRequests(batchId, requestIds) {
const db = await getDb();
const [existing] = await db.query(
`SELECT request_id, batch_id, status FROM purchase_requests WHERE request_id IN (?)`,
[requestIds]
);
const invalid = existing.filter(r => r.status !== 'pending' || r.batch_id !== null);
if (invalid.length > 0) {
const ids = invalid.map(r => r.request_id);
throw new Error(`다음 요청은 추가할 수 없습니다 (이미 그룹 소속이거나 대기 상태가 아님): ${ids.join(', ')}`);
}
const PurchaseRequestModel = require('./purchaseRequestModel');
await PurchaseRequestModel.groupIntoBatch(requestIds, batchId);
},
// batch에서 요청 제거
async removeRequests(batchId, requestIds) {
const db = await getDb();
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
if (!batch.length || batch[0].status !== 'pending') {
throw new Error('진행중인 그룹에서만 요청을 제거할 수 있습니다.');
}
const PurchaseRequestModel = require('./purchaseRequestModel');
await PurchaseRequestModel.removeFromBatch(requestIds);
}
};
module.exports = PurchaseBatchModel;

View File

@@ -2,17 +2,19 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const PurchaseRequestModel = { const PurchaseRequestModel = {
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용) // 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용, batch 정보 포함)
async getAll(filters = {}) { async getAll(filters = {}) {
const db = await getDb(); const db = await getDb();
let sql = ` let sql = `
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit, SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path, ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
pr.custom_item_name, pr.custom_category, pr.custom_item_name, pr.custom_category,
su.name AS requester_name su.name AS requester_name,
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category
FROM purchase_requests pr FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id LEFT JOIN sso_users su ON pr.requester_id = su.user_id
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
WHERE 1=1 WHERE 1=1
`; `;
const params = []; const params = [];
@@ -25,23 +27,28 @@ const PurchaseRequestModel = {
} }
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); } if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); } if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
if (filters.batch_id) { sql += ' AND pr.batch_id = ?'; params.push(filters.batch_id); }
sql += ' ORDER BY pr.created_at DESC'; sql += ' ORDER BY pr.created_at DESC';
const [rows] = await db.query(sql, params); const [rows] = await db.query(sql, params);
return rows; return rows;
}, },
// 단건 조회 // 단건 조회 (batch 정보 포함)
async getById(requestId) { async getById(requestId) {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit, SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path, ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
pr.custom_item_name, pr.custom_category, pr.custom_item_name, pr.custom_category,
su.name AS requester_name su.name AS requester_name,
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category,
rsu.name AS received_by_name
FROM purchase_requests pr FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id LEFT JOIN sso_users su ON pr.requester_id = su.user_id
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
WHERE pr.request_id = ? WHERE pr.request_id = ?
`, [requestId]); `, [requestId]);
return rows[0] || null; return rows[0] || null;
@@ -105,6 +112,164 @@ const PurchaseRequestModel = {
[requestId] [requestId]
); );
return result.affectedRows > 0; return result.affectedRows > 0;
},
// 내 신청 목록 (모바일용, 페이지네이션)
async getMyRequests(userId, { page = 1, limit = 20, status } = {}) {
const db = await getDb();
const offset = (page - 1) * limit;
let where = 'WHERE pr.requester_id = ?';
const params = [userId];
if (status) { where += ' AND pr.status = ?'; params.push(status); }
const [[{ total }]] = await db.query(
`SELECT COUNT(*) AS total FROM purchase_requests pr ${where}`, params
);
const [rows] = await db.query(`
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
pr.custom_item_name, pr.custom_category,
pb.batch_name, pb.status AS batch_status,
rsu.name AS received_by_name
FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
${where}
ORDER BY pr.created_at DESC
LIMIT ? OFFSET ?
`, [...params, limit, offset]);
return {
data: rows,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
};
},
// batch에 요청 그룹화 (status → grouped)
async groupIntoBatch(requestIds, batchId) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests SET batch_id = ?, status = 'grouped'
WHERE request_id IN (?) AND status = 'pending' AND batch_id IS NULL`,
[batchId, requestIds]
);
},
// batch에서 제거 (status → pending 복원)
async removeFromBatch(requestIds) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending'
WHERE request_id IN (?) AND status = 'grouped'`,
[requestIds]
);
},
// batch 내 전체 요청 purchased 전환
async markBatchPurchased(batchId) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests SET status = 'purchased' WHERE batch_id = ? AND status = 'grouped'`,
[batchId]
);
},
// 개별 입고 처리
async receive(requestId, { receivedPhotoPath, receivedLocation, receivedBy }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'received', received_photo_path = ?, received_location = ?,
received_at = NOW(), received_by = ?
WHERE request_id = ? AND status = 'purchased'`,
[receivedPhotoPath || null, receivedLocation || null, receivedBy, requestId]
);
return this.getById(requestId);
},
// batch 내 전체 입고 처리
async receiveBatch(batchId, { receivedPhotoPath, receivedLocation, receivedBy }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'received', received_photo_path = ?, received_location = ?,
received_at = NOW(), received_by = ?
WHERE batch_id = ? AND status = 'purchased'`,
[receivedPhotoPath || null, receivedLocation || null, receivedBy, batchId]
);
},
// batch 내 모든 요청이 received인지 확인
async checkBatchAllReceived(batchId) {
const db = await getDb();
const [[{ total, received }]] = await db.query(
`SELECT COUNT(*) AS total,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) AS received
FROM purchase_requests WHERE batch_id = ?`,
[batchId]
);
return total > 0 && total === received;
},
// grouped 상태에서 hold (batch에서 자동 제거)
async holdFromGrouped(requestId, holdReason) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests SET status = 'hold', hold_reason = ?, batch_id = NULL
WHERE request_id = ? AND status = 'grouped'`,
[holdReason || null, requestId]
);
return this.getById(requestId);
},
// batch 내 신청자 ID 목록 조회
async getRequesterIdsByBatch(batchId) {
const db = await getDb();
const [rows] = await db.query(
`SELECT DISTINCT requester_id FROM purchase_requests WHERE batch_id = ?`,
[batchId]
);
return rows.map(r => r.requester_id);
},
// 구매 취소 (purchased → pending 복원, batch에서도 제거)
async cancelPurchase(requestId, { cancelledBy, cancelReason }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?,
batch_id = NULL
WHERE request_id = ? AND status = 'purchased'`,
[cancelledBy, cancelReason || null, requestId]
);
return this.getById(requestId);
},
// 반품 (received → returned)
async returnItem(requestId, { cancelledBy, cancelReason }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'returned', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?
WHERE request_id = ? AND status = 'received'`,
[cancelledBy, cancelReason || null, requestId]
);
return this.getById(requestId);
},
// 취소/반품에서 원래 상태로 되돌리기
async revertCancel(requestId) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'pending', cancelled_at = NULL, cancelled_by = NULL, cancel_reason = NULL
WHERE request_id = ? AND status = 'cancelled'`,
[requestId]
);
return this.getById(requestId);
} }
}; };

View File

@@ -83,6 +83,47 @@ const SettlementModel = {
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' }; return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
}, },
// 입고일 기준 월간 분류별 요약
async getCategorySummaryByReceived(yearMonth) {
const db = await getDb();
const [rows] = await db.query(`
SELECT ci.category,
COUNT(*) AS count,
SUM(pr.quantity * COALESCE(p.unit_price, 0)) AS total_amount
FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN purchases p ON p.request_id = pr.request_id
WHERE pr.status = 'received'
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
GROUP BY ci.category
`, [yearMonth]);
return rows;
},
// 입고일 기준 월간 상세 목록
async getMonthlyReceived(yearMonth) {
const db = await getDb();
const [rows] = await db.query(`
SELECT pr.request_id, pr.quantity, pr.received_at, pr.received_location,
pr.received_photo_path, pr.status, pr.notes,
ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price,
p.unit_price, p.purchase_date, p.vendor_id,
v.vendor_name,
su.name AS requester_name,
rsu.name AS received_by_name
FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN purchases p ON p.request_id = pr.request_id
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
WHERE pr.status IN ('received', 'returned')
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
ORDER BY pr.received_at DESC
`, [yearMonth]);
return rows;
},
// 가격 변동 목록 (월간) // 가격 변동 목록 (월간)
async getPriceChanges(yearMonth) { async getPriceChanges(yearMonth) {
const db = await getDb(); const db = await getDb();

View File

@@ -13,14 +13,15 @@ const vacationBalanceModel = {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
vbd.*, svb.id, svb.user_id, svb.vacation_type_id, svb.year,
vt.type_name, svb.total_days, svb.used_days,
vt.type_code, (svb.total_days - svb.used_days) AS remaining_days,
vt.priority, svb.balance_type, svb.expires_at, svb.notes,
vt.is_special svb.created_by, svb.created_at, svb.updated_at,
FROM vacation_balance_details vbd vt.type_name, vt.type_code, vt.priority, vt.is_special
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id FROM sp_vacation_balances svb
WHERE vbd.user_id = ? AND vbd.year = ? INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.user_id = ? AND svb.year = ?
ORDER BY vt.priority ASC, vt.type_name ASC ORDER BY vt.priority ASC, vt.type_name ASC
`, [userId, year]); `, [userId, year]);
return rows; return rows;
@@ -33,14 +34,16 @@ const vacationBalanceModel = {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
vbd.*, svb.id, svb.user_id, svb.vacation_type_id, svb.year,
vt.type_name, svb.total_days, svb.used_days,
vt.type_code (svb.total_days - svb.used_days) AS remaining_days,
FROM vacation_balance_details vbd svb.balance_type, svb.expires_at,
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id vt.type_name, vt.type_code
WHERE vbd.user_id = ? FROM sp_vacation_balances svb
AND vbd.vacation_type_id = ? INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
AND vbd.year = ? WHERE svb.user_id = ?
AND svb.vacation_type_id = ?
AND svb.year = ?
`, [userId, vacationTypeId, year]); `, [userId, vacationTypeId, year]);
return rows; return rows;
}, },
@@ -52,16 +55,17 @@ const vacationBalanceModel = {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
vbd.*, svb.id, svb.user_id, svb.vacation_type_id, svb.year,
w.worker_name, svb.total_days, svb.used_days,
w.employment_status, (svb.total_days - svb.used_days) AS remaining_days,
vt.type_name, svb.balance_type, svb.expires_at, svb.notes,
vt.type_code, svb.created_by, svb.created_at, svb.updated_at,
vt.priority w.worker_name, w.employment_status,
FROM vacation_balance_details vbd vt.type_name, vt.type_code, vt.priority
INNER JOIN workers w ON vbd.user_id = w.user_id FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id INNER JOIN workers w ON svb.user_id = w.user_id
WHERE vbd.year = ? INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.year = ?
AND w.employment_status = 'employed' AND w.employment_status = 'employed'
ORDER BY w.worker_name ASC, vt.priority ASC ORDER BY w.worker_name ASC, vt.priority ASC
`, [year]); `, [year]);
@@ -73,7 +77,7 @@ const vacationBalanceModel = {
*/ */
async create(balanceData) { async create(balanceData) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(`INSERT INTO vacation_balance_details SET ?`, balanceData); const [result] = await db.query(`INSERT INTO sp_vacation_balances SET ?`, balanceData);
return result; return result;
}, },
@@ -82,7 +86,7 @@ const vacationBalanceModel = {
*/ */
async update(id, updateData) { async update(id, updateData) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(`UPDATE vacation_balance_details SET ? WHERE id = ?`, [updateData, id]); const [result] = await db.query(`UPDATE sp_vacation_balances SET ? WHERE id = ?`, [updateData, id]);
return result; return result;
}, },
@@ -91,7 +95,7 @@ const vacationBalanceModel = {
*/ */
async delete(id) { async delete(id) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(`DELETE FROM vacation_balance_details WHERE id = ?`, [id]); const [result] = await db.query(`DELETE FROM sp_vacation_balances WHERE id = ?`, [id]);
return result; return result;
}, },
@@ -101,7 +105,7 @@ const vacationBalanceModel = {
async deductDays(userId, vacationTypeId, year, daysToDeduct) { async deductDays(userId, vacationTypeId, year, daysToDeduct) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(` const [result] = await db.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days + ?, SET used_days = used_days + ?,
updated_at = NOW() updated_at = NOW()
WHERE user_id = ? WHERE user_id = ?
@@ -117,7 +121,7 @@ const vacationBalanceModel = {
async restoreDays(userId, vacationTypeId, year, daysToRestore) { async restoreDays(userId, vacationTypeId, year, daysToRestore) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(` const [result] = await db.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = GREATEST(0, used_days - ?), SET used_days = GREATEST(0, used_days - ?),
updated_at = NOW() updated_at = NOW()
WHERE user_id = ? WHERE user_id = ?
@@ -134,20 +138,21 @@ const vacationBalanceModel = {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
vbd.id, svb.id,
vbd.vacation_type_id, svb.vacation_type_id,
vt.type_name, vt.type_name,
vt.type_code, vt.type_code,
vt.priority, vt.priority,
vbd.total_days, svb.total_days,
vbd.used_days, svb.used_days,
vbd.remaining_days (svb.total_days - svb.used_days) AS remaining_days,
FROM vacation_balance_details vbd svb.balance_type
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id FROM sp_vacation_balances svb
WHERE vbd.user_id = ? INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
AND vbd.year = ? WHERE svb.user_id = ?
AND vbd.remaining_days > 0 AND svb.year = ?
ORDER BY vt.priority ASC AND (svb.total_days - svb.used_days) > 0
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
`, [userId, year]); `, [userId, year]);
return rows; return rows;
}, },
@@ -161,8 +166,8 @@ const vacationBalanceModel = {
} }
const db = await getDb(); const db = await getDb();
const query = `INSERT INTO vacation_balance_details const query = `INSERT INTO sp_vacation_balances
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by) (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
VALUES ?`; VALUES ?`;
const values = balances.map(b => [ const values = balances.map(b => [
@@ -172,7 +177,8 @@ const vacationBalanceModel = {
b.total_days || 0, b.total_days || 0,
b.used_days || 0, b.used_days || 0,
b.notes || null, b.notes || null,
b.created_by b.created_by,
b.balance_type || 'AUTO'
]); ]);
const [result] = await db.query(query, [values]); const [result] = await db.query(query, [values]);
@@ -204,19 +210,26 @@ const vacationBalanceModel = {
*/ */
async deductByPriority(userId, year, daysToDeduct) { async deductByPriority(userId, year, daysToDeduct) {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [balances] = await db.query(` const [balances] = await conn.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days, SELECT svb.id, svb.vacation_type_id, svb.total_days, svb.used_days,
(vbd.total_days - vbd.used_days) as remaining_days, (svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type,
vt.type_code, vt.type_name, vt.priority vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE vbd.user_id = ? AND vbd.year = ? WHERE svb.user_id = ? AND svb.year = ?
AND (vbd.total_days - vbd.used_days) > 0 AND (svb.total_days - svb.used_days) > 0
ORDER BY vt.priority ASC AND (svb.expires_at IS NULL OR svb.expires_at >= CURDATE())
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
FOR UPDATE
`, [userId, year]); `, [userId, year]);
if (balances.length === 0) { if (balances.length === 0) {
await conn.rollback();
console.warn(`[VacationBalance] 작업자 ${userId}${year}년 휴가 잔액이 없습니다`); console.warn(`[VacationBalance] 작업자 ${userId}${year}년 휴가 잔액이 없습니다`);
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 }; return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
} }
@@ -226,30 +239,28 @@ const vacationBalanceModel = {
for (const balance of balances) { for (const balance of balances) {
if (remaining <= 0) break; if (remaining <= 0) break;
const available = parseFloat(balance.remaining_days); const available = parseFloat(balance.remaining_days);
const toDeduct = Math.min(remaining, available); const toDeduct = Math.min(remaining, available);
if (toDeduct > 0) { if (toDeduct > 0) {
await db.query(` await conn.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days + ?, updated_at = NOW() SET used_days = used_days + ?, updated_at = NOW()
WHERE id = ? WHERE id = ?
`, [toDeduct, balance.id]); `, [toDeduct, balance.id]);
deductions.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, deducted: toDeduct });
deductions.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
deducted: toDeduct
});
remaining -= toDeduct; remaining -= toDeduct;
} }
} }
await conn.commit();
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions); console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
return { success: true, deductions, totalDeducted: daysToDeduct - remaining }; return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}, },
/** /**
@@ -257,15 +268,20 @@ const vacationBalanceModel = {
*/ */
async restoreByPriority(userId, year, daysToRestore) { async restoreByPriority(userId, year, daysToRestore) {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [balances] = await db.query(` const [balances] = await conn.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days, SELECT svb.id, svb.vacation_type_id, svb.used_days,
svb.balance_type,
vt.type_code, vt.type_name, vt.priority vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE vbd.user_id = ? AND vbd.year = ? WHERE svb.user_id = ? AND svb.year = ?
AND vbd.used_days > 0 AND svb.used_days > 0
ORDER BY vt.priority DESC ORDER BY vt.priority DESC, FIELD(svb.balance_type, 'COMPANY_GRANT', 'LONG_SERVICE', 'MANUAL', 'AUTO', 'CARRY_OVER')
FOR UPDATE
`, [userId, year]); `, [userId, year]);
let remaining = daysToRestore; let remaining = daysToRestore;
@@ -273,30 +289,28 @@ const vacationBalanceModel = {
for (const balance of balances) { for (const balance of balances) {
if (remaining <= 0) break; if (remaining <= 0) break;
const usedDays = parseFloat(balance.used_days); const usedDays = parseFloat(balance.used_days);
const toRestore = Math.min(remaining, usedDays); const toRestore = Math.min(remaining, usedDays);
if (toRestore > 0) { if (toRestore > 0) {
await db.query(` await conn.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days - ?, updated_at = NOW() SET used_days = used_days - ?, updated_at = NOW()
WHERE id = ? WHERE id = ?
`, [toRestore, balance.id]); `, [toRestore, balance.id]);
restorations.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, restored: toRestore });
restorations.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
restored: toRestore
});
remaining -= toRestore; remaining -= toRestore;
} }
} }
await conn.commit();
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations); console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
return { success: true, restorations, totalRestored: daysToRestore - remaining }; return { success: true, restorations, totalRestored: daysToRestore - remaining };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}, },
/** /**

View File

@@ -26,6 +26,7 @@
"compression": "^1.8.1", "compression": "^1.8.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"exceljs": "^4.4.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",

View File

@@ -153,6 +153,9 @@ function setupRoutes(app) {
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리 app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황
app.use('/api/monthly-comparison', require('./routes/monthlyComparisonRoutes')); // 월간 비교·확인·정산
app.use('/api/dashboard', require('./routes/dashboardRoutes')); // 대시보드 개인 요약
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/consumableCategoryController');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
router.get('/', ctrl.getAll);
router.post('/', requirePage('factory_purchases'), ctrl.create);
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
router.put('/:id/deactivate', requirePage('factory_purchases'), ctrl.deactivate);
module.exports = router;

View File

@@ -0,0 +1,13 @@
/**
* 대시보드 라우터
* Sprint 003 — 개인 요약 API
*/
const express = require('express');
const router = express.Router();
const dashboardController = require('../controllers/dashboardController');
const { verifyToken } = require('../middlewares/auth');
// 모든 인증된 사용자 접근 가능
router.get('/my-summary', verifyToken, dashboardController.getMySummary);
module.exports = router;

View File

@@ -2,7 +2,10 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const departmentController = require('../controllers/departmentController'); const departmentController = require('../controllers/departmentController');
const { requireAuth, requireRole } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 부서 목록 조회 (인증 필요) // 부서 목록 조회 (인증 필요)
router.get('/', requireAuth, departmentController.getAll); router.get('/', requireAuth, departmentController.getAll);
@@ -14,18 +17,18 @@ router.get('/:id', requireAuth, departmentController.getById);
router.get('/:id/workers', requireAuth, departmentController.getWorkers); router.get('/:id/workers', requireAuth, departmentController.getWorkers);
// 부서 생성 (관리자만) // 부서 생성 (관리자만)
router.post('/', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.create); router.post('/', requireAuth, requirePage('factory_departments'), departmentController.create);
// 부서 수정 (관리자만) // 부서 수정 (관리자만)
router.put('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.update); router.put('/:id', requireAuth, requirePage('factory_departments'), departmentController.update);
// 부서 삭제 (관리자만) // 부서 삭제 (관리자만)
router.delete('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.delete); router.delete('/:id', requireAuth, requirePage('factory_departments'), departmentController.delete);
// 작업자 부서 이동 (관리자만) // 작업자 부서 이동 (관리자만)
router.post('/move-worker', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorker); router.post('/move-worker', requireAuth, requirePage('factory_departments'), departmentController.moveWorker);
// 여러 작업자 부서 일괄 이동 (관리자만) // 여러 작업자 부서 일괄 이동 (관리자만)
router.post('/move-workers', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorkers); router.post('/move-workers', requireAuth, requirePage('factory_departments'), departmentController.moveWorkers);
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/itemAliasController');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
router.post('/', requirePage('factory_purchases'), ctrl.create);
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
module.exports = router;

View File

@@ -1,24 +1,26 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../controllers/meetingController'); const ctrl = require('../controllers/meetingController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 회의록 // 회의록
router.get('/', ctrl.getAll); router.get('/', ctrl.getAll);
router.get('/action-items', ctrl.getActionItems); router.get('/action-items', ctrl.getActionItems);
router.get('/:id', ctrl.getById); router.get('/:id', ctrl.getById);
router.post('/', requireMinLevel('support_team'), ctrl.create); router.post('/', requirePage('factory_meetings'), ctrl.create);
router.put('/:id', requireMinLevel('support_team'), ctrl.update); router.put('/:id', requirePage('factory_meetings'), ctrl.update);
router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish); router.put('/:id/publish', requirePage('factory_meetings'), ctrl.publish);
router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish); router.put('/:id/unpublish', requirePage('factory_meetings'), ctrl.unpublish);
router.delete('/:id', requireMinLevel('admin'), ctrl.delete); router.delete('/:id', requirePage('factory_meetings'), ctrl.delete);
// 안건 // 안건
router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem); router.post('/:id/items', requirePage('factory_meetings'), ctrl.addItem);
router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem); router.put('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.updateItem);
router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem); router.delete('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.deleteItem);
// 조치상태 업데이트 // 조치상태 업데이트
router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus); router.put('/items/:itemId/status', requirePage('factory_meetings'), ctrl.updateItemStatus);
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/monthlyComparisonController');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
function requireSupportTeam(req, res, next) {
const role = (req.user?.role || '').toLowerCase();
if (!ADMIN_ROLES.includes(role)) {
return res.status(403).json({ success: false, message: '지원팀 이상 권한이 필요합니다.' });
}
next();
}
// 본인 월간 비교
router.get('/my-records', ctrl.getMyRecords);
// 특정 작업자 비교 (내부에서 권한 체크)
router.get('/records', ctrl.getRecords);
// 확인/반려
router.post('/confirm', ctrl.confirm);
// 관리자: 확인요청 발송 (pending → review_sent)
router.post('/review-send', requireSupportTeam, ctrl.reviewSend);
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
router.post('/review-respond', requireSupportTeam, ctrl.reviewRespond);
// 관리자: 개별 검토 태깅
router.post('/admin-check', requireSupportTeam, ctrl.adminCheck);
// 전체 현황 (support_team+)
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);
// 엑셀 다운로드 (support_team+)
router.get('/export', requireSupportTeam, ctrl.exportExcel);
module.exports = router;

View File

@@ -3,6 +3,11 @@ const router = express.Router();
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const { requireAuth, requireAdmin } = require('../middlewares/auth'); const { requireAuth, requireAdmin } = require('../middlewares/auth');
// Admin 역할 확인 헬퍼
function isAdminRole(role) {
return ['admin', 'system'].includes((role || '').toLowerCase());
}
/** /**
* 모든 페이지 목록 조회 * 모든 페이지 목록 조회
* GET /api/pages * GET /api/pages
@@ -11,7 +16,7 @@ router.get('/pages', requireAuth, async (req, res) => {
try { try {
const db = await getDb(); const db = await getDb();
const [pages] = await db.query(` const [pages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, description, is_admin_only, display_order SELECT id, page_key, page_name, page_path, category, description, display_order
FROM pages FROM pages
ORDER BY display_order, page_name ORDER BY display_order, page_name
`); `);
@@ -19,7 +24,7 @@ router.get('/pages', requireAuth, async (req, res) => {
res.json({ success: true, data: pages }); res.json({ success: true, data: pages });
} catch (error) { } catch (error) {
console.error('페이지 목록 조회 오류:', error); console.error('페이지 목록 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' }); res.status(500).json({ success: false, message: '페이지 목록을 불러오는데 실패했습니다.' });
} }
}); });
@@ -32,24 +37,21 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
const { userId } = req.params; const { userId } = req.params;
const db = await getDb(); const db = await getDb();
// 사용자의 역할 확인 // 사용자 조회 (sso_users)
const [userRows] = await db.query(` const [userRows] = await db.query(`
SELECT u.user_id, u.username, u.role_id, r.name as role_name SELECT user_id, name, role FROM sso_users WHERE user_id = ?
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [userId]); `, [userId]);
if (userRows.length === 0) { if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' }); return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
} }
const user = userRows[0]; const user = userRows[0];
// Admin/System Admin인 경우 모든 페이지 접근 가능 // Admin인 경우 모든 페이지 접근 가능
if (user.role_name === 'Admin' || user.role_name === 'System Admin') { if (isAdminRole(user.role)) {
const [allPages] = await db.query(` const [allPages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only SELECT id, page_key, page_name, page_path, category
FROM pages FROM pages
ORDER BY display_order, page_name ORDER BY display_order, page_name
`); `);
@@ -60,15 +62,24 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
page_name: page.page_name, page_name: page.page_name,
page_path: page.page_path, page_path: page.page_path,
category: page.category, category: page.category,
is_admin_only: page.is_admin_only,
can_access: true, can_access: true,
is_default: true // Admin은 기본적으로 모든 권한 보유 is_default: true
})); }));
return res.json({ success: true, data: { user, pageAccess } }); return res.json({ success: true, data: { user, pageAccess } });
} }
// 사용자의 부서 조회 (workers 우선, 없으면 sso_users fallback)
const [workerRows] = await db.query(`
SELECT COALESCE(w.department_id, su2.department_id, 0) AS department_id
FROM sso_users su2
LEFT JOIN workers w ON su2.user_id = w.user_id
WHERE su2.user_id = ?
`, [userId]);
const departmentId = workerRows[0]?.department_id || 0;
// 일반 사용자의 페이지 접근 권한 조회 // 일반 사용자의 페이지 접근 권한 조회
// department_page_permissions.page_name은 's1.' 접두사 사용, pages.page_key는 접두사 없음
const [pageAccess] = await db.query(` const [pageAccess] = await db.query(`
SELECT SELECT
p.id as page_id, p.id as page_id,
@@ -76,21 +87,22 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
p.page_name, p.page_name,
p.page_path, p.page_path,
p.category, p.category,
p.is_admin_only, COALESCE(upp.can_access, dpp.can_access, p.is_default_accessible, 0) as can_access,
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access, upp.granted_at
upa.granted_at,
u2.username as granted_by_username
FROM pages p FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? LEFT JOIN user_page_permissions upp
LEFT JOIN users u2 ON upa.granted_by = u2.user_id ON upp.user_id = ?
WHERE p.is_admin_only = 0 AND (upp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR upp.page_name COLLATE utf8mb4_general_ci = p.page_key)
LEFT JOIN department_page_permissions dpp
ON dpp.department_id = ?
AND (dpp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR dpp.page_name COLLATE utf8mb4_general_ci = p.page_key)
ORDER BY p.display_order, p.page_name ORDER BY p.display_order, p.page_name
`, [userId]); `, [userId, departmentId]);
res.json({ success: true, data: { user, pageAccess } }); res.json({ success: true, data: { user, pageAccess } });
} catch (error) { } catch (error) {
console.error('페이지 접근 권한 조회 오류:', error); console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' }); res.status(500).json({ success: false, message: '페이지 접근 권한을 불러오는데 실패했습니다.' });
} }
}); });
@@ -101,56 +113,35 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
*/ */
router.post('/users/:userId/page-access', requireAuth, async (req, res) => { router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
try { try {
const { userId } = req.params; if (!isAdminRole(req.user.role)) {
const { pageIds, canAccess } = req.body; return res.status(403).json({ success: false, message: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
const adminUserId = req.user.user_id; // 권한을 부여하는 Admin의 user_id
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
} }
const { userId } = req.params;
const { pageIds, canAccess } = req.body;
const adminUserId = req.user.user_id;
const db = await getDb();
// 사용자 존재 확인 // 사용자 존재 확인
const [userRows] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [userId]); const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
if (userRows.length === 0) { if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' }); return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
} }
// 페이지 접근 권한 업데이트 // 페이지 접근 권한 업데이트
for (const pageId of pageIds) { for (const pageId of pageIds) {
// 기존 권한 확인 await db.query(`
const [existing] = await db.query( INSERT INTO user_page_access (user_id, page_id, can_access, granted_by)
'SELECT * FROM user_page_access WHERE user_id = ? AND page_id = ?', VALUES (?, ?, ?, ?)
[userId, pageId] ON DUPLICATE KEY UPDATE can_access = ?, granted_at = NOW(), granted_by = ?
); `, [userId, pageId, canAccess ? 1 : 0, adminUserId, canAccess ? 1 : 0, adminUserId]);
if (existing.length > 0) {
// 업데이트
await db.query(
'UPDATE user_page_access SET can_access = ?, granted_at = NOW(), granted_by = ? WHERE user_id = ? AND page_id = ?',
[canAccess ? 1 : 0, adminUserId, userId, pageId]
);
} else {
// 삽입
await db.query(
'INSERT INTO user_page_access (user_id, page_id, can_access, granted_by) VALUES (?, ?, ?, ?)',
[userId, pageId, canAccess ? 1 : 0, adminUserId]
);
}
} }
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' }); res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
} catch (error) { } catch (error) {
console.error('페이지 접근 권한 부여 오류:', error); console.error('페이지 접근 권한 부여 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' }); res.status(500).json({ success: false, message: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
} }
}); });
@@ -160,23 +151,13 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
*/ */
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => { router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
try { try {
const { userId, pageId } = req.params; if (!isAdminRole(req.user.role)) {
const adminUserId = req.user.user_id; return res.status(403).json({ success: false, message: '권한이 없습니다.' });
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
} }
// 접근 권한 삭제 const { userId, pageId } = req.params;
const db = await getDb();
await db.query( await db.query(
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?', 'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
[userId, pageId] [userId, pageId]
@@ -185,7 +166,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' }); res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
} catch (error) { } catch (error) {
console.error('페이지 접근 권한 회수 오류:', error); console.error('페이지 접근 권한 회수 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' }); res.status(500).json({ success: false, message: '페이지 접근 권한을 회수하는데 실패했습니다.' });
} }
}); });
@@ -195,42 +176,29 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
*/ */
router.get('/page-access/summary', requireAuth, async (req, res) => { router.get('/page-access/summary', requireAuth, async (req, res) => {
try { try {
const adminUserId = req.user.user_id; if (!isAdminRole(req.user.role)) {
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
} }
// 모든 사용자와 페이지 권한 조회 const db = await getDb();
const [summary] = await db.query(` const [summary] = await db.query(`
SELECT SELECT
u.user_id, su.user_id,
u.username, su.name,
u.name, su.role,
r.name as role_name,
COUNT(DISTINCT upa.page_id) as accessible_pages_count, COUNT(DISTINCT upa.page_id) as accessible_pages_count,
(SELECT COUNT(*) FROM pages WHERE is_admin_only = 0) as total_pages_count (SELECT COUNT(*) FROM pages) as total_pages_count
FROM users u FROM sso_users su
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN user_page_access upa ON su.user_id = upa.user_id AND upa.can_access = 1
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1 WHERE su.role NOT IN ('admin', 'system')
WHERE r.name NOT IN ('Admin', 'System Admin') GROUP BY su.user_id, su.name, su.role
GROUP BY u.user_id, u.username, u.name, r.name ORDER BY su.name
ORDER BY u.username
`); `);
res.json({ success: true, data: summary }); res.json({ success: true, data: summary });
} catch (error) { } catch (error) {
console.error('페이지 접근 권한 요약 조회 오류:', error); console.error('페이지 접근 권한 요약 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' }); res.status(500).json({ success: false, message: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
} }
}); });

View File

@@ -2,7 +2,10 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const projectController = require('../controllers/projectController'); const projectController = require('../controllers/projectController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// READ - 인증된 사용자 // READ - 인증된 사용자
router.get('/', requireAuth, projectController.getAllProjects); router.get('/', requireAuth, projectController.getAllProjects);
@@ -10,10 +13,10 @@ router.get('/active/list', requireAuth, projectController.getActiveProjects);
router.get('/:project_id', requireAuth, projectController.getProjectById); router.get('/:project_id', requireAuth, projectController.getProjectById);
// CREATE/UPDATE - support_team 이상 권한 필요 // CREATE/UPDATE - support_team 이상 권한 필요
router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject); router.post('/', requireAuth, requirePage('factory_projects'), projectController.createProject);
router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject); router.put('/:project_id', requireAuth, requirePage('factory_projects'), projectController.updateProject);
// DELETE - admin 이상 권한 필요 // DELETE - admin 이상 권한 필요
router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject); router.delete('/:project_id', requireAuth, requirePage('factory_projects'), projectController.removeProject);
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,20 @@
/**
* 대리입력 + 일별 현황 라우터
*/
const express = require('express');
const router = express.Router();
const proxyInputController = require('../controllers/proxyInputController');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 대리입력
router.post('/', requirePage('factory_proxy_input'), proxyInputController.proxyInput);
// 일별 현황
router.get('/daily-status', requirePage('factory_daily_status'), proxyInputController.getDailyStatus);
// 작업자별 상세
router.get('/daily-status/detail', requirePage('factory_daily_status'), proxyInputController.getDailyStatusDetail);
module.exports = router;

View File

@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/purchaseBatchController');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
router.get('/:id', requirePage('factory_purchases'), ctrl.getById);
router.post('/', requirePage('factory_purchases'), ctrl.create);
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
router.post('/:id/purchase', requirePage('factory_purchases'), ctrl.purchase);
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
module.exports = router;

View File

@@ -1,18 +1,34 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../controllers/purchaseRequestController'); const ctrl = require('../controllers/purchaseRequestController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 보조 데이터 // 보조 데이터
router.get('/consumable-items', ctrl.getConsumableItems); router.get('/consumable-items', ctrl.getConsumableItems);
router.put('/consumable-items/:id/photo', ctrl.updateItemPhoto);
router.get('/vendors', ctrl.getVendors); router.get('/vendors', ctrl.getVendors);
router.get('/search', ctrl.search);
// 내 신청 (모바일용 페이지네이션) — /:id 보다 먼저 등록
router.get('/my-requests', ctrl.getMyRequests);
// 품목 등록 + 신청 동시 (트랜잭션)
router.post('/register-and-request', ctrl.registerAndRequest);
// 일괄 신청 (장바구니)
router.post('/bulk', ctrl.bulkCreate);
// 구매신청 CRUD // 구매신청 CRUD
router.get('/', ctrl.getAll); router.get('/', ctrl.getAll);
router.get('/:id', ctrl.getById); router.get('/:id', ctrl.getById);
router.post('/', ctrl.create); router.post('/', ctrl.create);
router.put('/:id/hold', requireMinLevel('admin'), ctrl.hold); router.put('/:id/hold', requirePage('factory_purchases'), ctrl.hold);
router.put('/:id/revert', requireMinLevel('admin'), ctrl.revert); router.put('/:id/revert', requirePage('factory_purchases'), ctrl.revert);
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
router.put('/:id/cancel', requirePage('factory_purchases'), ctrl.cancel);
router.put('/:id/return', requirePage('factory_purchases'), ctrl.returnItem);
router.put('/:id/revert-cancel', requirePage('factory_purchases'), ctrl.revertCancel);
router.delete('/:id', ctrl.delete); router.delete('/:id', ctrl.delete);
module.exports = router; module.exports = router;

View File

@@ -1,10 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../controllers/purchaseController'); const ctrl = require('../controllers/purchaseController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
router.get('/', ctrl.getAll); router.get('/', ctrl.getAll);
router.post('/', requireMinLevel('admin'), ctrl.create); router.post('/', requirePage('factory_purchases'), ctrl.create);
router.get('/price-history/:itemId', ctrl.getPriceHistory); router.get('/price-history/:itemId', ctrl.getPriceHistory);
module.exports = router; module.exports = router;

View File

@@ -1,18 +1,20 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../controllers/scheduleController'); const ctrl = require('../controllers/scheduleController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 제품유형 // 제품유형
router.get('/product-types', ctrl.getProductTypes); router.get('/product-types', ctrl.getProductTypes);
// 표준공정 자동 생성 // 표준공정 자동 생성
router.post('/generate-from-template', requireMinLevel('support_team'), ctrl.generateFromTemplate); router.post('/generate-from-template', requirePage('factory_schedules'), ctrl.generateFromTemplate);
// 공정 단계 // 공정 단계
router.get('/phases', ctrl.getPhases); router.get('/phases', ctrl.getPhases);
router.post('/phases', requireMinLevel('admin'), ctrl.createPhase); router.post('/phases', requirePage('factory_schedules'), ctrl.createPhase);
router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase); router.put('/phases/:id', requirePage('factory_schedules'), ctrl.updatePhase);
// 작업 템플릿 // 작업 템플릿
router.get('/templates', ctrl.getTemplates); router.get('/templates', ctrl.getTemplates);
@@ -20,21 +22,21 @@ router.get('/templates', ctrl.getTemplates);
// 공정표 항목 // 공정표 항목
router.get('/entries', ctrl.getEntries); router.get('/entries', ctrl.getEntries);
router.get('/entries/gantt', ctrl.getGanttData); router.get('/entries/gantt', ctrl.getGanttData);
router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry); router.post('/entries', requirePage('factory_schedules'), ctrl.createEntry);
router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries); router.post('/entries/batch', requirePage('factory_schedules'), ctrl.createBatchEntries);
router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry); router.put('/entries/:id', requirePage('factory_schedules'), ctrl.updateEntry);
router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress); router.put('/entries/:id/progress', requirePage('factory_schedules'), ctrl.updateProgress);
router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry); router.delete('/entries/:id', requirePage('factory_schedules'), ctrl.deleteEntry);
// 의존관계 // 의존관계
router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency); router.post('/entries/:id/dependencies', requirePage('factory_schedules'), ctrl.addDependency);
router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency); router.delete('/entries/:id/dependencies/:depId', requirePage('factory_schedules'), ctrl.removeDependency);
// 마일스톤 // 마일스톤
router.get('/milestones', ctrl.getMilestones); router.get('/milestones', ctrl.getMilestones);
router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone); router.post('/milestones', requirePage('factory_schedules'), ctrl.createMilestone);
router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone); router.put('/milestones/:id', requirePage('factory_schedules'), ctrl.updateMilestone);
router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone); router.delete('/milestones/:id', requirePage('factory_schedules'), ctrl.deleteMilestone);
// 부적합 연동 // 부적합 연동
router.get('/nonconformance', ctrl.getNonconformance); router.get('/nonconformance', ctrl.getNonconformance);

View File

@@ -1,12 +1,16 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../controllers/settlementController'); const ctrl = require('../controllers/settlementController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
router.get('/summary', ctrl.getMonthlySummary); router.get('/summary', ctrl.getMonthlySummary);
router.get('/purchases', ctrl.getMonthlyPurchases); router.get('/purchases', ctrl.getMonthlyPurchases);
router.get('/price-changes', ctrl.getPriceChanges); router.get('/price-changes', ctrl.getPriceChanges);
router.post('/complete', requireMinLevel('admin'), ctrl.complete); router.get('/received-summary', ctrl.getMonthlyReceivedSummary);
router.post('/cancel', requireMinLevel('admin'), ctrl.cancel); router.get('/received-list', ctrl.getMonthlyReceivedList);
router.post('/complete', requirePage('factory_settlements'), ctrl.complete);
router.post('/cancel', requirePage('factory_settlements'), ctrl.cancel);
module.exports = router; module.exports = router;

View File

@@ -2,7 +2,10 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const TbmController = require('../controllers/tbmController'); const TbmController = require('../controllers/tbmController');
const { requireAuth, requireRole } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// ==================== TBM 세션 관련 ==================== // ==================== TBM 세션 관련 ====================
@@ -56,13 +59,13 @@ router.delete('/sessions/:sessionId/team/:userId', requireAuth, TbmController.re
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks); router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
// 안전 체크 항목 생성 (관리자용) // 안전 체크 항목 생성 (관리자용)
router.post('/safety-checks', requireAuth, requireRole('admin', 'system'), TbmController.createSafetyCheck); router.post('/safety-checks', requireAuth, requirePage('factory_tbm'), TbmController.createSafetyCheck);
// 안전 체크 항목 수정 (관리자용) // 안전 체크 항목 수정 (관리자용)
router.put('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.updateSafetyCheck); router.put('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.updateSafetyCheck);
// 안전 체크 항목 삭제 (관리자용) // 안전 체크 항목 삭제 (관리자용)
router.delete('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.deleteSafetyCheck); router.delete('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.deleteSafetyCheck);
// TBM 세션의 안전 체크 기록 조회 // TBM 세션의 안전 체크 기록 조회
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords); router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);

View File

@@ -2,15 +2,18 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const controller = require('../controllers/toolsController'); const controller = require('../controllers/toolsController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 읽기 작업: 인증된 사용자 // 읽기 작업: 인증된 사용자
router.get('/', requireAuth, controller.getAll); router.get('/', requireAuth, controller.getAll);
router.get('/:id', requireAuth, controller.getById); router.get('/:id', requireAuth, controller.getById);
// 쓰기 작업: group_leader 이상 권한 필요 // 쓰기 작업: group_leader 이상 권한 필요
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create); router.post('/', requireAuth, requirePage('factory_tools'), controller.create);
router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update); router.put('/:id', requireAuth, requirePage('factory_tools'), controller.update);
router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete); router.delete('/:id', requireAuth, requirePage('factory_tools'), controller.delete);
module.exports = router; module.exports = router;

View File

@@ -3,8 +3,11 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const { requireAuth, requireMinLevel } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity'); const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
@@ -31,7 +34,7 @@ const upload = multer({
}); });
// 관리자 권한 필요 // 관리자 권한 필요
router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => { router.post('/upload-bg', requireAuth, requirePage('factory_uploads'), upload.single('image'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' }); return res.status(400).json({ success: false, message: '파일이 없습니다.' });
} }

View File

@@ -5,7 +5,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const workIssueController = require('../controllers/workIssueController'); const workIssueController = require('../controllers/workIssueController');
const { requireMinLevel } = require('../middlewares/auth'); const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// ==================== 카테고리 관리 ==================== // ==================== 카테고리 관리 ====================
@@ -16,13 +18,13 @@ router.get('/categories', workIssueController.getAllCategories);
router.get('/categories/type/:type', workIssueController.getCategoriesByType); router.get('/categories/type/:type', workIssueController.getCategoriesByType);
// 카테고리 생성 (admin 이상) // 카테고리 생성 (admin 이상)
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory); router.post('/categories', requirePage('factory_work_issues'), workIssueController.createCategory);
// 카테고리 수정 (admin 이상) // 카테고리 수정 (admin 이상)
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory); router.put('/categories/:id', requirePage('factory_work_issues'), workIssueController.updateCategory);
// 카테고리 삭제 (admin 이상) // 카테고리 삭제 (admin 이상)
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory); router.delete('/categories/:id', requirePage('factory_work_issues'), workIssueController.deleteCategory);
// ==================== 사전 정의 항목 관리 ==================== // ==================== 사전 정의 항목 관리 ====================
@@ -33,24 +35,24 @@ router.get('/items', workIssueController.getAllItems);
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory); router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
// 항목 생성 (admin 이상) // 항목 생성 (admin 이상)
router.post('/items', requireMinLevel('admin'), workIssueController.createItem); router.post('/items', requirePage('factory_work_issues'), workIssueController.createItem);
// 항목 수정 (admin 이상) // 항목 수정 (admin 이상)
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem); router.put('/items/:id', requirePage('factory_work_issues'), workIssueController.updateItem);
// 항목 삭제 (admin 이상) // 항목 삭제 (admin 이상)
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem); router.delete('/items/:id', requirePage('factory_work_issues'), workIssueController.deleteItem);
// ==================== 통계 ==================== // ==================== 통계 ====================
// 통계 요약 (support_team 이상) // 통계 요약 (support_team 이상)
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary); router.get('/stats/summary', requirePage('factory_work_issues'), workIssueController.getStatsSummary);
// 카테고리별 통계 (support_team 이상) // 카테고리별 통계 (support_team 이상)
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory); router.get('/stats/by-category', requirePage('factory_work_issues'), workIssueController.getStatsByCategory);
// 작업장별 통계 (support_team 이상) // 작업장별 통계 (support_team 이상)
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace); router.get('/stats/by-workplace', requirePage('factory_work_issues'), workIssueController.getStatsByWorkplace);
// ==================== 문제 신고 관리 ==================== // ==================== 문제 신고 관리 ====================
@@ -72,10 +74,10 @@ router.delete('/:id', workIssueController.deleteReport);
// ==================== 상태 관리 ==================== // ==================== 상태 관리 ====================
// 신고 접수 (support_team 이상) // 신고 접수 (support_team 이상)
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport); router.put('/:id/receive', requirePage('factory_work_issues'), workIssueController.receiveReport);
// 담당자 배정 (support_team 이상) // 담당자 배정 (support_team 이상)
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport); router.put('/:id/assign', requirePage('factory_work_issues'), workIssueController.assignReport);
// 처리 시작 // 처리 시작
router.put('/:id/start', workIssueController.startProcessing); router.put('/:id/start', workIssueController.startProcessing);
@@ -84,7 +86,7 @@ router.put('/:id/start', workIssueController.startProcessing);
router.put('/:id/complete', workIssueController.completeReport); router.put('/:id/complete', workIssueController.completeReport);
// 신고 종료 (admin 이상) // 신고 종료 (admin 이상)
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport); router.put('/:id/close', requirePage('factory_work_issues'), workIssueController.closeReport);
// 상태 변경 이력 조회 // 상태 변경 이력 조회
router.get('/:id/logs', workIssueController.getStatusLogs); router.get('/:id/logs', workIssueController.getStatusLogs);

View File

@@ -2,11 +2,14 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const workReportAnalysisController = require('../controllers/workReportAnalysisController'); const workReportAnalysisController = require('../controllers/workReportAnalysisController');
const { requireAuth, requireRole } = require('../middlewares/auth'); const { requireAuth } = require('../middlewares/auth');
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
const { getDb } = require('../dbPool');
const requirePage = createRequirePage(getDb);
// 🔒 모든 분석 라우트에 인증 + Admin 권한 필요 // 🔒 모든 분석 라우트에 인증 + Admin 권한 필요
router.use(requireAuth); router.use(requireAuth);
router.use(requireRole('admin', 'system')); router.use(requirePage('factory_work_analysis'));
// 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록) // 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
router.get('/filters', workReportAnalysisController.getAnalysisFilters); router.get('/filters', workReportAnalysisController.getAnalysisFilters);

View File

@@ -9,16 +9,21 @@
const AttendanceModel = require('../models/attendanceModel'); const AttendanceModel = require('../models/attendanceModel');
const vacationBalanceModel = require('../models/vacationBalanceModel'); const vacationBalanceModel = require('../models/vacationBalanceModel');
const { getDb } = require('../dbPool');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
/** /**
* 휴가 사용 유형 ID 차감 일수로 변환 * 휴가 사용 유형 ID 차감 일수 (DB vacation_types.deduct_days 조회)
* vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일)
*/ */
const getVacationDays = (vacationTypeId) => { const getVacationDays = async (vacationTypeId) => {
const daysMap = { 1: 1, 2: 0.5, 3: 0.25 }; if (!vacationTypeId) return 0;
return daysMap[vacationTypeId] || 0; const db = await getDb();
const [rows] = await db.execute(
'SELECT deduct_days FROM vacation_types WHERE id = ?',
[vacationTypeId]
);
return rows.length > 0 ? parseFloat(rows[0].deduct_days) || 0 : 0;
}; };
/** /**
@@ -143,8 +148,8 @@ const upsertAttendanceRecordService = async (recordData) => {
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트) // 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
const year = new Date(record_date).getFullYear(); const year = new Date(record_date).getFullYear();
const previousDays = getVacationDays(previousVacationTypeId); const previousDays = await getVacationDays(previousVacationTypeId);
const newDays = getVacationDays(vacation_type_id); const newDays = await getVacationDays(vacation_type_id);
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감 // 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
if (previousDays !== newDays) { if (previousDays !== newDays) {

View File

@@ -23,7 +23,9 @@ try {
const UPLOAD_DIRS = { const UPLOAD_DIRS = {
issues: path.join(__dirname, '../uploads/issues'), issues: path.join(__dirname, '../uploads/issues'),
equipments: path.join(__dirname, '../uploads/equipments'), equipments: path.join(__dirname, '../uploads/equipments'),
purchase_requests: path.join(__dirname, '../uploads/purchase_requests') purchase_requests: path.join(__dirname, '../uploads/purchase_requests'),
purchase_received: path.join(__dirname, '../uploads/purchase_received'),
consumables: path.join(__dirname, '../uploads/consumables')
}; };
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지 const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 }; const MAX_SIZE = { width: 1920, height: 1920 };

View File

@@ -0,0 +1,156 @@
/**
* 한국어 스마트 검색 유틸리티
* - 초성 추출 및 매칭
* - 별칭(alias) 매칭
* - 인메모리 캐시 (5분 TTL)
*/
const { getDb } = require('../dbPool');
// 초성 목록 (19개)
const CHOSUNG = [
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
];
// 자음 문자 집합 (초성 판별용)
const JAMO_SET = new Set([
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
]);
// 캐시
let cache = null;
let cacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5분
/**
* 한글 완성형 문자에서 초성 추출
* @param {string} str
* @returns {string} 초성 문자열
*/
function extractChosung(str) {
let result = '';
for (const ch of str) {
const code = ch.charCodeAt(0);
if (code >= 0xAC00 && code <= 0xD7A3) {
const idx = Math.floor((code - 0xAC00) / (21 * 28));
result += CHOSUNG[idx];
} else {
result += ch;
}
}
return result;
}
/**
* 검색어가 모두 자음(초성)인지 판별
* @param {string} query
* @returns {boolean}
*/
function isChosungOnly(query) {
if (query.length < 2) return false;
for (const ch of query) {
if (!JAMO_SET.has(ch)) return false;
}
return true;
}
/**
* 캐시 로드 (consumable_items + item_aliases)
*/
async function loadCache() {
if (cache && (Date.now() - cacheTime < CACHE_TTL)) return cache;
const db = await getDb();
const [items] = await db.query(
`SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path
FROM consumable_items WHERE is_active = 1`
);
const [aliases] = await db.query(
`SELECT alias_id, item_id, alias_name FROM item_aliases`
);
// 아이템별 별칭 맵 생성
const aliasMap = {};
for (const a of aliases) {
if (!aliasMap[a.item_id]) aliasMap[a.item_id] = [];
aliasMap[a.item_id].push(a.alias_name);
}
// 초성 미리 계산
const enriched = items.map(item => ({
...item,
aliases: aliasMap[item.item_id] || [],
chosung_name: extractChosung(item.item_name),
chosung_aliases: (aliasMap[item.item_id] || []).map(a => extractChosung(a))
}));
cache = enriched;
cacheTime = Date.now();
return cache;
}
/**
* 캐시 무효화
*/
function clearCache() {
cache = null;
cacheTime = 0;
}
/**
* 스마트 검색
* @param {string} query - 검색어
* @returns {Promise<Array>} 스코어 기준 상위 20건
*/
async function search(query) {
if (!query || query.trim().length === 0) return [];
const items = await loadCache();
const q = query.trim().toLowerCase();
const qChosung = isChosungOnly(q) ? q : null;
const scored = [];
for (const item of items) {
let score = 0;
let matchType = '';
const nameLower = item.item_name.toLowerCase();
const specLower = (item.spec || '').toLowerCase();
const makerLower = (item.maker || '').toLowerCase();
// exact match (이름 완전 일치)
if (nameLower === q) {
score = 100; matchType = 'exact';
}
// substring match (이름)
else if (nameLower.includes(q)) {
score = 80; matchType = 'name';
}
// alias match
else if (item.aliases.some(a => a.toLowerCase().includes(q))) {
score = 75; matchType = 'alias';
}
// spec/maker match
else if (specLower.includes(q) || makerLower.includes(q)) {
score = 70; matchType = 'spec';
}
// 초성 매칭 (이름)
else if (qChosung && item.chosung_name.includes(qChosung)) {
score = 50; matchType = 'chosung';
}
// 초성 매칭 (별칭)
else if (qChosung && item.chosung_aliases.some(ca => ca.includes(qChosung))) {
score = 40; matchType = 'chosung_alias';
}
if (score > 0) {
scored.push({ ...item, _score: score, _matchType: matchType });
}
}
// 점수 높은 순, 같은 점수면 이름 짧은 순 (더 구체적)
scored.sort((a, b) => b._score - a._score || a.item_name.length - b.item_name.length);
return scored.slice(0, 20);
}
module.exports = { search, clearCache, extractChosung, isChosungOnly };

View File

@@ -11,11 +11,15 @@ RUN apt-get update && apt-get install -y \
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# non-root user 생성
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 애플리케이션 코드 복사 # 애플리케이션 코드 복사
COPY . . COPY --chown=appuser:appuser . .
# 포트 노출 # 포트 노출
EXPOSE 8000 EXPOSE 8000
# 애플리케이션 실행 # 애플리케이션 실행
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -8,6 +8,7 @@ class Settings:
# 기본 설정 # 기본 설정
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000")) FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005") EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379") REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
NODE_ENV: str = os.getenv("NODE_ENV", "development") NODE_ENV: str = os.getenv("NODE_ENV", "development")

View File

@@ -7,6 +7,7 @@ import logging
from typing import Any, Dict from typing import Any, Dict
import aiohttp import aiohttp
import jwt as pyjwt
import uvicorn import uvicorn
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -206,10 +207,29 @@ async def analytics_dashboard():
} }
} }
def _verify_proxy_token(request: Request) -> dict:
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
token = auth_header.split(" ", 1)[1]
if not settings.JWT_SECRET:
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
return {}
try:
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
return payload
except pyjwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]: async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)""" """Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
user_payload = _verify_proxy_token(request)
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
# Express.js API URL 구성 # Express.js API URL 구성
target_url = f"{settings.EXPRESS_API_URL}/api/{path}" target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
@@ -219,9 +239,9 @@ async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
params = dict(request.query_params) params = dict(request.query_params)
# GET 요청에 대해서만 캐싱 적용 # GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
if request.method == "GET": if request.method == "GET":
cache_key = cache_manager._generate_key("api", path, **params) cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
cached_result = await cache_manager.get(cache_key) cached_result = await cache_manager.get(cache_key)
if cached_result is not None: if cached_result is not None:

View File

@@ -4,3 +4,4 @@ aiohttp==3.9.1
python-multipart==0.0.6 python-multipart==0.0.6
redis==5.0.1 redis==5.0.1
python-dotenv==1.0.0 python-dotenv==1.0.0
PyJWT==2.8.0

View File

@@ -1,13 +1,5 @@
FROM nginx:alpine FROM nginx:alpine
# 정적 파일 복사
COPY . /usr/share/nginx/html/
# 디렉토리 권한 보정 (macOS에서 복사 시 700이 되는 문제 방지)
RUN find /usr/share/nginx/html -type d -exec chmod 755 {} +
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,208 +0,0 @@
// js/change-password.js
// 개인 비밀번호 변경 페이지 JavaScript
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
const token = ensureAuthenticated();
// DOM 요소
const form = document.getElementById('changePasswordForm');
const messageArea = document.getElementById('message-area');
const submitBtn = document.getElementById('submitBtn');
const resetBtn = document.getElementById('resetBtn');
// 비밀번호 토글 기능
document.querySelectorAll('.password-toggle').forEach(button => {
button.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const input = document.getElementById(targetId);
if (input) {
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
this.textContent = isPassword ? '👁️‍🗨️' : '👁️';
}
});
});
// 초기화 버튼
resetBtn?.addEventListener('click', () => {
form.reset();
clearMessages();
document.getElementById('passwordStrength').innerHTML = '';
});
// 메시지 표시 함수
function showMessage(type, message) {
messageArea.innerHTML = `
<div class="message-box ${type}">
${type === 'error' ? '❌' : '✅'} ${message}
</div>
`;
// 에러 메시지는 5초 후 자동 제거
if (type === 'error') {
setTimeout(clearMessages, 5000);
}
}
function clearMessages() {
messageArea.innerHTML = '';
}
// 비밀번호 강도 체크
async function checkPasswordStrength(password) {
if (!password) {
document.getElementById('passwordStrength').innerHTML = '';
return;
}
try {
const res = await fetch(`${API}/auth/check-password-strength`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ password })
});
const result = await res.json();
updatePasswordStrengthUI(result);
} catch (error) {
console.error('Password strength check error:', error);
}
}
// 비밀번호 강도 UI 업데이트
function updatePasswordStrengthUI(strength) {
const container = document.getElementById('passwordStrength');
if (!container) return;
const colors = {
0: '#f44336',
1: '#ff9800',
2: '#ffc107',
3: '#4caf50',
4: '#2196f3'
};
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
const color = colors[strength.strength] || '#ccc';
const percentage = (strength.score / strength.maxScore) * 100;
container.innerHTML = `
<div style="margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
${strengthText}
</span>
<span style="font-size: 0.8rem; color: #666;">
${strength.score}/${strength.maxScore}
</span>
</div>
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
</div>
${strength.feedback && strength.feedback.length > 0 ? `
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
</ul>
` : ''}
</div>
`;
}
// 비밀번호 입력 이벤트
let strengthCheckTimer;
document.getElementById('newPassword')?.addEventListener('input', (e) => {
clearTimeout(strengthCheckTimer);
strengthCheckTimer = setTimeout(() => {
checkPasswordStrength(e.target.value);
}, 300);
});
// 폼 제출
form?.addEventListener('submit', async (e) => {
e.preventDefault();
clearMessages();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 유효성 검사
if (!currentPassword || !newPassword || !confirmPassword) {
showMessage('error', '모든 필드를 입력해주세요.');
return;
}
if (newPassword !== confirmPassword) {
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
return;
}
if (newPassword.length < 6) {
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
return;
}
if (currentPassword === newPassword) {
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
return;
}
// 버튼 상태 변경
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
try {
const res = await fetch(`${API}/auth/change-password`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
currentPassword,
newPassword
})
});
const result = await res.json();
if (res.ok && result.success) {
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
form.reset();
document.getElementById('passwordStrength').innerHTML = '';
// 카운트다운 시작
let countdown = 3;
const countdownInterval = setInterval(() => {
showMessage('success',
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
);
countdown--;
if (countdown < 0) {
clearInterval(countdownInterval);
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
}, 1000);
} else {
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
showMessage('error', errorMessage);
}
} catch (error) {
console.error('Password change error:', error);
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// 페이지 로드 시 현재 사용자 정보 표시
document.addEventListener('DOMContentLoaded', () => {
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -12,18 +12,53 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# 민감 파일 차단 — exact match (^~ 우회 불가)
location = /Dockerfile { return 404; }
location = /docker-compose.yml { return 404; }
location = /nginx.conf { return 404; }
location = /.env { return 404; }
location = /.gitignore { return 404; }
# .git 디렉토리 전체 차단
location ^~ /.git/ { return 404; }
location = /.git { return 404; }
# 민감 파일 차단 — regex (하위 경로 + 변형 대비)
location ~* (Dockerfile|docker-compose|\.env|nginx\.conf) {
return 404;
}
# HTML 캐시 비활성화 # HTML 캐시 비활성화
location ~* \.html$ { location ~* \.html$ {
expires -1; expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate"; add_header Cache-Control "no-store, no-cache, must-revalidate";
} }
# SW, manifest 캐시 비활성화 (PWA 업데이트 즉시 반영)
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /manifest.json {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 정적 파일 캐시 (JS, CSS, 이미지 등) # 정적 파일 캐시 (JS, CSS, 이미지 등)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
expires 1h; expires 1h;
add_header Cache-Control "public, no-transform"; add_header Cache-Control "public, no-transform";
} }
# SSO Auth API 프록시 (/api/auth/* → sso-auth)
location /api/auth/ {
set $upstream http://sso-auth:3000;
proxy_pass $upstream$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API 프록시 (System 1 API) # API 프록시 (System 1 API)
location /api/ { location /api/ {
set $upstream http://system1-api:3005; set $upstream http://system1-api:3005;
@@ -81,12 +116,19 @@ server {
proxy_send_timeout 180s; proxy_send_timeout 180s;
} }
# 레거시 /login, /dashboard → gateway(tkds) 리다이렉트 # /login → 로그인 페이지 (gateway 대시보드)
# tkfb.technicalkorea.net이 system1-web을 직접 가리키므로
# 외부 리다이렉트 대신 gateway 내부 프록시로 처리
location = /login { location = /login {
return 302 $scheme://tkds.technicalkorea.net/dashboard$is_args$args; set $gw http://gateway:80;
rewrite ^/login$ /dashboard break;
proxy_pass $gw;
proxy_set_header Host $host;
} }
location = /dashboard { location = /dashboard {
return 301 $scheme://tkds.technicalkorea.net/dashboard; set $gw http://gateway:80;
proxy_pass $gw;
proxy_set_header Host $host;
} }
# Health check # Health check

View File

@@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile overlay -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 날짜/시간 헤더 -->
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-xl font-bold text-gray-800">대시보드</h2>
<p class="text-sm text-gray-500 mt-0.5" id="dateTimeDisplay">-</p>
</div>
<button onclick="loadDashboard()" class="text-sm text-gray-500 hover:text-orange-600 border border-gray-200 px-3 py-1.5 rounded-lg hover:bg-orange-50">
<i class="fas fa-sync-alt mr-1"></i>새로고침
</button>
</div>
<!-- 요약 카드 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div class="stat-card">
<div class="stat-value text-orange-600" id="statTbm">-</div>
<div class="stat-label">금일 TBM</div>
</div>
<div class="stat-card">
<div class="stat-value text-blue-600" id="statWorkers">-</div>
<div class="stat-label">출근 인원</div>
</div>
<div class="stat-card">
<div class="stat-value text-red-600" id="statRepairs">-</div>
<div class="stat-label">수리 요청</div>
</div>
<div class="stat-card">
<div class="stat-value text-purple-600" id="statNotifications"><i class="fas fa-external-link-alt text-base"></i></div>
<div class="stat-label">알림 관리</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<!-- 금일 TBM 현황 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4">
<i class="fas fa-clipboard-list text-orange-500 mr-2"></i>금일 TBM
</h3>
<div id="tbmList" class="space-y-2">
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
</div>
</div>
<!-- 최근 알림 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4">
<i class="fas fa-bell text-orange-500 mr-2"></i>최근 알림
</h3>
<div id="notificationList" class="space-y-2">
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
</div>
</div>
<!-- 미완료 수리 요청 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4">
<i class="fas fa-tools text-orange-500 mr-2"></i>수리 요청 현황
</h3>
<div id="repairList" class="space-y-2">
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
</div>
</div>
<!-- 빠른 이동 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4">
<i class="fas fa-rocket text-orange-500 mr-2"></i>빠른 이동
</h3>
<div class="grid grid-cols-2 gap-3">
<a href="/pages/work/tbm.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-clipboard-list text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">TBM 관리</span>
</a>
<a href="/pages/work/report-create.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-file-alt text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">작업보고서</span>
</a>
<a href="/pages/attendance/checkin.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-user-check text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">출근 체크</span>
</a>
<a href="/pages/admin/repair-management.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-tools text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">시설설비 관리</span>
</a>
<a href="/pages/attendance/vacation-request.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-paper-plane text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">휴가 신청</span>
</a>
<a href="/pages/dashboard.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
<i class="fas fa-map text-orange-500 w-5 text-center"></i>
<span class="text-sm text-gray-700">작업장 현황</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
<script src="/static/js/tkfb-dashboard.js?v=2026031701"></script>
</body>
</html>

View File

@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>부적합 현황 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile overlay -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 페이지 헤더 -->
<div class="mb-5">
<h2 class="text-xl font-bold text-gray-800">부적합 현황</h2>
<p class="text-sm text-gray-500 mt-0.5">자재, 설계, 검사 등 작업 관련 부적합 신고 현황입니다.</p>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5" id="statsGrid">
<div class="stat-card">
<div class="stat-value text-blue-600" id="statReported">-</div>
<div class="stat-label">신고</div>
</div>
<div class="stat-card">
<div class="stat-value text-orange-600" id="statReceived">-</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card">
<div class="stat-value text-purple-600" id="statProgress">-</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card">
<div class="stat-value text-green-600" id="statCompleted">-</div>
<div class="stat-label">완료</div>
</div>
</div>
<!-- 필터 바 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-5 flex flex-wrap items-center gap-3">
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
<option value="">전체 상태</option>
<option value="reported">신고</option>
<option value="received">접수</option>
<option value="in_progress">처리중</option>
<option value="completed">완료</option>
<option value="closed">종료</option>
</select>
<input type="date" id="filterStartDate" class="input-field px-3 py-2 rounded-lg text-sm" title="시작일">
<input type="date" id="filterEndDate" class="input-field px-3 py-2 rounded-lg text-sm" title="종료일">
<a id="btnNewReport" href="#" class="ml-auto inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-semibold hover:bg-orange-700 transition-colors">
<i class="fas fa-plus"></i>부적합 신고
</a>
</div>
<!-- 신고 목록 -->
<div id="issueList" class="space-y-3">
<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-400 text-sm">로딩 중...</div>
</div>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
<script src="/static/js/tkfb-nonconformity.js"></script>
</body>
</html>

View File

@@ -0,0 +1,292 @@
/* daily-status.css — 일별 입력 현황 대시보드 */
/* Header */
.ds-header {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
padding: 16px 16px 12px;
border-radius: 0 0 16px 16px;
margin: -16px -16px 0;
position: sticky;
top: 56px;
z-index: 20;
}
.ds-header h1 { font-size: 1.125rem; font-weight: 700; }
/* Date Navigation */
.ds-date-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0;
background: white;
border-radius: 12px;
margin: 12px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.ds-date-btn {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #6b7280;
background: #f3f4f6;
border: none; cursor: pointer;
transition: all 0.15s;
}
.ds-date-btn:hover { background: #e5e7eb; color: #374151; }
.ds-date-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.ds-date-display {
display: flex; flex-direction: column; align-items: center;
cursor: pointer; user-select: none;
}
.ds-date-display #dateText { font-size: 1rem; font-weight: 700; color: #1f2937; }
.ds-day-label { font-size: 0.75rem; color: #6b7280; }
/* Summary Cards */
.ds-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.ds-card {
background: white;
border-radius: 12px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
border-top: 3px solid transparent;
}
.ds-card:active { transform: scale(0.97); }
.ds-card-total { border-top-color: #3b82f6; }
.ds-card-done { border-top-color: #16a34a; }
.ds-card-missing { border-top-color: #dc2626; }
.ds-card-num { font-size: 1.5rem; font-weight: 800; color: #1f2937; line-height: 1; }
.ds-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 4px; }
.ds-card-pct { font-size: 0.7rem; font-weight: 600; color: #9ca3af; margin-top: 2px; }
.ds-card-done .ds-card-pct { color: #16a34a; }
.ds-card-missing .ds-card-pct { color: #dc2626; }
/* Filter Tabs */
.ds-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 10px;
margin-bottom: 12px;
}
.ds-tab {
flex: 1;
padding: 8px 4px;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.ds-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.ds-tab-badge {
display: inline-flex;
align-items: center; justify-content: center;
min-width: 18px; height: 18px;
font-size: 0.65rem; font-weight: 700;
background: #e5e7eb; color: #6b7280;
border-radius: 9px;
padding: 0 5px;
margin-left: 2px;
}
.ds-tab.active .ds-tab-badge { background: #dbeafe; color: #2563eb; }
/* Worker List */
.ds-list { padding-bottom: 140px; }
.ds-worker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: white;
border-radius: 10px;
margin-bottom: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
cursor: pointer;
transition: background 0.15s;
}
.ds-worker-row:active { background: #f9fafb; }
.ds-status-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.ds-status-dot.complete { background: #16a34a; }
.ds-status-dot.tbm_only, .ds-status-dot.report_only { background: #f59e0b; }
.ds-status-dot.both_missing { background: #dc2626; }
.ds-worker-info { flex: 1; min-width: 0; }
.ds-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
.ds-worker-dept { font-size: 0.7rem; color: #9ca3af; }
.ds-worker-status { text-align: right; flex-shrink: 0; }
.ds-worker-status span {
display: inline-block;
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 4px;
margin-left: 2px;
}
.ds-badge-ok { background: #dcfce7; color: #16a34a; }
.ds-badge-no { background: #fef2f2; color: #dc2626; }
.ds-badge-proxy { background: #ede9fe; color: #7c3aed; font-size: 0.6rem; }
/* Skeleton */
.ds-skeleton {
height: 56px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: ds-shimmer 1.5s infinite;
border-radius: 10px;
margin-bottom: 6px;
}
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Empty / No Permission */
.ds-empty, .ds-no-perm {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 16px;
color: #9ca3af;
font-size: 0.875rem;
}
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
/* Bottom Action */
.ds-bottom-action {
position: fixed;
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
left: 0; right: 0;
padding: 10px 16px;
background: white;
border-top: 1px solid #e5e7eb;
z-index: 30;
max-width: 480px;
margin: 0 auto;
}
.ds-proxy-btn {
width: 100%;
padding: 12px;
background: #2563eb;
color: white;
font-size: 0.875rem;
font-weight: 700;
border: none;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.ds-proxy-btn:hover { background: #1d4ed8; }
.ds-proxy-btn:disabled { background: #d1d5db; cursor: not-allowed; }
/* Bottom Sheet */
.ds-sheet-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 40;
}
.ds-sheet {
position: fixed;
bottom: 0; left: 0; right: 0;
background: white;
border-radius: 16px 16px 0 0;
max-height: 70vh;
overflow-y: auto;
z-index: 41;
padding: 0 16px 24px;
transform: translateY(100%);
transition: transform 0.3s ease;
max-width: 480px;
margin: 0 auto;
}
.ds-sheet.open { transform: translateY(0); }
.ds-sheet-handle {
width: 40px; height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 10px auto 12px;
cursor: pointer;
}
.ds-sheet-header {
display: flex; align-items: baseline; gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 12px;
}
.ds-sheet-header span:first-child { font-size: 1rem; font-weight: 700; color: #1f2937; }
.ds-sheet-sub { font-size: 0.75rem; color: #9ca3af; }
.ds-sheet-body { min-height: 80px; }
.ds-sheet-loading { text-align: center; padding: 24px; color: #9ca3af; font-size: 0.875rem; }
.ds-sheet-section { margin-bottom: 12px; }
.ds-sheet-section-title {
font-size: 0.75rem; font-weight: 700; color: #6b7280;
margin-bottom: 6px;
display: flex; align-items: center; gap: 6px;
}
.ds-sheet-card {
background: #f9fafb;
border-radius: 8px;
padding: 10px;
font-size: 0.8rem;
color: #374151;
}
.ds-sheet-card.empty { color: #9ca3af; text-align: center; }
.ds-sheet-actions {
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.ds-sheet-btn {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
font-size: 0.8rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
}
/* Bottom Nav (reuse tbm-mobile pattern) */
.m-bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; justify-content: space-around;
background: white;
border-top: 1px solid #e5e7eb;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
z-index: 35;
max-width: 480px;
margin: 0 auto;
}
.m-nav-item {
display: flex; flex-direction: column; align-items: center;
gap: 2px; color: #9ca3af;
text-decoration: none;
font-size: 0.65rem;
padding: 4px 8px;
}
.m-nav-item svg { width: 22px; height: 22px; }
.m-nav-item.active { color: #2563eb; }
.m-nav-label { font-weight: 500; }
@media (max-width: 480px) {
body { max-width: 480px; margin: 0 auto; }
}

Some files were not shown because too many files have changed in this diff Show More