Compare commits

...

285 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
Hyungi Ahn
1ceeef2a65 refactor(tkeg): print→logging 교체 + 레거시 파일 정리 (-5,447줄)
- 6개 파일 디버그 print문 102건 → logger.info/warning/error 교체
- DashboardPage.old.jsx 삭제 (미사용)
- NewMaterialsPage.jsx/css 삭제 (dead import)
- spool_manager_v2.py 삭제 (미사용)
- App.jsx dead import 제거
- main.py 구 주석 블록 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:41:39 +09:00
Hyungi Ahn
d6dd03a52f feat(schedule): 공정표 제품유형 + 표준공정 자동생성 백엔드
- 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 마이그레이션 러너 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:39:12 +09:00
Hyungi Ahn
7abf62620b fix(tkuser): 권한 사용자에게 비밀번호 변경 폼 중복 표시 제거
tkuser.users 권한이 있는 일반 사용자에게 adminSection과
passwordChangeSection이 동시 표시되던 문제 수정.
비밀번호 변경 폼은 아무 권한 없는 사용자 fallback으로만 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:43:22 +09:00
Hyungi Ahn
280efc46ed fix(tksupport): 부서 페이지 권한 동작 수정 — requireAdmin/requireSupportTeam 제거, 네비게이션 권한 기반 렌더링
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:30:53 +09:00
Hyungi Ahn
a6724b2a20 feat(tkuser): requireAdmin → requireAdminOrPermission 전환 — 권한 기반 접근 제어
- 9개 라우트 파일의 쓰기 작업을 requireAdminOrPermission으로 전환
- 권한 관리에서 tkuser.* 권한 부여 시 일반 사용자도 해당 탭 접근 가능
- GET(참조 데이터)은 requireAuth 유지, permissionRoutes는 admin 전용 유지
- 기존 partnerRoutes.js 패턴과 동일한 방식 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:29:28 +09:00
Hyungi Ahn
d663b9bfa6 feat(tksupport): 휴가 보정 관리 페이지 추가 — 캘린더 기반 추가/삭제
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:12:14 +09:00
Hyungi Ahn
05c9f22bdf feat(tkuser): 권한 관리 페이지 최신화 — tksupport 추가, tksafety 보강, S1 휴가 정리
- tksupport 행정지원 6페이지 권한 정의 추가 (indigo 테마)
- tksupport 라우트에 requirePage() 미들웨어 적용
- tksafety 권한 2→8개 확장 (출입관리 4 + 교육/점검 4)
- System1 안전관리 그룹 제거 (s1.safety.* 고아키)
- System1 근태관리 휴가 5항목 제거 (tksupport로 통합)
- 월간근태를 공장관리 그룹으로 이동
- System3 업무, tkuser 연차설정 백엔드 키 동기화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:06:06 +09:00
Hyungi Ahn
d46e509e42 fix(tkuser): 연차설정 저장 시 settings 객체→배열 변환 누락 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:59:49 +09:00
Hyungi Ahn
08a629f662 fix(tksupport): 전사 차감 월별 반영 + 테이블 가독성 개선 + 캘린더 차감일 표시
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:51:00 +09:00
Hyungi Ahn
71289be375 feat(tksupport): 전체 휴가관리 대시보드 개편 — 연간 총괄 + 월간 캘린더 뷰
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:34:56 +09:00
Hyungi Ahn
66db012754 fix(tksupport): 전사 휴가 차감 시 관리계정·미입사자 제외
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:55:14 +09:00
Hyungi Ahn
a40c1e0f18 feat(tkuser): 사용자 목록 검색 + 부서 필터 기능 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:15:33 +09:00
Hyungi Ahn
f09aa0875a feat(tkuser): 입사일 자동표시 + 퇴사자 목록 분리 + 퇴사일 관리
- 사용자 추가 시 hire_date 전송 (서울 오늘날짜 기본값)
- resigned_date 컬럼 마이그레이션 + CRUD 지원
- 비활성화(삭제) 시 resigned_date 자동 설정 (COALESCE)
- 활성/비활성 사용자 목록 분리, 퇴사자 접기/펼치기
- 퇴사자 재활성화 기능 (resigned_date 초기화)
- 편집 모달에 퇴사일 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:47:14 +09:00
Hyungi Ahn
1f3eb14128 fix(tkuser): hire_date 지원 + 부서 팀장 타부서 선택 허용
- findAll()에 hire_date 추가, update()에 hire_date 처리
- 사용자 편집 모달에 입사일 input 추가
- 사용자 목록에 입사일 뱃지 표시 (미등록 시 주황 경고)
- 부서 팀장 드롭다운: 전체 사용자 optgroup 방식으로 변경
- u.id → u.user_id 버그 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:40:39 +09:00
Hyungi Ahn
3d314c1fb4 fix(infra): nginx 동적 DNS resolve + Docker 헬스체크 추가
컨테이너 재생성 시 502 Bad Gateway 방지:
- 모든 nginx proxy_pass를 set $upstream 변수 방식으로 전환 (9개 파일, 24개 location)
- resolver 127.0.0.11 valid=10s ipv6=off 통합 선언
- ai-api location의 개별 resolver 8.8.8.8 제거 (server-level로 통합)
- 10개 API 서비스에 healthcheck 추가 (Node: wget, Python: urllib)
- 모든 web/gateway depends_on을 condition: service_healthy로 강화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:53:34 +09:00
Hyungi Ahn
2afcc4448b fix(tkuser): 마이그레이션 SQL 순서 수정 — ADD INDEX 후 DROP INDEX
FK가 기존 unique_user_type_year 인덱스를 참조하므로
새 인덱스를 먼저 추가한 뒤 기존 것을 삭제해야 함

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:44:57 +09:00
Hyungi Ahn
a2bb157111 fix(tkuser): Dockerfile에 migrations 디렉토리 COPY 추가
컨테이너 내부에서 /usr/src/migrations/ 경로 참조하나
user-management/migrations/가 복사되지 않아 startup crash 발생

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:38:36 +09:00
Hyungi Ahn
36cf9d553d fix(tkuser): Sprint 001 리뷰 권장 개선 3건 — 방어 코딩 및 일관성 보완
- setLongServiceExclusion: affectedRows 체크 추가 (존재하지 않는 user_id → 404)
- ACCESS_LEVELS: user: 1 키 추가 (role='user' 사용자 레벨 0 방지)
- escapeHtml → escHtml 통일 (tkuser-vacations.js 라인 381)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:27:54 +09:00
Hyungi Ahn
19e668a56a fix(tkuser): getBalancesByYear에 su.long_service_excluded 컬럼 추가
Section B 프론트엔드에서 장기근속 제외 체크박스 표시에 필요한 필드 누락 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:22:46 +09:00
Hyungi Ahn
b3ff87b151 fix(tkuser): XSS 미이스케이프 4개소 수정 — escHtml() 누락 보완
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:22:26 +09:00
Hyungi Ahn
36391c02e1 feat(tksupport): Sprint 001 Section C — 전사 휴가관리 구현
- 전사 휴가 부여/관리 (company-holidays) CRUD + 연차차감 트랜잭션
- 전체 휴가관리 대시보드 (vacation-dashboard) 부서별/직원별 현황
- 내 휴가 현황 개선 (/my-status) balance_type별 카드, 전사 휴가일
- requireSupportTeam 미들웨어, 부서명 JOIN, 마이그레이션 002 추가
- 사이드바 roles 기반 메뉴 필터링 (하위호환 유지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:16:50 +09:00
Hyungi Ahn
a3f7a324b1 feat(tkuser): 연차/휴가 관리 프론트엔드 개편 (Sprint 001 Section B)
- workers 기반 → sso_users 기반 전환 (vacWorkers→vacUsers, /workers→/users)
- 휴가 탭: 부서 필터, 이름 검색, balance_type 뱃지, 장기근속 제외 체크박스
- 배정 모달: balance_type/expires_at 필드 추가, 사용자 부서별 optgroup
- 부서 탭: 팀장 표시/편집, 승인권한 CRUD
- 연차 설정 탭/JS 신규: 기본연차·장기근속·이월연차 설정 UI
- API 미완성 대응: 필드명 폴백(worker_id→user_id), 404 graceful degradation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:14:28 +09:00
Hyungi Ahn
c158da7832 feat(tkuser): Sprint 001 Section A — 연차/휴가 백엔드 전환 (DB + API)
workers/vacation_balance_details → sso_users/sp_vacation_balances 기반 전환.
부서장 설정, 승인권한 CRUD, vacation_settings 테이블, 장기근속 자동부여,
startup migration 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:13:03 +09:00
Hyungi Ahn
b44ae36329 fix(tkfb): daily-status 500 에러 수정 — 존재하지 않는 컬럼 참조 제거
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:44:39 +09:00
Hyungi Ahn
fa4199a277 fix(tkfb): 대시보드 콘솔 에러 수정 (notifications, attendance, repair-requests)
- notifications/unread 호출 제거 → tkuser 링크로 대체
- attendance/today-summary → daily-status 엔드포인트로 변경
- GET /equipments/repair-requests 엔드포인트 신규 구현
- 캐시 버스팅 tkfb-dashboard.js?v=2026031701

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:19:43 +09:00
Hyungi Ahn
0c149673fb refactor: shared 모듈 추출 Phase 1~4 (notifyHelper, errors, logger, auth, dbPool)
Phase 1: notifyHelper.js → shared/utils/ (4개 서비스 중복 제거)
Phase 2: auth.js → shared/middleware/ (system1/system2 통합)
Phase 3: errors.js + logger.js → shared/utils/ (system1/system2 통합)
Phase 4: DB pool → shared/config/database.js (Group B 4개 서비스 통합)

- Docker 빌드 컨텍스트를 루트로 변경 (6개 API 서비스)
- 기존 파일은 re-export 패턴으로 consumer 변경 0개 유지
- .dockerignore 추가로 빌드 최적화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:07:22 +09:00
Hyungi Ahn
84cf222b81 feat(tkuser): 알림 시스템 이관 system1-factory → tkuser
- Phase 1: tkuser에 알림 CRUD, Push/ntfy 발송, 내부 알림 API 추가
- Phase 2: notifyHelper URL을 tkuser-api:3000으로 전환 (system2, tkpurchase, tksafety, system1)
- Phase 3: notification-bell.js API 도메인 tkuser로 변경 + 캐시 버스팅 v=4
- Phase 4: system1에서 알림 코드 제거 (routes, controllers, models, utils)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:56:41 +09:00
Hyungi Ahn
afa10c044f fix: 미커밋 수정사항 정리 (purchase migration, 로컬네트워크 URL, 포트 수정)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:47:28 +09:00
Hyungi Ahn
862a2683d3 feat(tkuser): 탭 카테고리 그룹핑 + 설비 관리 탭 추가 + tkfb admin 페이지 통합
- tkuser 탭을 5개 카테고리로 그룹핑 (인력/현장/업무/거래/시스템)
- 설비 관리 탭 신규 추가 (CRUD, 필터, 상세 보기)
- tkfb 사이드바 admin 메뉴 6개를 tkuser 외부 링크로 교체
- tkfb admin HTML 6개를 tkuser 리다이렉트로 변경
- gateway 알림 벨 링크를 tkuser로 변경
- _tkuserBase 헬퍼로 개발/운영 환경 자동 분기

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:35:24 +09:00
Hyungi Ahn
f548a95767 feat(tkuser): 알림 수신자 탭에 ntfy 구독 관리 추가
- notificationRecipientModel에 ntfy CRUD 메서드 추가 (같은 DB 직접 쿼리)
- ntfy 라우트 3개 추가 (GET/POST/DELETE, /:type 위에 배치)
- 알림 수신자 탭 상단에 ntfy 구독 관리 카드 렌더링
- ntfy 추가 모달에 앱 설정 안내 문구 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:16:14 +09:00
Hyungi Ahn
1cef745cc9 feat(ntfy): Phase 2 — sendPushToUsers() ntfy 연동 + 구독 관리 UI
- ntfy_subscriptions 테이블 마이그레이션 추가
- ntfySender.js 유틸 (ntfy HTTP POST 래퍼)
- sendPushToUsers() ntfy 우선 분기 (ntfy 구독자 → ntfy, 나머지 → Web Push)
- ntfy subscribe/unsubscribe/status API 엔드포인트
- notification-bell.js ntfy 토글 버튼 + 앱 설정 안내 모달
- docker-compose system1-api에 NTFY 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:01:03 +09:00
Hyungi Ahn
e50ff3fb63 feat(ntfy): 푸시 알림 서버 Phase 1 인프라 구축
- docker-compose.yml에 ntfy 서비스 추가 (포트 30750)
- ntfy/etc/server.yml 서버 설정 (인증 deny-all, 72h 캐시)
- ntfy/README.md 운영 매뉴얼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 10:24:09 +09:00
Hyungi Ahn
184cdd6aa8 fix(tkfb): project_code → job_no 컬럼명 수정 (500 에러 해결)
projects 테이블에 project_code 컬럼이 없고 job_no가 올바른 컬럼명.
백엔드 SQL에서는 pr.job_no AS project_code alias 사용,
프론트 드롭다운에서는 p.job_no로 직접 참조.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:18:49 +09:00
Hyungi Ahn
adf3a197fd fix(tkfb): pages INSERT에서 is_active 컬럼 제거
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:10:06 +09:00
Hyungi Ahn
49949bda62 fix(tkfb): 마이그레이션 FK 타입 불일치 수정 (signed/unsigned)
projects, sso_users 테이블은 signed int이므로 해당 FK에서 .unsigned() 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:09:04 +09:00
Hyungi Ahn
d7cc568c01 feat(tkfb): 공정표 + 생산회의록 시스템 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:05:18 +09:00
Hyungi Ahn
b5dc9c2f20 refactor(tkeg): 대시보드 프로젝트 생성 기능 제거 (tkuser로 통합)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:47:04 +09:00
Hyungi Ahn
0910f5d0a6 fix(tkeg): JWT 파싱 시 한글 이름 깨짐 수정 (UTF-8 디코딩)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:31:06 +09:00
Hyungi Ahn
9a2b682b18 fix(tkfb): TBM 완료 시 연차 작업자 작업보고서 생성 500 에러 수정
daily_work_reports INSERT에 NOT NULL 필수 컬럼 work_type_id 누락으로
연차 작업자 포함 TBM 완료 시 500 에러 발생. work_type_id=11(휴무) 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:54:41 +09:00
Hyungi Ahn
1e1d2f631a feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:41:58 +09:00
Hyungi Ahn
2699242d1f feat(tkeg, gateway): tkeg 대시보드 리디자인 + gateway 구매관리 네이밍 수정
- tkeg: MUI 기반 대시보드 전면 리디자인 (theme, 메트릭 카드, 프로젝트 Autocomplete, Quick Action, 관리자 섹션)
- gateway: tkpurchase 카드 "소모품 관리" → "구매관리"로 복원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:36:02 +09:00
Hyungi Ahn
9b586da720 feat(tkfb): TBM 카드에 완료 버튼 추가
draft 상태 카드에서 근무 현황 입력 모달을 열 수 있도록 완료 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:32:44 +09:00
Hyungi Ahn
5cc3191871 fix(tkfb): TBM 팀 구성 모달 기본 정보 바인딩 버그 수정
날짜/입력자/프로젝트/공정 필드가 모두 빈 값으로 표시되던 4건 수정:
- sessionDateDisplay.textContent로 날짜 표시
- leaderName .value → .textContent (div 요소)
- 프로젝트/공정 드롭다운 옵션 채우기 + 기존 값 선택

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:20:20 +09:00
Hyungi Ahn
ec59efcdb6 docs: CLAUDE.md + 슬래시 커맨드 + 워크플로우 가이드 추가
Claude Code 협업 효율화를 위한 문서 체계 구축:
- CLAUDE.md: 서비스 맵·코드 규칙·배포 정보 (매 세션 자동 로드)
- 슬래시 커맨드 5개: deploy, check-deploy, cache-bust, add-page, add-api
- WORKFLOW-GUIDE.md: Plan 모드·서브에이전트·검증 루프 활용 가이드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:13:49 +09:00
Hyungi Ahn
c2e8b58849 fix(tkfb): TBM 팀 구성 모달 workerTaskList DOM 누락 버그 수정
tbmModal에 편집 모드용 workerTaskListSection/workerTaskList/workerListEmpty 요소 추가.
openTeamCompositionModal에서 생성↔편집 모드 전환 로직 추가, closeTbmModal에서 원복.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:09:59 +09:00
Hyungi Ahn
573ef74246 feat(tkfb): 모바일 전체 최적화 — 네비 수정 + 공통 기반 + 페이지별 개선
Phase 1: 기반 수정
- 햄버거 메뉴 .mobile-open 규칙 커밋 (네비 버그 수정)
- 36개 HTML 파일 tkfb.css 캐시 버스팅 ?v=2026031601
- tkfb.css 공통 모바일 기반: 터치 44px, iOS 줌 방지, 테이블 스크롤, 모달 최적화

Phase 2: 페이지별 최적화
- 그룹 A (심각): daily.html, work-status.html JS 카드 뷰 변환
- 그룹 A: monthly.html 모바일 컨트롤 스택 + No열 숨김 + 범례 그리드
- 공통 CSS: 페이지 헤더/컨트롤/필터 스택, 탭 가로 스크롤,
  폼 2열→1열, 요약 바 wrap, 저장 바 sticky, 작업자 칩 터치 최적화,
  2열 레이아웃→세로 스택, 테이블 래퍼 오버플로, 모달 풀스크린
- 개별 페이지: checkin, vacation-management, vacation-approval,
  projects, repair-management, annual-overview 인라인 모바일 스타일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:39:19 +09:00
Hyungi Ahn
65839e94a4 fix(tkfb): 모바일 대시보드 레이아웃 깨짐 — mobile.css 누락 수정
dashboard.html에 mobile.css 링크가 없어 모바일 대시보드 뷰가
스타일 없이 평문으로 표시되던 문제 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:11:17 +09:00
Hyungi Ahn
0a05bd8d76 feat(consumable): 소모품 마스터에 "규격(spec)" 필드 추가
품목의 규격 정보(예: 4" 용접, M16)를 분리 저장할 수 있도록 spec 컬럼 추가.
DB ALTER 필요: ALTER TABLE consumable_items ADD COLUMN spec VARCHAR(200) DEFAULT NULL AFTER item_name;

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:34:43 +09:00
Hyungi Ahn
cc47d25851 refactor(tkfb): "구매 관리" → "소모품 관리" 리네이밍 — UI 라벨을 실제 기능에 맞게 변경
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:19:31 +09:00
Hyungi Ahn
817002f798 fix(tkuser): 권한 기반 탭 자동 라우팅 — 제한 사용자 진입 시 첫 허용 탭 표시
users 권한 없는 일반 사용자가 빈 화면(비밀번호 변경 폼만) 보이던 문제 수정.
허용된 탭으로 자동 전환하고, 탭 버튼에 data-tab 속성 추가하여 프로그래밍적
switchTab() 호출 시 active 버튼도 정확히 갱신되도록 개선.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:08:48 +09:00
Hyungi Ahn
4108a6e64a feat(tkuser): 부서 마스터 + 개인 추가 부여 권한 시스템 구현
부서 권한을 바닥(마스터)으로 설정하고 개인은 추가 부여만 가능하도록 변경.
부서 허용 항목은 개인 페이지에서 잠금(해제 불가) 표시되며,
부서 이동 시 기존 개인 권한이 자동 초기화됨.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:49:25 +09:00
Hyungi Ahn
f711a721ec feat(tkuser): 협력업체 CRUD 권한을 permission 시스템으로 확장
tkuser.partners 권한이 부여된 일반 사용자도 업체/작업자 등록·수정·비활성화 가능.
완전삭제는 admin 전용 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:23:17 +09:00
Hyungi Ahn
5a911f1d4b fix(tkuser): 협력업체 삭제 시 sso_users 컬럼명 오류 수정 (id → user_id)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:13:04 +09:00
Hyungi Ahn
457c74084f fix(tkpurchase): 테이블 첫 행 가림 버그 수정 — sticky 헤더 제거
position: sticky 테이블 헤더가 첫 번째 데이터 행을 가려서 데이터 누락으로 보이던 문제 해결.
overflow-x-auto 래퍼와 sticky 조합의 브라우저 불일치 문제도 함께 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:41:26 +09:00
Hyungi Ahn
509691eebb feat(tkpurchase): 협력업체 포털/이력에 프로젝트 정보 배지 추가
- 포털 스케줄 카드에 프로젝트명·job_no 초록 배지 표시
- 이력 카드에 프로젝트명·job_no 초록 배지 표시
- checkinModel.findHistoryByCompany에 LEFT JOIN projects 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:34:56 +09:00
Hyungi Ahn
5d24584553 fix(tkpurchase): API 요청에 _t= 타임스탬프 캐시버스터 추가
디버깅 결과 서버(DB→Model→API) 모두 정상 3건 반환 확인.
브라우저/프록시 레벨 캐싱이 원인으로 추정되어
api() 함수에 Date.now() 기반 캐시버스터 파라미터 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:27:06 +09:00
Hyungi Ahn
8ed0b832ab feat(tkuser): 협력업체 완전삭제 기능 추가 (admin 전용)
- 관련 데이터 cascade 삭제 (workers, schedules, checkins, reports, SSO 계정 등)
- 구매 이력 있는 업체는 삭제 차단
- 프론트엔드: 목록/상세에 완전삭제 버튼 + prompt("삭제") 안전장치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 07:58:39 +09:00
Hyungi Ahn
73bd13a7cd fix(tkpurchase): fetch에 cache:no-store 추가 — 브라우저 캐시 완전 우회
이전 응답이 브라우저에 캐시된 상태에서 서버 Cache-Control만으로는
기존 캐시를 무효화할 수 없음. fetch() 호출 시 cache:'no-store'로
브라우저가 항상 네트워크 요청하도록 강제.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 07:46:41 +09:00
Hyungi Ahn
5398581b87 feat(tkpurchase/tkuser): 사이드바에 협력업체 관리 외부 링크 추가 + tkuser ?tab= 라우팅
tkpurchase 사이드바에 tkuser 협력업체 관리 페이지로 이동하는 링크 추가.
tkuser에 URL ?tab= 파라미터 기반 탭 자동 전환 지원 (화이트리스트 + replaceState).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 07:37:44 +09:00
Hyungi Ahn
54bb26dbd6 fix(tkpurchase): 일정 목록 미표시 버그 수정 — 캐시·타임존·페이지네이션
- API에 Cache-Control: no-store 미들웨어 추가 (304 캐시 문제 해결)
- toLocalDate() 유틸 추가, 전체 8개 JS의 toISOString 타임존 버그 수정
- scheduleModel.findAll에 total COUNT 추가, 컨트롤러에서 total 반환
- HTML 캐시 버스팅 ?v=2026031601

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 07:34:40 +09:00
Hyungi Ahn
17f7c6f3b0 fix(tksafety): uploads/risk 디렉토리 생성을 lazy로 변경 — 볼륨 권한 충돌 해결
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:07:13 +09:00
Hyungi Ahn
e9b69ed87b feat(tksafety): 위험성평가 모듈 Phase 1 구현 — DB·API·Excel·프론트엔드
5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩,
프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기,
프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:05:19 +09:00
Hyungi Ahn
fe5f7cd155 feat(ux): 전체 시스템 모바일 UX 개선 — 햄버거메뉴·필터반응형·터치타겟·iOS줌방지
7개 시스템(tkpurchase/tksafety/tksupport/tkuser/system1/system2/system3)의
모바일 사용성 일괄 개선. system1(tkfb)의 모바일 메뉴 패턴을 3개 신규 시스템에 적용.

주요 변경:
- 모바일 햄버거 메뉴: tkpurchase/tksafety/tksupport에 toggleMobileMenu+overlay 추가
- 필터 반응형: 768px 이하 2열 그리드 전환 (filter-bar/filter-actions 클래스)
- 터치 타겟 44px: 테이블 액션 버튼 36px+gap, tksafety ±버튼 w-11
- iOS 줌 방지: input/select/textarea font-size 16px
- tkuser: 탭 가로스크롤+fade힌트, 사이드바·grid·드롭다운 반응형
- system1: 대시보드 인라인 width 제거, 이동설비 그리드 1열
- system2: 사진그리드 4열, 유형버튼 2열 (480px 이하)
- system3: 카드 내 액션 버튼 stopPropagation 추가
- 캐시 무효화: 전체 HTML ?v=2026031401

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:57:47 +09:00
Hyungi Ahn
2d8ac92404 fix(ux): 데스크탑 UX 일괄 개선 — fade-in·ESC·스크롤잠금·z-index·sticky
A1: fade-in querySelectorAll 전환 (5개 core.js) — 복수 요소 모두 표시
A2: 모달 ESC 키 닫기 (5개 core.js)
A3: 모바일 메뉴 body 스크롤 잠금 (tkfb-core.js)
A4: Gateway 대시보드 max-width 800→1080px
B1: 모달 z-index 50→60 — 헤더 위에 표시 (4개 CSS)
B3: 테이블 sticky header (tkfb.css, tkpurchase.css)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:51:40 +09:00
Hyungi Ahn
e42a08e74d fix(tkfb): 작업 분석 페이지 백지 현상 — 내부 요소 fade-in 클래스 제거
tkfb.css의 .fade-in(opacity:0)이 내부 요소에도 적용되어
.visible 클래스가 추가되지 않는 내부 요소들이 영구 투명 상태였음.
외부 wrapper의 fade-in만 유지하고 내부 4개 요소에서 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:22:09 +09:00
Hyungi Ahn
cc626a408e feat(tkfb): navigation.js 모듈 추가 — api-config.js import 404 해결
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:09:40 +09:00
Hyungi Ahn
cea72b1858 fix(purchase): year_month 예약어 백틱 처리
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:58:54 +09:00
Hyungi Ahn
aacd18be1c fix(purchase): purchase_requests 테이블 스키마 — item_id NULL + 직접입력/사진 컬럼
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:57:49 +09:00
Hyungi Ahn
cae735f243 feat(purchase): 구매신청 검색/직접입력/사진첨부/HEIC 지원/마스터 자동등록
- 소모품 select → 검색형 드롭다운 (debounce + 키보드 탐색)
- 미등록 품목 직접 입력 + 분류 선택 지원
- 사진 첨부 (base64 업로드, HEIC→JPEG 프론트 변환)
- 구매 처리 시 미등록 품목 소모품 마스터 자동 등록
- item_id NULL 허용, LEFT JOIN, custom_item_name/custom_category/photo_path 컬럼
- DB 마이그레이션 필요: ALTER TABLE purchase_requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:49:41 +09:00
Hyungi Ahn
13e177e818 fix(tkuser): uploads/consumables 디렉토리 권한 — Dockerfile에서 미리 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:23:21 +09:00
Hyungi Ahn
3623551a6b feat(purchase): 생산소모품 구매 관리 시스템 구현
tkuser: 업체(공급업체) CRUD + 소모품 마스터 CRUD (사진 업로드 포함)
tkfb: 구매신청 → 구매 처리 → 월간 분석/정산 전체 워크플로
설비(equipment) 분류 구매 시 자동 등록 + 실패 시 admin 알림

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:21:59 +09:00
Hyungi Ahn
1abdb92a71 fix(tksafety): 작업별 항목 표시 안 되는 버그 — check_type 'task' vs 'work_type' 불일치 수정
DB에 저장된 check_type='task'를 프론트에서 'work_type'으로 필터링하여 매칭 0건.
HTML option value와 JS 필터를 모두 'task'로 통일.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:13:20 +09:00
Hyungi Ahn
07aac305d6 feat(tksafety): 체크리스트 작업별 항목에 tkuser 작업(task) 참조 연동
- getAllChecks: tasks/work_types/weather_conditions JOIN + 프론트엔드 필드명 alias
- createCheck/updateCheck: item_type→check_type 등 프론트-DB 필드 매핑
- 모달에 작업(task) 드롭다운 추가, 공정 선택 시 동적 로드
- renderWorktypeItems: work_type → task 2단 그룹핑
- openEditItem: async/await로 task 목록 로드 후 값 설정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:07:29 +09:00
Hyungi Ahn
e8076a8550 fix(purchase): 작업일정 삭제 시 관련 데이터 캐스케이드 삭제 (admin 전용)
- 삭제 권한을 admin 전용으로 변경 (requireAdmin)
- 트랜잭션으로 reports → checkins → safety_education → schedule 순서 삭제
- 프론트엔드: admin만 삭제 버튼 표시, 종속 데이터 삭제 경고 추가
- 404 처리 및 한국어 에러 메시지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:03:27 +09:00
Hyungi Ahn
3e50639914 feat(training): 안전교육 실시 페이지 수정/삭제 기능 추가
대기 목록·완료 이력 양쪽에 수정/삭제 버튼 추가.
교육 기록 삭제 시 트랜잭션으로 출입 신청 상태를 approved로 복원.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:31:29 +09:00
Hyungi Ahn
e236883c64 feat(tkuser): 부서 관리 개선 — 상위부서 제거, hard delete, 휴가 부서별 그룹
- 상위부서(parent_id) 필드 UI/API 전체 제거
- 부서 비활성화(soft delete) → 진짜 삭제(hard delete) 전환 (트랜잭션)
- 소속 인원 있는 부서 삭제 시 department_id=NULL 처리
- 편집 모달에서 활성/비활성 필드 제거
- 휴가 발생 입력 작업자 select를 부서별 optgroup으로 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:27:14 +09:00
Hyungi Ahn
7e10a90a1a feat(dashboard): 행정지원(tksupport) 카드 활성화 — 준비중 해제
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:22:18 +09:00
Hyungi Ahn
054518f4fc fix: loadNotifications() 에러 내성 강화 - r.ok 체크 추가
배포 시 컨테이너 재시작으로 인한 502 응답이 JSON 파싱 실패를 일으키던 문제 방지.
에러 메시지도 "잠시 후 다시 시도해주세요"로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:05:29 +09:00
Hyungi Ahn
0fd202dcbb fix(dashboard): page key를 DB 실제 key와 일치시켜 카드 표시 수정
s1. 접두어 제거, 크로스시스템 key를 accessKey/minRole로 전환하여
admin 계정에서 모든 시스템 카드가 정상 표시되도록 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:59:52 +09:00
Hyungi Ahn
12367dd3a1 fix(security): 전체 서비스 보안 점검 — XSS·인가·토큰·헤더·에러마스킹 일괄 수정
Phase 1 CRITICAL XSS:
- marked.parse() → DOMPurify.sanitize() (system3 ai-assistant, issues-management)
- toast innerHTML에 escapeHtml 적용 (system1 api-base, system3 common-header)
- onclick 핸들러 → data 속성 + addEventListener (system2 issue-detail)

Phase 2 HIGH 인가:
- getUserBalance 본인확인 추가 (tksupport vacationController)

Phase 3 HIGH 토큰+CSP:
- localStorage 토큰 저장 제거 — 쿠키 전용 (7개 서비스)
- unsafe-eval CSP 제거 (system1 security.js)

Phase 4 MEDIUM:
- nginx 보안 헤더 추가 (8개 서비스)
- 500 에러 메시지 마스킹 (5개 API)
- path traversal 방지 (system3 file_service.py)
- cookie fallback 데드코드 제거 (4개 auth.js)
- /login/form rate limiting 추가 (sso-auth)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:50:00 +09:00
Hyungi Ahn
86312c1af7 feat(dashboard): 대시보드 UI 개선 — 환영 인사 + 현황 카드 + 시스템 설명
- A: 시간대별 환영 인사, 날짜, 날씨 API 연동
- B: 오늘 현황 숫자카드 (출근/작업/이슈) — 권한 기반 동적 표시
- C: 시스템 카드에 설명 텍스트 추가
- fix: notification-bell 드롭다운 position:fixed + 스크롤 시 닫기

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:39:21 +09:00
Hyungi Ahn
7161351607 refactor(gateway): gateway↔system1 분리 — gateway=문짝, system1-web=독립
gateway에서 system1 프록시 제거, 대시보드+로그인+공유JS만 담당.
system1-web에 /auth/, /ai-api/ 프록시 이관. tkds-web 제거(gateway 흡수).
notification-bell URL tkfb→tkds, system3 로그인 URL tkds/dashboard로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:19:45 +09:00
Hyungi Ahn
a66656b1c3 feat(tkds): 대시보드 바로가기를 동적 배너로 교체
17개 개별 페이지 바로가기 제거, API 기반 동적 배너(미확인 알림/미제출 TBM/휴가 승인 대기)로 교체.
시스템 카드 7개는 기존 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:51:31 +09:00
Hyungi Ahn
ccdb1087d7 fix(sso-auth): CORS allowedOrigins에 누락된 서브도메인 4개 추가
tkpurchase, tksafety, tksupport, tkds 서브도메인 누락으로 인한 로그인 실패 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:51:30 +09:00
Hyungi Ahn
2a8ae8572f feat(tkds): 대시보드 UI 개선 — 역할 기반 바로가기 + 시스템 입구 통합
4개 섹션(자주사용/일상업무/시스템/업무도구)을 2개(내 바로가기/시스템)로 재구성.
권한 기반 ALL_SHORTCUTS 17개 + SYSTEM_CARDS 7개 통합, 카테고리 그룹핑 지원.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:29:40 +09:00
Hyungi Ahn
f4999df334 feat(tkds): 독립 대시보드 서비스 분리 (tkds.technicalkorea.net)
대시보드를 gateway(tkfb)에서 분리하여 독립 서비스 tkds로 이동.
- tkds/web: nginx + dashboard.html 신규 서비스 (port 30780)
- gateway: /login 복원, /dashboard → tkds 301 리다이렉트
- 전체 시스템 getLoginUrl() → tkds.technicalkorea.net/dashboard로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:38:53 +09:00
Hyungi Ahn
baf68ca065 feat(gateway): 통합 대시보드 네비게이션 허브 추가
로그인 후 다른 시스템으로의 진입점이 없던 문제 해결.
dashboard.html에 로그인 폼 + 네비게이션 허브를 통합하고,
/, /login을 /dashboard로 리다이렉트.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:51:05 +09:00
Hyungi Ahn
4b68431d2d fix(notifications): 알림 수신자 관리 본인 검증을 권한 검증으로 교체
본인 검증이 타인 수신자 추가/제거를 차단하는 문제 수정.
permissionModel.checkAccess로 tkuser.notification_recipients 권한 확인.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:33:21 +09:00
Hyungi Ahn
be24c12551 fix(notifications): 알림 수신자 관리 비admin 사용자 권한 허용
본인 수신자 추가/제거는 모든 인증 사용자 허용, 타인 수신자 관리는 관리자만 허용하도록 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:50:23 +09:00
Hyungi Ahn
3011495e6d feat(tksupport): 전사 행정지원 서비스 신규 구축 (Phase 1 - 휴가신청)
sso_users 기반 전사 휴가신청/승인/잔여일 관리 서비스.
기존 tkfb의 workers 종속 휴가 기능을 전사 확장.

- API: Express + MariaDB, SSO JWT 인증, 자동 마이그레이션
- Web: 대시보드, 휴가 신청/현황/승인 페이지 (보라색 테마)
- DB: sp_vacation_requests, sp_vacation_balances 신규 테이블
- Docker: API(30600), Web(30680) 포트 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:39:59 +09:00
Hyungi Ahn
fa61bdbb30 fix(tkpurchase): 협력업체 포탈 작업 신청 폼 항상 표시
활성 일정이 있으면 작업 신청 폼이 숨겨지던 문제 수정.
일정 유무와 관계없이 신청 대기/반려 카드 + 작업 신청 폼을 항상 하단에 표시.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:38:13 +09:00
Hyungi Ahn
b1154a8bc7 feat(notifications): 알림 유형 개선 - 카테고리 그룹화 + 구매팀 세분화
- equipment/maintenance 삭제, partner_work/day_labor 신규 추가
- 알림 수신자 관리 UI: 카테고리별 그룹 렌더링 (생산/안전/구매/시스템)
- tkpurchase 컨트롤러 알림 타입 변경
- notification-bell 라벨 및 notifications.html 아이콘 업데이트
- 전 서비스 cache busting 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:29:29 +09:00
Hyungi Ahn
0a712813e2 fix(tkpurchase): 협력업체 포탈 활성 일정 전체 표시로 변경
오늘 날짜 범위 필터 제거 → 마감/취소되지 않은 모든 일정 표시.
체크인 날짜 제한도 상태 기반 검증으로 변경하여 일정 기간 외에도 체크인 가능.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:21:30 +09:00
Hyungi Ahn
7fd646e9ba feat: 실시간 알림 시스템 (Web Push + 알림 벨 + 서비스간 알림 연동)
- Phase 1: 모든 서비스 헤더에 알림 벨 UI 추가 (notification-bell.js)
- Phase 2: VAPID Web Push 구독/전송 (push-sw.js, pushSubscription API)
- Phase 3: 내부 알림 API + notifyHelper로 서비스간 알림 연동
  - tksafety: 출입 승인/반려, 안전교육 완료, 방문자 체크인
  - tkpurchase: 일용공 신청, 작업보고서 제출
  - system2-report: 신고 접수/확인/처리완료
- Phase 4: 30일 이상 알림 자동 정리 cron, Redis 캐싱
- CORS에 tkuser/tkpurchase/tksafety 서브도메인 추가
- HTML cache busting 버전 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:01:44 +09:00
Hyungi Ahn
1ad82fd52c fix(tksafety): UNION ALL collation 불일치 해결 (utf8mb4_general_ci/unicode_ci)
대시보드 UNION 쿼리에서 테이블 간 collation 불일치로 500 에러 발생.
unicode_ci 테이블 컬럼에 COLLATE utf8mb4_general_ci 명시하여 해결.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:58:28 +09:00
Hyungi Ahn
bf9254170b fix(tkpurchase): 협력업체 포탈 캐시로 인한 로딩 실패 수정
구 JS 캐시가 신 API 응답(객체)을 배열로 처리하여 TypeError 발생.
Array.isArray 방어 로직 추가 + 캐시 버스팅 버전 갱신(v=20260313a).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:51:22 +09:00
Hyungi Ahn
2fc4179052 fix(tksafety): DB 스키마 불일치로 인한 API 500 에러 수정
- departments.name → department_name (3곳)
- users → sso_users 테이블 참조 수정 (7곳)
- tbm_sessions.start_time → created_at (존재하지 않는 컬럼)
- tbm_team_assignments JOIN: ta.user_id → ta.worker_id
- workers leader JOIN: leader.worker_id → leader.user_id
- tbm_weather_conditions → weather_conditions 테이블명 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:51:06 +09:00
Hyungi Ahn
0211889636 feat(tkpurchase): 협력업체 작업 신청 기능 추가
협력업체 포탈에서 오늘 일정이 없을 때 직접 작업을 신청할 수 있는 기능.
구매팀이 승인하면 일정이 생성되고, 반려 시 재신청 가능.

- DB: status ENUM에 requested/rejected 추가, requested_by 컬럼 추가
- API: POST /schedules/request, PUT /:id/approve, PUT /:id/reject
- 포탈: 신청 폼 + 승인 대기/반려 상태 카드
- 관리자: 신청 배지 + 승인 모달 (프로젝트 배정, 작업장 보정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:40:20 +09:00
Hyungi Ahn
03119a0849 fix(tksafety): Dockerfile *.html 와일드카드로 변경하여 HTML 404 해결
개별 COPY 대신 와일드카드 사용으로 모든 HTML 파일 nginx에 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:36:34 +09:00
Hyungi Ahn
6a20056e05 feat(tksafety): 통합 출입신고 관리 시스템 구현
- DB 마이그레이션: request_type, visitor_name, department_id, check_in/out_time 컬럼 + status ENUM 확장
- 4소스 UNION 대시보드: 방문(외부/내부) + TBM + 협력업체 통합 조회
- 체크인/체크아웃 API + 내부 출입 신고(승인 불필요) 지원
- 통합 출입 현황판 페이지 신규 (entry-dashboard.html)
- 출입 신청/관리 페이지에 유형 필터 + 체크인/아웃 버튼 추가
- safety_entry_dashboard 권한 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:24:13 +09:00
Hyungi Ahn
5a062759c5 fix(tkpurchase): 체크아웃 폼 worker_names 없을 때 actual_worker_count만큼 빈 행 생성
체크인 시 작업자 이름 미입력 + 인원 수만 입력한 경우 체크아웃 폼에 빈 행 1개만 표시되던 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:18 +09:00
Hyungi Ahn
6e5c1554d0 feat(tkpurchase): 협력업체 포탈 3→2단계 흐름 단순화 + 작업 이력 페이지
- 체크아웃 시 work_report 자동 생성 (checkout-with-report 통합 엔드포인트)
- 업무현황 입력 단계 제거, 작업자+시간만 입력하면 체크아웃 완료
- 협력업체 작업 이력 조회 페이지 신규 추가 (partner-history)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:50:07 +09:00
Hyungi Ahn
e2def8ab14 feat(tksafety): 테마 색상 주황→파랑 변경 (tkfb와 시각적 구분)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:43:49 +09:00
Hyungi Ahn
b14448fc54 feat(tkpurchase): 체크인 worker_names 배열 저장 + 구매팀 체크인 관리 기능
- doCheckIn()에서 worker_names를 콤마 split 배열로 전송 (DB에 JSON 배열로 저장)
- 구매팀 일정 페이지에 체크인 조회/수정/삭제 모달 추가
- DELETE /checkins/:id endpoint + 트랜잭션 삭제 (reports cascade)
- PUT /checkins/:id에 requirePage 권한 guard 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:53:46 +09:00
Hyungi Ahn
9fda89a374 feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환
Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
         미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:46:22 +09:00
Hyungi Ahn
8373fe9e75 fix(tkpurchase): 협력업체 포탈 보고 폼 자동 표시 + 체크인 작업자 pre-populate
- 보고 0건일 때 입력 폼 자동 표시 (버튼 클릭 불필요)
- 체크인 시 입력한 작업자 명단으로 보고 폼 작업자 행 자동 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:31:27 +09:00
Hyungi Ahn
0a0439c794 fix: tkuser 권한 UI 캐시 버스팅 버전 업데이트
JS 파일 수정 후 script 태그 ?v= 파라미터 미갱신으로
브라우저가 구버전 캐시를 로드하여 tkuser 권한 항목이 비어있던 문제 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:27:32 +09:00
Hyungi Ahn
3b0ac615bf feat(tkuser): 통합 관리 탭별 권한 시스템 추가
- DEFAULT_PAGES에 tkuser 시스템 10개 페이지 권한 정의 추가
- 권한 관리 UI에 tkuser 섹션 추가 (개인/부서 권한 모두)
- 비admin 사용자 로그인 시 effective-permissions 기반 탭 표시 제어
- switchTab()에 권한 guard 추가하여 비허용 탭 접근 차단

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:20:21 +09:00
Hyungi Ahn
976e55d672 feat(tkpurchase): 업무현황 다건 입력 + 작업자 시간 추적 + 종합 페이지
- DB: 유니크 제약 제거, report_seq 컬럼, work_report_workers 테이블
- API: 트랜잭션 기반 다건 생성/수정, 작업자 CRUD, 요약/엑셀 엔드포인트
- 협력업체 포탈: 다건 보고 UI, 작업자+시간 입력(자동완성), 수정 기능
- 업무현황 페이지: 보고순번/작업자 상세 표시
- 종합 페이지(NEW): 업체별/프로젝트별 취합, 엑셀 추출

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:43:33 +09:00
Hyungi Ahn
48994cff1f fix: 업무현황 저장 시 report_date 누락 버그 수정
submitWorkReport에서 API 필수값인 report_date를 전송하지 않아
"보고일은 필수입니다" 에러 발생. 오늘 날짜를 자동으로 설정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:06:29 +09:00
Hyungi Ahn
1006e8479e fix: 로그아웃 후 자동 재로그인 버그 수정
쿠키를 단일 진실 출처로 만들어 서브도메인 간 로그아웃 불일치 해결:
- login.html: logout=1 파라미터 시 localStorage+쿠키 전부 정리 후 토큰 체크 스킵
- 각 시스템 logout 함수에 &logout=1 추가 (6개 파일)
- 각 시스템 initAuth에 쿠키 우선 검증 추가 (7개 파일)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:00:19 +09:00
Hyungi Ahn
3d6cedf667 fix: 작업일정 업체명 검색 필터 동작하도록 수정
프론트엔드는 company(텍스트)를 보내지만 백엔드는 company_id(정수)만
읽고 있어 업체명 필터가 무시되던 버그 수정. findAll()에서
pc.company_name LIKE 검색 지원 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:34:39 +09:00
Hyungi Ahn
b5b0fa1728 feat: 작업일정 기간 기반 + 프로젝트 연결
- partner_schedules: work_date → start_date/end_date 기간 기반으로 변경
- project_id 컬럼 추가 (projects 테이블 연결, 선택사항)
- 프로젝트 조회 API 추가 (GET /projects/active)
- 일정 조회 시 기간 겹침 조건으로 필터링
- 체크인 시 기간 내 검증 추가
- 프론트엔드: 시작일/종료일 입력 + 프로젝트 선택 드롭다운
- 마이그레이션 SQL 포함 (scripts/migration-schedule-daterange.sql)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:18:53 +09:00
Hyungi Ahn
fa4c899d95 fix: 계정관리 API 경로 수정 — /partners/:id/accounts → /partner-accounts
프론트엔드에서 /partners/:id/accounts로 호출했지만 실제 API 라우트는
/partner-accounts/company/:id (GET), /partner-accounts (POST),
/partner-accounts/:id (PUT/DELETE). 4곳 경로 일치시킴.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:47:15 +09:00
Hyungi Ahn
9ac92f5775 fix: 협력업체 목록 안 뜨는 버그 수정 — c.name → c.company_name
partner_companies 테이블 컬럼명은 company_name인데 JS에서 c.name으로
접근하여 undefined가 반환되던 문제. accounts/schedule/workreport 3개 파일 6곳 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:44:56 +09:00
Hyungi Ahn
5945176ad4 fix: daily_work_reports 테이블명 충돌 → partner_work_reports로 변경
기존 TBM 시스템의 daily_work_reports 테이블과 이름 충돌.
협력업체 업무현황 테이블을 partner_work_reports로 분리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:25:07 +09:00
Hyungi Ahn
efc3c14db5 fix: 배포 후 버그 수정 — 테이블명/컬럼명 불일치, navbar active, API 검증 강화, 대시보드 통계 라우트 추가
- checkinModel: partner_checkins → partner_work_checkins, countActive() 추가
- workReportModel: partner_work_reports → daily_work_reports
- partner-portal: check_out_at/check_in_at → check_out_time/check_in_time
- checkinModel findTodayByCompany: LEFT JOIN has_work_report
- tkpurchase-core/tksafety-core: navbar match '' 제거
- checkinController: checkOut에 업무현황 검증, stats() 추가
- workReportController: checkin_id 필수 + schedule 일치 검증
- checkinRoutes: GET / 대시보드 통계 라우트 추가
- nginx.conf: visit.html → tksafety 리다이렉트
- migration-purchase-safety.sql: DDL 동기화
- migration-purchase-safety-patch.sql: 신규 패치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:22:25 +09:00
Hyungi Ahn
b800792152 feat: 구매/안전 시스템 전면 개편 — tkpurchase 개편 + tksafety 신규 + 권한 보강
Phase 1: tkuser 협력업체 CRUD 이관 (읽기전용 → 전체 CRUD)
Phase 2: tkpurchase 개편 — 일용공 신청/확정, 작업일정, 업무현황, 계정관리, 협력업체 포털
Phase 3: tksafety 신규 시스템 — 방문관리 + 안전교육 신고
Phase 4: SSO 인증 보강 (partner_company_id JWT, 만료일 체크), 권한 테이블 기반 접근 제어

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:42:59 +09:00
Hyungi Ahn
a195dd1d50 fix: JWT 디코딩 시 한글 깨짐 수정 (atob → TextDecoder)
atob()가 UTF-8 멀티바이트 문자를 Latin-1로 처리하여 한글 이름이
깨지는 문제 수정. Uint8Array + TextDecoder 패턴으로 교체.
tkpurchase, tkuser nginx에 charset utf-8 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:06:21 +09:00
Hyungi Ahn
281f5d35d1 feat: tkpurchase 시스템 Phase 1 - 협력업체 마스터 + 당일 방문 관리
신규 독립 시스템 tkpurchase (구매/방문 관리) 구축:
- 협력업체 CRUD + 소속 작업자 관리 (마스터 데이터 소유)
- 당일 방문 등록/체크인/체크아웃 + 일괄 마감
- 업체 자동완성, CSV 내보내기, 집계 통계
- 자정 자동 체크아웃 (node-cron)
- tkuser 협력업체 읽기 전용 탭 + 권한 그리드(tkpurchase-perms) 추가
- docker-compose에 tkpurchase-api/web 서비스 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:45:37 +09:00
Hyungi Ahn
5b1b89254c feat: RAG 임베딩 자동 동기화 + AI 서비스 개선
- 부적합 라이프사이클 전 과정에서 Qdrant 임베딩 자동 동기화
  - 관리함 5개 저장 함수 + 수신함 상태 변경 시 fire-and-forget sync
  - 30분 주기 전체 재동기화 안전망 (FastAPI lifespan 백그라운드 태스크)
  - build_document_text에 카테고리(final_category/category) 포함
- RAG 질의에 DB 통계 집계 지원 (카테고리별/부서별 건수)
- Qdrant client.search → query_points API 마이그레이션
- AI 어시스턴트 페이지 권한 추가 (tkuser)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:05:32 +09:00
Hyungi Ahn
65db787f92 fix: system2-web nginx ai-service upstream을 ai.hyungi.net으로 변경
ai-service가 맥미니로 이전됐으나 system2-web nginx.conf만 미반영되어
컨테이너 시작 실패. system3과 동일하게 외부 URL로 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:02:30 +09:00
Hyungi Ahn
d827f22f4d feat: tkreport/tkqc UX 개선 - 신고 완료 모달, 크로스시스템 배너, AI 도우미 가시성
- 신고 제출 후 alert → 성공 모달로 교체 (신고현황/새신고 버튼)
- cross-nav.js: tkreport 페이지 상단 크로스시스템 네비게이션 배너
- report-status.html: AI 신고 도우미 버튼 추가
- common-header.js: tkqc 헤더에 "신고" 외부 링크 추가
- 배포 스크립트/가이드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:00:14 +09:00
Hyungi Ahn
85f674c9cb feat: ai-service를 ds923에서 맥미니로 이전
- ChromaDB → Qdrant 전환 (맥미니 기존 인스턴스, tk_qc_issues 컬렉션)
- Ollama 임베딩/텍스트 생성 URL 분리 (임베딩: 맥미니, 텍스트: GPU서버)
- MLX fallback 제거, Ollama 단일 경로로 단순화
- ds923 docker-compose에서 ai-service 제거
- gateway/system3-web nginx: ai-service 프록시를 ai.hyungi.net 경유로 변경
- resolver + 변수 기반 proxy_pass로 런타임 DNS 해석 (컨테이너 시작 실패 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:36:42 +09:00
Hyungi Ahn
2d25d54589 fix: Chrome 로그아웃 실패 수정 - 쿠키 삭제 시 secure/samesite 속성 추가
Chrome은 secure 쿠키 삭제 시 삭제 문자열에도 secure 플래그가 필요함.
6개 파일의 cookieRemove 함수에 '; secure; samesite=lax' 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:59:06 +09:00
Hyungi Ahn
d42380ff63 feat: 챗봇 신고 페이지 AI 백엔드 추가 및 기타 개선
- ai-service: 챗봇 분석/요약 엔드포인트 추가 (chatbot.py, chatbot_service.py)
- tkreport: 챗봇 신고 페이지 (chat-report.html/js/css), nginx ai-api 프록시
- tkreport: 이미지 업로드 서비스 개선, M-Project 연동 신고자 정보 전달
- system1: TBM 작업보고서 UI 개선
- TKQC: 관리함/수신함 기능 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:11:00 +09:00
Hyungi Ahn
5aeda43605 fix: 전 시스템 Chrome 무한 로그인 루프 해결 및 role 대소문자 통일
- gateway: 로그인 페이지 자동 리다이렉트 시 SSO 쿠키 재설정 + Cache-Control no-store
- tkreport(system2): SW 해제, 401 핸들러 리다이렉트 제거, 루프 방지, localStorage 백업
- TKQC 모바일(system3): mCheckAuth를 authManager 위임으로 변경, 루프 방지
- TKQC 공통(system3): api.js 로그인 URL 캐시 버스팅, auth-manager localStorage 백업
- tkuser: SW 해제, 401 핸들러 수정, 루프 방지, localStorage 백업, requireAdmin role 소문자 통일
- system1: 작업보고서 admin role 대소문자 무시, refresh 토큰에 role 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:10:46 +09:00
Hyungi Ahn
df0a125faa fix: TKQC Chrome 무한 로그인 루프 해결 및 SSO 리다이렉트 수정
- Service Worker 제거: 캐시 간섭으로 인한 Chrome 인증 루프 방지
  - sw.js를 자기 정리(캐시 삭제+해제) 버전으로 교체
  - auth-manager.js에 SW 해제 코드 추가 (모든 페이지 즉시 적용)
  - page-preloader.js SW 등록을 해제 로직으로 전환
- Gateway 로그인 리다이렉트: isSafeRedirect() 함수로 서브도메인 절대 URL 허용
  - *.technicalkorea.net만 허용하여 open redirect 방지 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 03:13:24 +09:00
Hyungi Ahn
81478dc6ac fix: TKQC 인증 흐름 무한루프 방지 및 스크립트 로드 순서 정리
- api.js 401 핸들러: window.location.href 리다이렉트 제거, throw Error로 변경 (auth-manager가 처리)
- auth-manager.js refreshAuth(): throw error → return null (무한 리다이렉트 방지)
- auth-manager.js setupTokenExpiryCheck(): catch→logout 대신 then으로 변경 (이중 리다이렉트 방지)
- 모든 HTML: api.js를 auth-manager.js보다 먼저 로드하도록 순서 수정
- 누락 페이지(archive, issue-view)에 api.js + auth-manager.js 추가
- 전체 HTML 캐시 버스팅 버전 v=20260308로 통일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:11:40 +09:00
844 changed files with 107804 additions and 34532 deletions

92
.claude/WORKFLOW-GUIDE.md Normal file
View File

@@ -0,0 +1,92 @@
# Claude Code 워크플로우 가이드 — TK Factory Services
## 1. CLAUDE.md 활용
- 프로젝트 루트의 `CLAUDE.md`가 매 세션 자동 로드 → 프로젝트 컨텍스트 즉시 파악
- 서비스별 CLAUDE.md 추가 가능 (예: `system1-factory/CLAUDE.md`) → 해당 디렉토리 작업 시 추가 로드
- git으로 관리되므로 팀원 간 공유 가능
## 2. 슬래시 커맨드 (.claude/commands/)
| 커맨드 | 설명 | 사용 예 |
|--------|------|---------|
| `/deploy` | 원클릭 NAS 배포 | `/deploy system1-web` |
| `/check-deploy` | NAS 서비스 상태 점검 | `/check-deploy` |
| `/cache-bust` | 변경된 JS/CSS 캐시 버스팅 일괄 갱신 | `/cache-bust` |
| `/add-page` | System1 새 HTML 페이지 스캐폴드 | `/add-page consumable/stock 소모품 재고` |
| `/add-api` | System1 새 API 엔드포인트 스캐폴드 | `/add-api consumable-stock` |
커스텀 추가: `.claude/commands/``.md` 파일 추가하면 자동 인식.
## 3. Plan 모드 활용
적합한 작업:
- 여러 서비스에 걸친 기능 추가 (예: 새 마이크로서비스)
- DB 스키마 변경 + API + 프론트엔드 동시 수정
- 대규모 리팩터링 (30+ 파일 변경)
사용법:
1. `/plan`으로 Plan 모드 진입
2. Claude가 코드 탐색 → 구현 계획 작성
3. 계획 검토 · 수정 → 승인
4. 자동 실행
## 4. 서브에이전트 활용 패턴
| 에이전트 | 용도 | 예시 |
|----------|------|------|
| Explore | 코드베이스 탐색 | "system1과 system2에서 인증 처리 방식 비교해줘" |
| Plan | 구현 설계 | "tkeg에 새 모듈 추가 계획 세워줘" |
- 독립적 탐색은 병렬 실행 가능 (최대 3개)
- 넓은 범위 검색에 유용 (단순 파일 찾기는 직접 검색이 빠름)
## 5. 검증 피드백 루프 (Boris Cherny 패턴)
코드 변경 후 검증 사이클:
1. **빌드 확인**`docker compose build <서비스>`
2. **배포**`/deploy <서비스>`
3. **health check** → 자동 실행 (deploy에 포함)
4. **수동 확인** → 브라우저에서 모바일 + 데스크탑 테스트
## 6. 효율적 작업 패턴
### 프론트엔드 수정 (Vanilla JS 서비스)
1. HTML/JS/CSS 수정
2. `/cache-bust`로 캐시 버스팅 갱신
3. `git commit` + `/deploy system1-web`
### API 수정 (Node.js 서비스)
1. model → controller → route 수정
2. `git commit` + `/deploy system1-api`
3. `curl`로 API 테스트
### tkeg 수정 (React + FastAPI)
1. 프론트: `npm run build` (Vite 자동 해시, 캐시 버스팅 불필요)
2. 백엔드: FastAPI 코드 수정
3. `git commit` + `/deploy tkeg-api tkeg-web`
### 여러 서비스 동시 수정
1. `/plan`으로 계획 수립
2. 변경 실행
3. 영향받는 서비스 모두 재배포: `/deploy system1-api system1-web system2-api`
## 7. 자주 쓰는 디버깅 명령
```bash
# DB 접속 (NAS)
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p\"\$MYSQL_PASSWORD\" hyungi"
# 로그 확인 (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 <서비스>"
# 컨테이너 상태 (NAS)
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose ps"
# PostgreSQL 접속 (tkeg)
ssh hyungi@100.71.132.52 "docker exec tk-tkeg-postgres psql -U tkbom_user -d tk_bom"
```
## 8. 팁
- **작은 단위로 커밋**: 서비스별로 나눠서 커밋하면 롤백이 쉬움
- **CLAUDE.md 업데이트**: 새 서비스 추가 시 서비스 맵 테이블 갱신
- **메모리 활용**: 반복되는 피드백은 Claude가 자동 저장 → 다음 세션에도 적용

View File

@@ -0,0 +1,30 @@
System1(system1-factory) 새 API 엔드포인트를 생성합니다.
인자: $ARGUMENTS (예: "consumable-stock 소모품 재고" 또는 리소스명)
절차:
1. 기존 패턴 확인:
- system1-factory/api/ 아래 기존 model, controller, route 파일 하나씩 읽어 패턴 파악
2. Model 생성 (`api/models/<리소스>.js`):
- Knex 기반 DB 쿼리 패턴
- CRUD 기본 메서드: getAll, getById, create, update, delete
3. Controller 생성 (`api/controllers/<리소스>Controller.js`):
- responseFormatter (res.success, res.error, res.paginated) 사용
- try/catch + 에러 핸들링 패턴
- 페이지네이션 지원 (GET list)
4. Route 생성 (`api/routes/<리소스>Routes.js`):
- Express Router
- auth 미들웨어 적용
- RESTful 패턴: GET /, GET /:id, POST /, PUT /:id, DELETE /:id
5. 라우트 등록:
- `api/config/routes.js` (또는 app.js/server.js)에 새 라우트 추가
6. 생성된 파일 목록 + API 엔드포인트 정리 보고
주의:
- 다른 서비스(system2, tkpurchase 등)의 API 추가 시 해당 서비스의 패턴을 먼저 확인
- System3, tkeg는 FastAPI 패턴 (Python)

View File

@@ -0,0 +1,25 @@
System1(system1-factory) 새 HTML 페이지를 생성합니다.
인자: $ARGUMENTS (예: "consumable/stock 소모품 재고 관리")
→ 경로와 페이지 제목을 파싱
절차:
1. 기존 페이지를 템플릿으로 사용:
- system1-factory/web/ 아래 비슷한 기능의 기존 HTML 페이지를 찾아 참조
- 공통 구조: 헤더(nav), 사이드네비, 메인 콘텐츠 영역
2. 새 HTML 파일 생성 (`system1-factory/web/<경로>.html`):
- 공통 CSS: tkfb.css, Tailwind CDN, Font Awesome
- 공통 JS: tkfb-core.js, api-base.js
- 페이지 제목, 설명 커스터마이즈
- 사이드네비에 현재 페이지 active 표시
3. 대응 JS 컨트롤러 파일 생성 (필요 시):
- `system1-factory/web/js/<경로>.js`
- 기존 JS 패턴 따르기 (DOMContentLoaded, API 호출 등)
4. 생성된 파일 목록 보고 + 캐시 버스팅 안내
주의:
- 다른 서비스(system2, system3 등)에 페이지 추가 시 해당 서비스의 패턴을 먼저 확인
- HTML의 script/link 태그에 ?v= 버전 포함

View File

@@ -0,0 +1,20 @@
커밋 전 변경된 JS/CSS 파일에 대해 캐시 버스팅 버전을 갱신합니다.
절차:
1. `git diff --name-only HEAD`로 아직 커밋 안 된 변경사항 중 .js, .css 파일 목록 추출
- staged + unstaged 모두 포함: `git diff --name-only HEAD` + `git diff --name-only --cached`
- untracked 파일도 포함: `git ls-files --others --exclude-standard -- '*.js' '*.css'`
2. 각 변경된 JS/CSS 파일에 대해:
- 해당 파일을 참조하는 HTML 파일을 grep으로 검색 (같은 서비스 디렉토리 내)
- `<script src="...파일명...?v=...">` 또는 `<link href="...파일명...?v=...">` 패턴 찾기
3. 찾은 HTML 참조의 `?v=` 버전을 오늘 날짜 기반으로 갱신:
- 포맷: `?v=YYYYMMDD01` (같은 날 여러 번이면 NN 증가)
- 기존 ?v= 없으면 추가
4. 변경된 HTML 파일 목록과 갱신된 버전 번호를 보고
주의사항:
- tkeg는 Vite 빌드이므로 제외 (자동 해시)
- 같은 파일의 ?v=를 여러 HTML에서 참조할 수 있으므로 모두 갱신

View File

@@ -0,0 +1,25 @@
NAS 서비스 상태 점검:
1. SSH로 docker compose ps 실행:
```
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose ps"
```
2. 각 주요 서비스 health check (curl로 확인):
- Gateway (30000): curl http://100.71.132.52:30000/
- SSO Auth (30050): curl http://100.71.132.52:30050/health
- System1 API (30005): curl http://100.71.132.52:30005/api/health
- System2 API (30105): curl http://100.71.132.52:30105/api/health
- System3 API (30200): curl http://100.71.132.52:30200/health
- tkuser API (30300): curl http://100.71.132.52:30300/api/health
- tkpurchase API (30400): curl http://100.71.132.52:30400/api/health
- tksafety API (30500): curl http://100.71.132.52:30500/api/health
- tksupport API (30600): curl http://100.71.132.52:30600/api/health
- tkeg API (30700): curl http://100.71.132.52:30700/health
3. 로컬 vs NAS git 커밋 비교:
- 로컬: `git rev-parse HEAD`
- NAS: `ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git rev-parse HEAD"`
- 차이가 있으면 어떤 커밋이 빠져있는지 보고
4. 결과를 표로 정리하여 보고 (서비스명 | 상태 | health check | 비고)

View File

@@ -0,0 +1,26 @@
전제조건: Tailscale SSH 키 인증 (hyungi@100.71.132.52) 설정 완료
서비스명: $ARGUMENTS
배포 실행 절차:
1. `git status`로 커밋 안 된 변경사항 확인 → 있으면 사용자에게 경고하고 계속할지 확인
2. `git push` 실행
3. SSH로 NAS에서 배포:
```
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 $ARGUMENTS"
```
4. 30초 대기 후 health check:
- API 서비스면: `curl -s http://100.71.132.52:<포트>/api/health` 또는 `/health`
- Web 서비스면: `curl -s -o /dev/null -w '%{http_code}' http://100.71.132.52:<포트>/`
5. 결과 보고 (성공/실패, 걸린 시간)
서비스별 포트 매핑:
- system1-api: 30005, system1-web: 30080
- system2-api: 30105, system2-web: 30180
- system3-api: 30200, system3-web: 30280
- tkuser-api: 30300, tkuser-web: 30380
- tkpurchase-api: 30400, tkpurchase-web: 30480
- tksafety-api: 30500, tksafety-web: 30580
- tksupport-api: 30600, tksupport-web: 30680
- tkeg-api: 30700, tkeg-web: 30780
- gateway: 30000, sso-auth: 30050

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 필수 (선택적→필수) |

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.claude
**/.env
**/node_modules
**/logs
**/__pycache__
**/.pytest_cache
**/venv
**/web/
*.md
!shared/**
FEATURES.pdf

View File

@@ -99,4 +99,16 @@ OLLAMA_TIMEOUT=120
# tkfb.technicalkorea.net → http://tk-gateway:80
# tkreport.technicalkorea.net → http://tk-system2-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

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

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ venv/
coverage/
db_archive/
*.log
DEPLOY_LOG

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)

108
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,108 @@
# TK Factory Services — 시스템 아키텍처
## 전체 구조
21개 컨테이너, Docker Compose 기반, Cloudflare Tunnel로 외부 노출.
```
[Cloudflare Tunnel] → tk-cloudflared
├── tkfb.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
├── tkuser.technicalkorea.net → tk-tkuser-web:80 (통합관리)
├── tkpurchase.technicalkorea.net → tk-tkpurchase-web:80 (구매관리)
├── tksafety.technicalkorea.net → tk-tksafety-web:80 (안전관리)
└── tksupport.technicalkorea.net → tk-tksupport-web:80 (행정지원)
```
## 서비스 목록
| 서비스 | 컨테이너명 | 로컬 포트 | 내부 포트 | 역할 |
|---|---|---|---|---|
| **Gateway** | tk-gateway | 30000 | 80 | 로그인/대시보드/공유JS + SSO·API 프록시 |
| **System1 API** | tk-system1-api | 30005 | 3005 | 공장관리 백엔드 (Node.js) |
| **System1 Web** | tk-system1-web | 30080 | 80 | 공장관리 프론트엔드 (nginx) |
| **System1 FastAPI** | tk-system1-fastapi | 30008 | 8000 | FastAPI Bridge |
| **System2 API** | tk-system2-api | 30105 | 3005 | 신고 백엔드 (Node.js) |
| **System2 Web** | tk-system2-web | 30180 | 80 | 신고 프론트엔드 (nginx) |
| **System3 API** | tk-system3-api | 30200 | 8000 | 부적합관리 백엔드 (FastAPI) |
| **System3 Web** | tk-system3-web | 30280 | 80 | 부적합관리 프론트엔드 (nginx) |
| **User API** | tk-tkuser-api | 30300 | 3000 | 사용자관리 백엔드 (Node.js) |
| **User Web** | tk-tkuser-web | 30380 | 80 | 사용자관리 프론트엔드 (nginx) |
| **Purchase API** | tk-tkpurchase-api | 30400 | 3000 | 구매관리 백엔드 (Node.js) |
| **Purchase Web** | tk-tkpurchase-web | 30480 | 80 | 구매관리 프론트엔드 (nginx) |
| **Safety API** | tk-tksafety-api | 30500 | 3000 | 안전관리 백엔드 (Node.js) |
| **Safety Web** | tk-tksafety-web | 30580 | 80 | 안전관리 프론트엔드 (nginx) |
| **Support API** | tk-tksupport-api | 30600 | 3000 | 행정지원 백엔드 (Node.js) |
| **Support Web** | tk-tksupport-web | 30680 | 80 | 행정지원 프론트엔드 (nginx) |
| **SSO Auth** | tk-sso-auth | 30050 | 3000 | SSO 인증 서비스 (Node.js) |
| **MariaDB** | tk-mariadb | 30306 | 3306 | 메인 데이터베이스 |
| **Redis** | tk-redis | — | 6379 | 세션/캐시 |
| **phpMyAdmin** | tk-phpmyadmin | 30880 | 80 | DB 관리 도구 |
| **Cloudflared** | tk-cloudflared | — | — | Cloudflare Tunnel 에이전트 |
## 요청 흐름
```
브라우저 → Cloudflare DNS → Cloudflare Tunnel → cloudflared 컨테이너
→ 서브도메인별 라우팅 → 해당 web 컨테이너 (nginx)
→ /api/ → 해당 API 컨테이너
→ /auth/ → sso-auth 컨테이너
→ /uploads/ → API 컨테이너 (파일 서빙)
→ 나머지 → 정적 파일 (SPA fallback)
```
## SSO 인증 흐름
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
2. 토큰 없음/만료 → `tkfb.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
6. 각 서비스의 API는 `Authorization: Bearer <token>` 헤더로 인증 검증
## CORS 관리
- **sso-auth**: `sso-auth-service/config/` — 모든 `*.technicalkorea.net` 서브도메인 허용
- **system1-factory API**: `system1-factory/api/config/cors.js` — 허용 origin 명시적 관리
- 로컬 네트워크(192.168.x.x)는 자동 허용
## 공유 JS
Gateway(`/shared/`)에서 서빙:
- `notification-bell.js` — 알림 벨 UI, 모든 서비스에서 로딩
- `nav-header.js` — 공통 네비게이션 헤더
각 서비스의 core.js에서 동적 로딩:
```
프로덕션: https://tkfb.technicalkorea.net/shared/notification-bell.js
로컬: http://localhost:30000/shared/notification-bell.js
```
## 신규 서비스 추가 체크리스트
1. `<service>/api/` + `<service>/web/` 디렉토리 생성 (Dockerfile 포함)
2. `docker-compose.yml`에 api + web 서비스 추가 (포트 할당)
3. `cloudflared.depends_on`에 web 서비스 추가
4. Cloudflare Tunnel 대시보드에서 서브도메인 → 컨테이너 라우팅 추가
5. `sso-auth-service/config/`에 새 origin 추가
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
8. 로그인 리다이렉트: `tkfb.technicalkorea.net/dashboard?redirect=` 패턴 사용
## 배포 절차
```bash
# 로컬에서 push
git push
# NAS에서 pull + rebuild
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 <서비스명>"
```
전체 재시작: `docker compose up -d --build`
특정 서비스만: `docker compose up -d --build gateway system1-web`

82
CLAUDE.md Normal file
View File

@@ -0,0 +1,82 @@
# CLAUDE.md — TK Factory Services
## 프로젝트 개요
공장관리 플랫폼. 8개 마이크로서비스, 21개+ Docker 컨테이너. Synology NAS 배포, Cloudflare Tunnel.
## 서비스 맵
| 서비스 | 디렉토리 | 스택 | API/Web 포트 | DB |
|--------|----------|------|-------------|-----|
| Gateway | gateway/ | nginx | 30000 | - |
| SSO Auth | sso-auth-service/ | Node.js | 30050 | MariaDB |
| System1 공장관리 | system1-factory/ | Node.js + nginx | 30005/30080 | MariaDB |
| System2 신고 | system2-report/ | Node.js + nginx | 30105/30180 | MariaDB |
| System3 부적합 | system3-nonconformance/ | FastAPI + nginx | 30200/30280 | MariaDB |
| tkuser 사용자 | user-management/ | Node.js + nginx | 30300/30380 | MariaDB |
| tkpurchase 소모품 | tkpurchase/ | Node.js + nginx | 30400/30480 | MariaDB |
| tksafety 안전 | tksafety/ | Node.js + nginx | 30500/30580 | MariaDB |
| tksupport 행정 | tksupport/ | Node.js + nginx | 30600/30680 | MariaDB |
| tkeg BOM | tkeg/ | FastAPI + React(Vite) | 30700/30780 | PostgreSQL |
System1에는 FastAPI bridge도 있음 (30008, AI 연동용).
## 기술 스택
- **프론트엔드**: Vanilla JS + Tailwind CDN + Font Awesome (빌드 없음). tkeg만 React+Vite+MUI
- **백엔드(Node)**: Express + mysql2/Knex. 패턴: Routes → Controllers → Services → Models → DB
- **백엔드(Python)**: FastAPI + SQLAlchemy (System3, tkeg)
- **DB**: MariaDB 공유(hyungi) + PostgreSQL(tkeg: tk_bom) + Redis(세션)
- **인증**: JWT SSO — sso_token 쿠키(domain=.technicalkorea.net) + localStorage 폴백
- **접근레벨**: worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
## 코드 규칙
1. **캐시 버스팅 필수**: JS/CSS 수정 시 HTML `<script>`/`<link>` 태그의 `?v=YYYYMMDDNN` 반드시 갱신
2. **API 응답 포맷**: `{ success: true, data: {...}, message: "..." }` — res.success(), res.paginated() 등
3. **커밋 메시지**: `type(scope): 한국어 설명` (예: `fix(tkfb): 모바일 레이아웃 수정`)
4. **CSS 전역**: system1-factory → tkfb.css (모든 페이지 로드). 모바일 = @media (max-width: 768px)
## 배포
전제: Tailscale SSH 키 인증 설정 완료 (hyungi@100.71.132.52)
```bash
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 참조.
## 개발 주의사항 — 실수 기록
### 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

@@ -1,6 +1,6 @@
# TK Factory Services - NAS 배포 가이드
> 최종 업데이트: 2026-02-09
> 최종 업데이트: 2026-03-12
## 아키텍처 개요
@@ -95,7 +95,7 @@ cat "/volume1/Technicalkorea Document/tkfb-package/.env" | grep MYSQL
cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES
# 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)
# 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 > \
/volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql
# 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 > \
/volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql
@@ -128,21 +128,38 @@ cp -r /volume1/docker/tkqc/tkqc-package/uploads \
/volume1/docker/backups/$(date +%Y%m%d)/tkqc-uploads
```
### Step 2: 프로젝트 NAS 전송
### Step 2: 프로젝트 NAS 전송 (git 기반)
NAS에 git이 설치되어 있으므로, Gitea에서 직접 clone/pull 합니다.
**최초 설정 (1회):**
```bash
# 로컬 Mac에서 실행
# node_modules 제외하여 전송
rsync -avz --exclude='node_modules' --exclude='.env' --exclude='__pycache__' \
-e ssh /Users/hyungiahn/Documents/code/tk-factory-services/ \
hyungi@192.168.0.3:/volume1/docker/tk-factory-services/
# NAS SSH 접속
ssh hyungi@192.168.0.3
# .env 파일 별도 전송
scp -O /Users/hyungiahn/Documents/code/tk-factory-services/.env \
hyungi@192.168.0.3:/volume1/docker/tk-factory-services/.env
# git credential 저장 설정
git config --global credential.helper store
# 기존 배포 디렉토리 백업 후 clone
cd /volume1/docker_1/
mv tk-factory-services tk-factory-services.bak
git clone https://git.hyungi.net/hyungi/tk-factory-services.git
# 기존 .env 복원
cp tk-factory-services.bak/.env tk-factory-services/.env
# 확인 후 백업 삭제
cd tk-factory-services && git log -1
rm -rf ../tk-factory-services.bak
```
> **참고**: Synology에서 `scp`는 `-O` 옵션 필수 (레거시 프로토콜)
**이후 배포 (맥북에서 원커맨드):**
```bash
# 맥북에서 실행 - 자동으로 push 확인, NAS 비교, 빌드, health check 수행
./scripts/deploy-remote.sh
```
> **참고**: 설정 파일 `~/.tk-deploy-config` 필요 (아래 "원격 배포 스크립트" 섹션 참고)
### Step 3: 기존 서비스 중지
@@ -150,11 +167,11 @@ scp -O /Users/hyungiahn/Documents/code/tk-factory-services/.env \
# NAS SSH
# TK-FB 중지
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 중지
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: 통합 서비스 기동
@@ -163,10 +180,10 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
cd /volume1/docker/tk-factory-services
# 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 마이그레이션
@@ -179,7 +196,7 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --ta
# (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 설정
@@ -220,22 +237,69 @@ curl -s http://localhost:30050/api/health # SSO Auth
---
## 원격 배포 스크립트
맥북에서 원커맨드로 배포/확인/롤백할 수 있는 스크립트입니다.
### 설정 파일
`~/.tk-deploy-config` 생성 (git 추적 안 함):
```bash
NAS_HOST=100.71.132.52
NAS_USER=hyungi
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
NAS_SUDO_PASS=<sudo 비밀번호>
```
### 사용법
```bash
# 배포 (pre-flight 체크 → NAS 비교 → 빌드 → health check → 로그 기록)
./scripts/deploy-remote.sh
# 배포 상태 확인 (NAS 버전, origin 대비 차이, 컨테이너 상태)
./scripts/check-version.sh
# 특정 커밋으로 롤백
./scripts/rollback-remote.sh <commit-hash>
```
### 배포 흐름
1. 로컬에서 코드 수정 → `git commit``git push`
2. `./scripts/deploy-remote.sh` 실행
3. 스크립트가 자동으로: 로컬 clean 확인 → origin push 확인 → NAS 버전 비교 → 배포될 커밋 표시 → 사용자 확인 → git pull → docker build → nginx 재시작 → health check
---
## 롤백 방법
문제 발생 시 기존 서비스로 복원:
### git 기반 롤백 (권장)
```bash
# 맥북에서 실행 - 특정 커밋으로 롤백
./scripts/rollback-remote.sh <commit-hash>
# 최근 커밋 목록 확인
git log --oneline -10
```
### 레거시 서비스 복원 (통합 이전으로)
문제 발생 시 기존 개별 서비스로 복원:
```bash
# 통합 서비스 중지
cd /volume1/docker/tk-factory-services
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
cd /volume1/docker_1/tk-factory-services
echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
# TK-FB 복원
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 복원
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)
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-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/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data/chroma
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --chown=appuser:appuser . .
RUN mkdir -p /app/data && chown appuser:appuser /app/data
EXPOSE 8000
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -2,16 +2,22 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
OLLAMA_BASE_URL: str = "https://gpu.hyungi.net"
# GPU서버 Ollama (텍스트 생성)
OLLAMA_BASE_URL: str = "http://192.168.1.186:11434"
OLLAMA_TEXT_MODEL: str = "qwen3.5:9b-q8_0"
OLLAMA_EMBED_MODEL: str = "bge-m3"
OLLAMA_TIMEOUT: int = 120
MLX_BASE_URL: str = "https://llm.hyungi.net"
MLX_TEXT_MODEL: str = "/Users/hyungi/mlx-models/Qwen3.5-27B-4bit"
# 맥미니 Ollama (임베딩) — OrbStack: host.internal / Docker Desktop: host.docker.internal
OLLAMA_EMBED_URL: str = "http://host.internal:11434"
OLLAMA_EMBED_MODEL: str = "bge-m3"
DB_HOST: str = "mariadb"
DB_PORT: int = 3306
# 맥미니 Qdrant (기존 인스턴스, 회사 전용 컬렉션)
QDRANT_URL: str = "http://host.internal:6333"
QDRANT_COLLECTION: str = "tk_qc_issues"
# ds923 MariaDB (Tailscale)
DB_HOST: str = "100.71.132.52"
DB_PORT: int = 30306
DB_USER: str = "hyungi_user"
DB_PASSWORD: str = ""
DB_NAME: str = "hyungi"
@@ -19,8 +25,8 @@ class Settings(BaseSettings):
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
SYSTEM1_API_URL: str = "http://system1-api:3005"
CHROMA_PERSIST_DIR: str = "/app/data/chroma"
# ds923 System1 API (Tailscale)
SYSTEM1_API_URL: str = "http://100.71.132.52:30005"
METADATA_DB_PATH: str = "/app/data/metadata.db"
class Config:

View File

@@ -1,19 +1,35 @@
import chromadb
import logging
import uuid
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
from config import settings
logger = logging.getLogger(__name__)
class VectorStore:
def __init__(self):
self.client = None
self.collection = None
self.collection = settings.QDRANT_COLLECTION # "tk_qc_issues"
def initialize(self):
self.client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_DIR)
self.collection = self.client.get_or_create_collection(
name="qc_issues",
metadata={"hnsw:space": "cosine"},
self.client = QdrantClient(url=settings.QDRANT_URL)
self._ensure_collection()
def _ensure_collection(self):
collections = [c.name for c in self.client.get_collections().collections]
if self.collection not in collections:
# bge-m3 기본 출력 = 1024 dims
self.client.create_collection(
collection_name=self.collection,
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)
@staticmethod
def _to_uuid(doc_id) -> str:
"""문자열/정수 ID → UUID5 변환 (Qdrant 호환)"""
return str(uuid.uuid5(uuid.NAMESPACE_URL, str(doc_id)))
def upsert(
self,
doc_id: str,
@@ -21,11 +37,13 @@ class VectorStore:
embedding: list[float],
metadata: dict = None,
):
self.collection.upsert(
ids=[doc_id],
documents=[document],
embeddings=[embedding],
metadatas=[metadata] if metadata else None,
point_id = self._to_uuid(doc_id)
payload = {"document": document, "original_id": str(doc_id)}
if metadata:
payload.update(metadata)
self.client.upsert(
collection_name=self.collection,
points=[PointStruct(id=point_id, vector=embedding, payload=payload)],
)
def query(
@@ -34,42 +52,55 @@ class VectorStore:
n_results: int = 5,
where: dict = None,
) -> list[dict]:
kwargs = {
"query_embeddings": [embedding],
"n_results": n_results,
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
query_filter = self._build_filter(where) if where else None
try:
results = self.collection.query(**kwargs)
except Exception:
response = self.client.query_points(
collection_name=self.collection,
query=embedding,
limit=n_results,
query_filter=query_filter,
with_payload=True,
)
except Exception as e:
logger.error(f"Qdrant search failed: {e}", exc_info=True)
return []
items = []
if results and results["ids"] and results["ids"][0]:
for i, doc_id in enumerate(results["ids"][0]):
for hit in response.points:
payload = hit.payload or {}
item = {
"id": doc_id,
"document": results["documents"][0][i] if results["documents"] else "",
"distance": results["distances"][0][i] if results["distances"] else 0,
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
"id": payload.get("original_id", str(hit.id)),
"document": payload.get("document", ""),
"distance": round(1 - hit.score, 4), # cosine score → distance
"metadata": {k: v for k, v in payload.items() if k not in ("document", "original_id")},
"similarity": round(hit.score, 4),
}
# cosine distance → similarity
item["similarity"] = round(1 - item["distance"], 4)
items.append(item)
return items
@staticmethod
def _build_filter(where: dict) -> Filter:
"""ChromaDB 스타일 where 조건 → Qdrant Filter 변환"""
conditions = []
for key, value in where.items():
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
return Filter(must=conditions)
def delete(self, doc_id: str):
self.collection.delete(ids=[doc_id])
point_id = self._to_uuid(doc_id)
self.client.delete(
collection_name=self.collection,
points_selector=[point_id],
)
def count(self) -> int:
return self.collection.count()
info = self.client.get_collection(collection_name=self.collection)
return info.points_count
def stats(self) -> dict:
return {
"total_documents": self.count(),
"collection_name": "qc_issues",
"collection_name": self.collection,
}

View File

@@ -1,3 +1,5 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
@@ -5,12 +7,14 @@ from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from routers import health, embeddings, classification, daily_report, rag
from routers import health, embeddings, classification, daily_report, rag, chatbot
from db.vector_store import vector_store
from db.metadata_store import metadata_store
from services.ollama_client import ollama_client
from middlewares.auth import verify_token
logger = logging.getLogger(__name__)
PUBLIC_PATHS = {"/", "/api/ai/health", "/api/ai/models"}
@@ -25,11 +29,29 @@ class AuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
async def _periodic_sync():
"""30분마다 전체 이슈 재동기화 (안전망)"""
await asyncio.sleep(60) # 시작 후 1분 대기 (초기화 완료 보장)
while True:
try:
from services.embedding_service import sync_all_issues
result = await sync_all_issues()
logger.info(f"Periodic sync completed: {result}")
except asyncio.CancelledError:
logger.info("Periodic sync task cancelled")
return
except Exception as e:
logger.warning(f"Periodic sync failed: {e}")
await asyncio.sleep(1800) # 30분
@asynccontextmanager
async def lifespan(app: FastAPI):
vector_store.initialize()
metadata_store.initialize()
sync_task = asyncio.create_task(_periodic_sync())
yield
sync_task.cancel()
await ollama_client.close()
@@ -64,6 +86,7 @@ app.include_router(embeddings.router, prefix="/api/ai")
app.include_router(classification.router, prefix="/api/ai")
app.include_router(daily_report.router, prefix="/api/ai")
app.include_router(rag.router, prefix="/api/ai")
app.include_router(chatbot.router, prefix="/api/ai")
@app.get("/")

View File

@@ -3,11 +3,14 @@
[질문]
{question}
[관련 부적합 데이터]
{stats_summary}
[관련 부적합 사례 (유사도 검색 결과)]
{retrieved_cases}
답변 규칙:
- 핵심을 먼저 말하고 근거 사례를 인용하세요
- 통계 요약이 있으면 통계 데이터를 우선 참고하고, 없으면 관련 사례만 참고하세요
- 핵심을 먼저 말하고 근거 데이터를 인용하세요
- 500자 이내로 간결하게 답변하세요
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
- 데이터에 없는 내용은 추측하지 마세요

View File

@@ -1,7 +1,7 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.27.0
chromadb==0.4.22
qdrant-client>=1.7.0
numpy==1.26.2
pydantic==2.5.0
pydantic-settings==2.1.0

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.chatbot_service import analyze_user_input, summarize_report
router = APIRouter(tags=["chatbot"])
class AnalyzeRequest(BaseModel):
user_text: str
categories: dict = {}
class SummarizeRequest(BaseModel):
description: str = ""
type: str = ""
category: str = ""
item: str = ""
location: str = ""
project: str = ""
@router.post("/chatbot/analyze")
async def chatbot_analyze(req: AnalyzeRequest):
try:
result = await analyze_user_input(req.user_text, req.categories)
return {"success": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 분석 중 오류가 발생했습니다")
@router.post("/chatbot/summarize")
async def chatbot_summarize(req: SummarizeRequest):
try:
result = await summarize_report(req.model_dump())
return {"success": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 요약 중 오류가 발생했습니다")

View File

@@ -10,20 +10,18 @@ async def health_check():
backends = await ollama_client.check_health()
stats = vector_store.stats()
# 메인 텍스트 모델명 결정 (Ollama 메인, MLX fallback)
# 메인 텍스트 모델명 결정
model_name = None
ollama_models = backends.get("ollama", {}).get("models", [])
if ollama_models:
model_name = ollama_models[0]
if not model_name and backends.get("mlx", {}).get("status") == "connected":
model_name = backends["mlx"].get("model")
text_models = backends.get("ollama_text", {}).get("models", [])
if text_models:
model_name = text_models[0]
return {
"status": "ok",
"service": "tk-ai-service",
"model": model_name,
"ollama": backends.get("ollama", {}),
"mlx": backends.get("mlx", {}),
"ollama_text": backends.get("ollama_text", {}),
"ollama_embed": backends.get("ollama_embed", {}),
"embeddings": stats,
}

View File

@@ -0,0 +1,121 @@
import json
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 도우미입니다.
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
신고 유형:
- nonconformity (부적합): 제품/작업 품질 관련 문제
- facility (시설설비): 시설, 설비, 장비 관련 문제
- safety (안전): 안전 위험, 위험 요소 관련 문제
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요:
{
"organized_description": "정리된 설명 (1-2문장)",
"suggested_type": "nonconformity 또는 facility 또는 safety",
"suggested_category_id": 카테고리ID(숫자) 또는 null,
"confidence": 0.0~1.0 사이의 확신도
}"""
SUMMARIZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 내용을 요약하는 AI 도우미입니다.
주어진 신고 정보를 보기 좋게 정리하여 한국어로 요약해주세요.
반드시 아래 JSON 형식으로만 응답하세요:
{
"summary": "요약 텍스트"
}"""
async def analyze_user_input(user_text: str, categories: dict) -> dict:
"""사용자 초기 입력을 분석하여 유형 제안 + 설명 정리"""
category_context = ""
for type_key, cats in categories.items():
type_label = {"nonconformity": "부적합", "facility": "시설설비", "safety": "안전"}.get(type_key, type_key)
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"
safe_text = sanitize_user_input(user_text)
prompt = f"""카테고리 목록:
{category_context}
사용자 입력:
<user_input>{safe_text}</user_input>
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
raw = await ollama_client.generate_text(prompt, system=ANALYZE_SYSTEM_PROMPT)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
result = json.loads(raw[start:end])
# Validate required fields
if "organized_description" not in result:
result["organized_description"] = user_text
if "suggested_type" not in result:
result["suggested_type"] = "nonconformity"
if "confidence" not in result:
result["confidence"] = 0.5
return result
except json.JSONDecodeError:
pass
return {
"organized_description": user_text,
"suggested_type": "nonconformity",
"suggested_category_id": None,
"confidence": 0.3,
}
async def summarize_report(data: dict) -> dict:
"""최종 신고 내용을 요약"""
prompt = f"""신고 정보:
<user_input>
- 설명: {sanitize_user_input(data.get('description', ''))}
- 유형: {sanitize_user_input(data.get('type', ''))}
- 카테고리: {sanitize_user_input(data.get('category', ''))}
- 항목: {sanitize_user_input(data.get('item', ''))}
- 위치: {sanitize_user_input(data.get('location', ''))}
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
</user_input>
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
raw = await ollama_client.generate_text(prompt, system=SUMMARIZE_SYSTEM_PROMPT)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
result = json.loads(raw[start:end])
if "summary" in result:
return result
except json.JSONDecodeError:
pass
# Fallback: construct summary manually
parts = []
if data.get("type"):
parts.append(f"[{data['type']}]")
if data.get("category"):
parts.append(data["category"])
if data.get("item"):
parts.append(f"- {data['item']}")
if data.get("location"):
parts.append(f"\n위치: {data['location']}")
if data.get("project"):
parts.append(f"\n프로젝트: {data['project']}")
if data.get("description"):
parts.append(f"\n내용: {data['description']}")
return {"summary": " ".join(parts) if parts else "신고 내용 요약"}

View File

@@ -82,6 +82,38 @@ def get_daily_qc_stats(date_str: str) -> dict:
return dict(row._mapping) if row else {}
def get_category_stats() -> list[dict]:
"""카테고리별 부적합 건수 집계"""
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT COALESCE(final_category, category) AS category, "
"COUNT(*) AS count "
"FROM qc_issues "
"GROUP BY COALESCE(final_category, category) "
"ORDER BY count DESC"
)
)
return [dict(row._mapping) for row in result]
def get_department_stats() -> list[dict]:
"""부서별 부적합 건수 집계"""
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT responsible_department AS department, "
"COUNT(*) AS count "
"FROM qc_issues "
"WHERE responsible_department IS NOT NULL "
"AND responsible_department != '' "
"GROUP BY responsible_department "
"ORDER BY count DESC"
)
)
return [dict(row._mapping) for row in result]
def get_issues_for_date(date_str: str) -> list[dict]:
with engine.connect() as conn:
result = conn.execute(

View File

@@ -1,11 +1,18 @@
import logging
from services.ollama_client import ollama_client
from db.vector_store import vector_store
from db.metadata_store import metadata_store
from services.db_client import get_all_issues, get_issue_by_id, get_issues_since
logger = logging.getLogger(__name__)
def build_document_text(issue: dict) -> str:
parts = []
cat = issue.get("final_category") or issue.get("category")
if cat:
parts.append(f"분류: {cat}")
if issue.get("description"):
parts.append(issue["description"])
if issue.get("final_description"):
@@ -84,6 +91,7 @@ async def sync_all_issues() -> dict:
async def sync_single_issue(issue_id: int) -> dict:
logger.info(f"Sync single issue: {issue_id}")
issue = get_issue_by_id(issue_id)
if not issue:
return {"status": "not_found"}

View File

@@ -5,7 +5,8 @@ from config import settings
class OllamaClient:
def __init__(self):
self.base_url = settings.OLLAMA_BASE_URL
self.text_url = settings.OLLAMA_BASE_URL # GPU서버 (텍스트 생성)
self.embed_url = settings.OLLAMA_EMBED_URL # 맥미니 (임베딩)
self.timeout = httpx.Timeout(float(settings.OLLAMA_TIMEOUT), connect=10.0)
self._client: httpx.AsyncClient | None = None
@@ -22,7 +23,7 @@ class OllamaClient:
async def generate_embedding(self, text: str) -> list[float]:
client = await self._get_client()
response = await client.post(
f"{self.base_url}/api/embeddings",
f"{self.embed_url}/api/embeddings",
json={"model": settings.OLLAMA_EMBED_MODEL, "prompt": text},
)
response.raise_for_status()
@@ -43,10 +44,8 @@ class OllamaClient:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
client = await self._get_client()
# 조립컴 Ollama 메인, MLX fallback
try:
response = await client.post(
f"{self.base_url}/api/chat",
f"{self.text_url}/api/chat",
json={
"model": settings.OLLAMA_TEXT_MODEL,
"messages": messages,
@@ -57,35 +56,26 @@ class OllamaClient:
)
response.raise_for_status()
return response.json()["message"]["content"]
except Exception:
response = await client.post(
f"{settings.MLX_BASE_URL}/chat/completions",
json={
"model": settings.MLX_TEXT_MODEL,
"messages": messages,
"max_tokens": 2048,
"temperature": 0.3,
},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
async def check_health(self) -> dict:
result = {}
short_timeout = httpx.Timeout(5.0, connect=3.0)
# GPU서버 Ollama (텍스트 생성)
try:
async with httpx.AsyncClient(timeout=short_timeout) as c:
response = await c.get(f"{self.base_url}/api/tags")
response = await c.get(f"{self.text_url}/api/tags")
models = response.json().get("models", [])
result["ollama"] = {"status": "connected", "models": [m["name"] for m in models]}
result["ollama_text"] = {"status": "connected", "url": self.text_url, "models": [m["name"] for m in models]}
except Exception:
result["ollama"] = {"status": "disconnected"}
result["ollama_text"] = {"status": "disconnected", "url": self.text_url}
# 맥미니 Ollama (임베딩)
try:
async with httpx.AsyncClient(timeout=short_timeout) as c:
response = await c.get(f"{settings.MLX_BASE_URL}/health")
result["mlx"] = {"status": "connected", "model": settings.MLX_TEXT_MODEL}
response = await c.get(f"{self.embed_url}/api/tags")
models = response.json().get("models", [])
result["ollama_embed"] = {"status": "connected", "url": self.embed_url, "models": [m["name"] for m in models]}
except Exception:
result["mlx"] = {"status": "disconnected"}
result["ollama_embed"] = {"status": "disconnected", "url": self.embed_url}
return result

View File

@@ -1,8 +1,54 @@
import logging
import time
from services.ollama_client import ollama_client
from services.embedding_service import search_similar_by_text, build_document_text
from services.db_client import get_issue_by_id
from services.db_client import get_issue_by_id, get_category_stats, get_department_stats
from services.utils import load_prompt
logger = logging.getLogger(__name__)
_stats_cache = {"data": "", "expires": 0}
STATS_CACHE_TTL = 300 # 5분
STATS_KEYWORDS = {"많이", "빈도", "추이", "비율", "통계", "몇 건", "자주", "빈번", "유형별", "부서별"}
def _needs_stats(question: str) -> bool:
"""키워드 매칭으로 통계성 질문인지 판별"""
return any(kw in question for kw in STATS_KEYWORDS)
def _build_stats_summary() -> str:
"""DB 집계 통계 요약 (5분 TTL 캐싱, 실패 시 빈 문자열)"""
now = time.time()
if _stats_cache["data"] and now < _stats_cache["expires"]:
return _stats_cache["data"]
try:
lines = ["[전체 통계 요약]"]
cats = get_category_stats()
if cats:
total = sum(c["count"] for c in cats)
lines.append(f"총 부적합 건수: {total}")
lines.append("카테고리별:")
for c in cats[:10]:
pct = round(c["count"] / total * 100, 1)
lines.append(f" - {c['category']}: {c['count']}건 ({pct}%)")
depts = get_department_stats()
if depts:
lines.append("부서별:")
for d in depts[:10]:
lines.append(f" - {d['department']}: {d['count']}")
if len(lines) <= 1:
return "" # 데이터 없으면 빈 문자열
result = "\n".join(lines)
_stats_cache["data"] = result
_stats_cache["expires"] = now + STATS_CACHE_TTL
return result
except Exception as e:
logger.warning(f"Stats summary failed: {e}")
return ""
def _format_retrieved_issues(results: list[dict]) -> str:
if not results:
@@ -81,11 +127,16 @@ async def rag_ask(question: str, project_id: int = None) -> dict:
results = await search_similar_by_text(
question, n_results=7, filters=None
)
logger.info(f"RAG ask: question='{question[:50]}', results={len(results)}")
context = _format_retrieved_issues(results)
# 통계성 질문일 때만 DB 집계 포함 (토큰 절약)
stats = _build_stats_summary() if _needs_stats(question) else ""
template = load_prompt("prompts/rag_qa.txt")
prompt = template.format(
question=question,
stats_summary=stats,
retrieved_cases=context,
)

View File

@@ -19,7 +19,7 @@ services:
- mariadb_data:/var/lib/mysql
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
ports:
- "30306:3306"
- "127.0.0.1:30306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
timeout: 20s
@@ -66,6 +66,12 @@ services:
- SSO_JWT_REFRESH_EXPIRES_IN=${SSO_JWT_REFRESH_EXPIRES_IN:-30d}
- REDIS_HOST=redis
- REDIS_PORT=6379
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
mariadb:
condition: service_healthy
@@ -80,8 +86,8 @@ services:
system1-api:
build:
context: ./system1-factory/api
dockerfile: Dockerfile
context: .
dockerfile: system1-factory/api/Dockerfile
container_name: tk-system1-api
restart: unless-stopped
ports:
@@ -103,6 +109,13 @@ services:
- REDIS_PORT=6379
- WEATHER_API_URL=${WEATHER_API_URL:-}
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- system1_uploads:/usr/src/app/uploads
- system1_logs:/usr/src/app/logs
@@ -123,9 +136,12 @@ services:
ports:
- "30080:80"
volumes:
- ./system1-factory/web:/usr/share/nginx/html:ro
- ./system1-factory/web/public:/usr/share/nginx/html:ro
depends_on:
- system1-api
system1-api:
condition: service_healthy
sso-auth:
condition: service_healthy
networks:
- tk-network
@@ -139,8 +155,15 @@ services:
- "30008:8000"
environment:
- API_BASE_URL=http://system1-api:3005
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
- system1-api
system1-api:
condition: service_healthy
networks:
- tk-network
@@ -150,8 +173,8 @@ services:
system2-api:
build:
context: ./system2-report/api
dockerfile: Dockerfile
context: .
dockerfile: system2-report/api/Dockerfile
container_name: tk-system2-api
restart: unless-stopped
ports:
@@ -172,6 +195,13 @@ services:
- M_PROJECT_USERNAME=${M_PROJECT_USERNAME:-api_service}
- M_PROJECT_PASSWORD=${M_PROJECT_PASSWORD:-}
- M_PROJECT_DEFAULT_PROJECT_ID=${M_PROJECT_DEFAULT_PROJECT_ID:-1}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- system2_uploads:/usr/src/app/uploads
- system2_logs:/usr/src/app/logs
@@ -192,7 +222,8 @@ services:
ports:
- "30180:80"
depends_on:
- system2-api
system2-api:
condition: service_healthy
networks:
- tk-network
@@ -220,6 +251,12 @@ services:
- ADMIN_USERNAME=${SYSTEM3_ADMIN_USERNAME:-hyungi}
- TZ=Asia/Seoul
- TKUSER_API_URL=http://tkuser-api:3000
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- system3_uploads:/app/uploads
depends_on:
@@ -239,7 +276,8 @@ services:
volumes:
- system3_uploads:/usr/share/nginx/html/uploads
depends_on:
- system3-api
system3-api:
condition: service_healthy
networks:
- tk-network
@@ -249,8 +287,8 @@ services:
tkuser-api:
build:
context: ./user-management/api
dockerfile: Dockerfile
context: .
dockerfile: user-management/api/Dockerfile
container_name: tk-tkuser-api
restart: unless-stopped
ports:
@@ -264,6 +302,21 @@ services:
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
- 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}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- system1_uploads:/usr/src/app/uploads
depends_on:
@@ -281,48 +334,254 @@ services:
ports:
- "30380:80"
depends_on:
- tkuser-api
tkuser-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# AI Service
# Purchase Management (tkpurchase)
# =================================================================
ai-service:
tkpurchase-api:
build:
context: ./ai-service
dockerfile: Dockerfile
container_name: tk-ai-service
context: .
dockerfile: tkpurchase/api/Dockerfile
container_name: tk-tkpurchase-api
restart: unless-stopped
ports:
- "30400:8000"
- "30400:3000"
environment:
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-https://gpu.hyungi.net}
- OLLAMA_TEXT_MODEL=${OLLAMA_TEXT_MODEL:-qwen3.5:9b-q8_0}
- OLLAMA_EMBED_MODEL=${OLLAMA_EMBED_MODEL:-bge-m3}
- OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-120}
- MLX_BASE_URL=${MLX_BASE_URL:-https://llm.hyungi.net}
- MLX_TEXT_MODEL=${MLX_TEXT_MODEL:-/Users/hyungi/mlx-models/Qwen3.5-27B-4bit}
- NODE_ENV=production
- PORT=3000
- DB_HOST=mariadb
- DB_PORT=3306
- DB_USER=${MYSQL_USER:-hyungi_user}
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SECRET_KEY=${SSO_JWT_SECRET}
- SYSTEM1_API_URL=http://system1-api:3005
- CHROMA_PERSIST_DIR=/app/data/chroma
- TZ=Asia/Seoul
volumes:
- ai_data:/app/data
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
mariadb:
condition: service_healthy
networks:
- tk-network
tkpurchase-web:
build:
context: ./tkpurchase/web
dockerfile: Dockerfile
container_name: tk-tkpurchase-web
restart: unless-stopped
ports:
- "30480:80"
depends_on:
tkpurchase-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# Gateway
# Safety Management (tksafety)
# =================================================================
tksafety-api:
build:
context: .
dockerfile: tksafety/api/Dockerfile
container_name: tk-tksafety-api
restart: unless-stopped
ports:
- "30500:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DB_HOST=mariadb
- DB_PORT=3306
- DB_USER=${MYSQL_USER:-hyungi_user}
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- tksafety_uploads:/usr/src/app/uploads
depends_on:
mariadb:
condition: service_healthy
networks:
- tk-network
tksafety-web:
build:
context: ./tksafety/web
dockerfile: Dockerfile
container_name: tk-tksafety-web
restart: unless-stopped
ports:
- "30580:80"
volumes:
- tksafety_uploads:/usr/share/nginx/html/uploads
depends_on:
tksafety-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# Support (tksupport) - 전사 행정지원
# =================================================================
tksupport-api:
build:
context: .
dockerfile: tksupport/api/Dockerfile
container_name: tk-tksupport-api
restart: unless-stopped
ports:
- "30600:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DB_HOST=mariadb
- DB_PORT=3306
- DB_USER=${MYSQL_USER:-hyungi_user}
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
mariadb:
condition: service_healthy
networks:
- tk-network
tksupport-web:
build:
context: ./tksupport/web
dockerfile: Dockerfile
container_name: tk-tksupport-web
restart: unless-stopped
ports:
- "30680:80"
depends_on:
tksupport-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# TK-EG - BOM 자재관리 (tkeg)
# =================================================================
tkeg-postgres:
image: postgres:15-alpine
container_name: tk-tkeg-postgres
restart: unless-stopped
environment:
POSTGRES_DB: tk_bom
POSTGRES_USER: tkbom_user
POSTGRES_PASSWORD: ${TKEG_POSTGRES_PASSWORD}
TZ: Asia/Seoul
volumes:
- tkeg_postgres_data:/var/lib/postgresql/data
- ./tkeg/api/database/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tkbom_user -d tk_bom"]
interval: 30s
timeout: 10s
retries: 3
networks:
- tk-network
tkeg-api:
build:
context: ./tkeg/api
dockerfile: Dockerfile
container_name: tk-tkeg-api
restart: unless-stopped
ports:
- "30700:8000"
environment:
- DATABASE_URL=postgresql://tkbom_user:${TKEG_POSTGRES_PASSWORD}@tkeg-postgres:5432/tk_bom
- REDIS_URL=redis://redis:6379
- SECRET_KEY=${SSO_JWT_SECRET}
- TKUSER_API_URL=http://tkuser-api:3000
- ENVIRONMENT=production
- TZ=Asia/Seoul
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
volumes:
- tkeg_uploads:/app/uploads
depends_on:
tkeg-postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- tk-network
tkeg-web:
build:
context: ./tkeg/web
dockerfile: Dockerfile
args:
- VITE_API_URL=/api
container_name: tk-tkeg-web
restart: unless-stopped
ports:
- "30780:80"
depends_on:
tkeg-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# ntfy — 푸시 알림 서버
# =================================================================
ntfy:
image: binwiederhier/ntfy
container_name: tk-ntfy
restart: unless-stopped
command: serve
ports:
- "30750:80"
environment:
- TZ=Asia/Seoul
volumes:
- ./ntfy/etc:/etc/ntfy
- ntfy_cache:/var/cache/ntfy
networks:
- tk-network
# =================================================================
# AI Service — 맥미니로 이전됨 (~/docker/tk-ai-service/)
# =================================================================
# =================================================================
# Gateway (로그인 + 대시보드 + 공유JS)
# =================================================================
gateway:
@@ -334,10 +593,10 @@ services:
ports:
- "30000:80"
depends_on:
- sso-auth
- system1-web
- system2-web
- system3-web
sso-auth:
condition: service_healthy
system1-api:
condition: service_healthy
networks:
- tk-network
@@ -350,7 +609,7 @@ services:
container_name: tk-phpmyadmin
restart: unless-stopped
ports:
- "30880:80"
- "127.0.0.1:30880:80"
environment:
- PMA_HOST=mariadb
- PMA_USER=${PMA_USER:-root}
@@ -375,8 +634,14 @@ services:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- gateway
- system1-web
- system2-web
- system3-web
- tkpurchase-web
- tksafety-web
- tksupport-web
- tkeg-web
- ntfy
networks:
- tk-network
@@ -390,10 +655,13 @@ volumes:
system1_logs:
system2_uploads:
system2_logs:
tksafety_uploads:
system3_uploads:
external: true
name: tkqc-package_uploads
ai_data:
tkeg_postgres_data:
tkeg_uploads:
ntfy_cache:
networks:
tk-network:
driver: bridge

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`에 제외할 파일이 있으면 등록.

870
gateway/html/dashboard.html Normal file
View File

@@ -0,0 +1,870 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TK 대시보드</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
min-height: 100vh;
}
/* ===== Login Form ===== */
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: white;
border-radius: 12px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
.login-box h1 {
text-align: center;
color: #1a56db;
font-size: 22px;
margin-bottom: 6px;
}
.login-box .sub {
text-align: center;
color: #6b7280;
font-size: 13px;
margin-bottom: 28px;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #1a56db;
box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
}
.btn-submit {
width: 100%;
padding: 12px;
background: #1a56db;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
margin-top: 8px;
}
.btn-submit:hover { background: #1e40af; }
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
.error-msg {
color: #dc2626;
font-size: 13px;
text-align: center;
margin-top: 12px;
display: none;
}
/* ===== Dashboard ===== */
.header {
background: #1a56db;
color: white;
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 { font-size: 18px; font-weight: 600; }
.user-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.user-info span { opacity: 0.9; }
.btn-logout {
background: rgba(255,255,255,0.2);
color: white;
border: none;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.btn-logout:hover { background: rgba(255,255,255,0.3); }
.container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px 40px;
}
.section { margin-bottom: 28px; }
.section-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
@media (min-width: 640px) {
.card-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
.card-grid { grid-template-columns: repeat(4, 1fr); }
}
.card {
background: white;
border-radius: 10px;
padding: 14px;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
border: 1px solid #e5e7eb;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.card:active { transform: translateY(0); }
.card-icon { font-size: 22px; flex-shrink: 0; }
.card-name { font-size: 13px; font-weight: 500; color: #1f2937; line-height: 1.3; }
/* System cards */
.system-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 640px) {
.system-grid { grid-template-columns: repeat(3, 1fr); }
}
.system-card {
background: white;
border-radius: 10px;
padding: 18px 16px;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
border-left: 4px solid;
}
.system-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.system-card .card-icon { font-size: 26px; }
.system-card .card-name { font-size: 14px; font-weight: 600; }
/* Banner */
.banner-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.banner-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: white;
border-radius: 10px;
border-left: 4px solid;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
transition: transform 0.15s, box-shadow 0.15s;
}
.banner-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.banner-icon { font-size: 20px; flex-shrink: 0; }
.banner-text { font-size: 14px; font-weight: 500; color: #1f2937; flex: 1; }
.banner-arrow { font-size: 18px; color: #9ca3af; }
/* Coming soon */
.badge-soon {
display: inline-block;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
margin-left: 4px;
vertical-align: middle;
}
.card.coming-soon {
opacity: 0.55;
cursor: default;
}
.card.coming-soon:hover {
transform: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
/* ===== Welcome Section ===== */
.welcome-section {
padding: 24px;
margin-bottom: 24px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-radius: 12px;
}
.welcome-greeting {
font-size: 20px;
font-weight: 600;
color: #1e3a5f;
}
.welcome-meta {
font-size: 14px;
color: #6b7280;
margin-top: 6px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* ===== Stats Cards ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
@media (min-width: 640px) {
.stats-grid { grid-template-columns: repeat(3, 1fr); }
}
.stat-card {
background: white;
border-radius: 10px;
padding: 18px 14px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border: 1px solid #e5e7eb;
}
.stat-icon { font-size: 22px; margin-bottom: 4px; }
.stat-label { font-size: 12px; color: #6b7280; font-weight: 500; }
.stat-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
/* ===== Card desc ===== */
.card-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.card-desc { font-size: 11px; color: #9ca3af; font-weight: 400; line-height: 1.3; }
.footer {
text-align: center;
padding: 16px;
color: #9ca3af;
font-size: 11px;
}
</style>
</head>
<body>
<!-- Login Form -->
<div id="loginView" class="login-wrapper" style="display:none">
<div class="login-box">
<h1>TK 공장관리 시스템</h1>
<p class="sub">통합 로그인</p>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username">사용자명</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-submit" id="submitBtn">로그인</button>
<p class="error-msg" id="errorMsg"></p>
</form>
</div>
</div>
<!-- Dashboard -->
<div id="dashboardView" style="display:none">
<div class="header">
<h1>TK 대시보드</h1>
<div class="user-info">
<span id="userName"></span>
<button class="btn-logout" onclick="logout()">로그아웃</button>
</div>
</div>
<div class="container">
<div class="welcome-section" id="welcomeSection" style="display:none">
<div class="welcome-greeting" id="welcomeGreeting"></div>
<div class="welcome-meta">
<span id="welcomeDate"></span>
<span id="welcomeWeather"></span>
</div>
</div>
<div class="section" id="statsSection" style="display:none">
<div class="stats-grid" id="statsGrid"></div>
</div>
<div class="section" id="bannerSection" style="display:none">
<div class="banner-list" id="bannerList"></div>
</div>
<div class="section" id="systemSection" style="display:none">
<div class="section-title"><span>&#127970;</span> 시스템</div>
<div class="system-grid" id="systemGrid"></div>
</div>
</div>
<div class="footer">TK Factory Services v1.0</div>
</div>
<script>
// ===== SSO Cookie Utility =====
var ssoCookie = {
set: function(name, value, days) {
var cookie = name + '=' + encodeURIComponent(value) + '; path=/';
if (days) cookie += '; max-age=' + (days * 86400);
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
},
get: function(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
},
remove: function(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
}
};
function getToken() {
return ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
}
function getUser() {
var raw = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
try { return JSON.parse(raw); } catch(e) { return null; }
}
function isTokenValid(token) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp > Math.floor(Date.now() / 1000);
} catch (e) { return false; }
}
function isSafeRedirect(url) {
if (!url) return false;
if (/^\/[a-zA-Z0-9]/.test(url) && !url.includes('://') && !url.includes('//')) return true;
try {
var parsed = new URL(url);
return parsed.hostname.endsWith('.technicalkorea.net') || parsed.hostname === 'technicalkorea.net';
} catch (e) { return false; }
}
// ===== Subdomain URLs =====
function getSubdomainUrl(name) {
var hostname = window.location.hostname;
var protocol = window.location.protocol;
if (hostname.includes('technicalkorea.net')) {
return protocol + '//' + name + '.technicalkorea.net';
}
var ports = { tkfb: 30080, tkreport: 30180, tkqc: 30280, tkuser: 30380, tkpurchase: 30480, tksafety: 30580, tksupport: 30680 };
return protocol + '//' + hostname + ':' + (ports[name] || 30000);
}
// ===== Banner Definitions =====
var BANNERS = [
{
id: 'notifications',
icon: '\uD83D\uDD14',
api: '/api/notifications/unread/count',
parse: function(data) {
var count = data.data && data.data.count;
return count > 0 ? { text: '\uBBF8\uD655\uC778 \uC54C\uB9BC ' + count + '\uAC74' } : null;
},
subdomain: 'tkfb',
path: '/pages/profile/notifications.html',
color: '#1a56db'
},
{
id: 'tbm',
icon: '\uD83D\uDCCB',
api: '/api/tbm/sessions/incomplete-reports',
parse: function(data) {
var items = data.data || data;
var count = Array.isArray(items) ? items.length : 0;
return count > 0 ? { text: '\uBBF8\uC81C\uCD9C TBM \uBCF4\uACE0 ' + count + '\uAC74' } : null;
},
subdomain: 'tkfb',
path: '/pages/work/tbm.html',
color: '#d97706',
requirePageKey: 'work.tbm'
},
{
id: 'vacation',
icon: '\uD83D\uDCC5',
api: '/api/vacation-requests/pending',
parse: function(data) {
var items = data.data || data;
var count = Array.isArray(items) ? items.length : 0;
return count > 0 ? { text: '\uD734\uAC00 \uC2B9\uC778 \uB300\uAE30 ' + count + '\uAC74' } : null;
},
subdomain: 'tkfb',
path: '/pages/attendance/vacation-management.html',
color: '#7c3aed',
requirePageKey: 'attendance.vacation_management'
}
];
// ===== Banner Loading =====
async function loadBanners(token, allowed) {
var container = document.getElementById('bannerList');
container.innerHTML = '';
var visible = BANNERS.filter(function(b) {
return !b.requirePageKey || allowed.has(b.requirePageKey);
});
var results = await Promise.allSettled(
visible.map(function(b) {
return fetch(b.api, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) { return data ? { banner: b, result: b.parse(data) } : null; });
})
);
var anyVisible = false;
results.forEach(function(r) {
if (r.status !== 'fulfilled' || !r.value || !r.value.result) return;
var b = r.value.banner;
var result = r.value.result;
var a = document.createElement('a');
a.className = 'banner-item';
a.style.borderLeftColor = b.color;
a.href = getSubdomainUrl(b.subdomain) + (b.path || '');
a.innerHTML = '<span class="banner-icon">' + b.icon + '</span>'
+ '<span class="banner-text">' + result.text + '</span>'
+ '<span class="banner-arrow">\u203A</span>';
container.appendChild(a);
anyVisible = true;
});
if (anyVisible) {
document.getElementById('bannerSection').style.display = '';
}
}
// ===== Card Definitions =====
var SYSTEM_CARDS = [
{ 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: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
{ id: 'safety', name: '안전관리', desc: '안전 점검, 방문 관리', icon: '\uD83E\uDDBA', subdomain: 'tksafety', color: '#7c3aed' },
{ id: 'support', name: '행정지원', desc: '전사 행정 업무 지원', icon: '\uD83C\uDFE2', subdomain: 'tksupport', color: '#0284c7' },
{ id: 'admin', name: '통합관리', desc: '사용자·권한 관리', icon: '\u2699\uFE0F', subdomain: 'tkuser', minRole: 'admin', color: '#0891b2' }
];
// ===== Rendering =====
function resolveHref(card) {
if (card.href) return card.href;
if (card.subdomain) {
var base = getSubdomainUrl(card.subdomain);
return card.path ? base + card.path : base;
}
return '#';
}
function createCardElement(card, isSystem) {
var a = document.createElement('a');
a.className = isSystem ? 'system-card' : 'card';
if (isSystem && card.color) {
a.style.borderLeftColor = card.color;
}
if (card.comingSoon) {
a.className += ' coming-soon';
a.href = 'javascript:void(0)';
a.onclick = function(e) { e.preventDefault(); };
} else {
a.href = resolveHref(card);
}
var iconSpan = document.createElement('span');
iconSpan.className = 'card-icon';
iconSpan.textContent = card.icon;
a.appendChild(iconSpan);
var nameSpan = document.createElement('span');
nameSpan.className = 'card-name';
nameSpan.textContent = card.name;
if (card.comingSoon) {
var badge = document.createElement('span');
badge.className = 'badge-soon';
badge.textContent = '\uC900\uBE44\uC911';
nameSpan.appendChild(document.createTextNode(' '));
nameSpan.appendChild(badge);
}
if (card.desc && isSystem) {
var textDiv = document.createElement('div');
textDiv.className = 'card-text';
textDiv.appendChild(nameSpan);
var descSpan = document.createElement('span');
descSpan.className = 'card-desc';
descSpan.textContent = card.desc;
textDiv.appendChild(descSpan);
a.appendChild(textDiv);
} else {
a.appendChild(nameSpan);
}
return a;
}
function isCardVisible(card, allowed, systemAccess, userRole) {
if (card.comingSoon) return true;
if (card.minRole) {
var roleOrder = ['user','leader','support_team','admin','system'];
var userIdx = roleOrder.indexOf(userRole);
var minIdx = roleOrder.indexOf(card.minRole);
if (userIdx < minIdx) return false;
}
if (card.pageKey && !allowed.has(card.pageKey)) return false;
if (card.accessKey && systemAccess[card.accessKey] === false) return false;
return true;
}
function renderSection(sectionId, gridId, cards, allowed, systemAccess, isSystem, userRole) {
var visible = cards.filter(function(c) { return isCardVisible(c, allowed, systemAccess, userRole); });
if (visible.length === 0) return;
var grid = document.getElementById(gridId);
grid.innerHTML = '';
visible.forEach(function(card) {
grid.appendChild(createCardElement(card, isSystem));
});
document.getElementById(sectionId).style.display = '';
}
// ===== Dashboard =====
async function showDashboard(user, token) {
document.getElementById('loginView').style.display = 'none';
document.getElementById('dashboardView').style.display = '';
document.getElementById('userName').textContent = (user.name || user.username);
var systemAccess = user.system_access || {};
var allowed = new Set();
// Fetch page access
try {
var userId = user.user_id || user.id;
var res = await fetch('/api/users/' + userId + '/page-access', {
headers: { 'Authorization': 'Bearer ' + token }
});
var data = await res.json();
if (data.success && data.data && data.data.pageAccess) {
data.data.pageAccess.forEach(function(p) {
if (p.can_access) allowed.add(p.page_key);
});
}
} catch (e) {
// Fallback: show cards based on system_access
console.warn('page-access API error:', e);
if (systemAccess.system1 !== false) {
['work.tbm', 'work.report_create', 'inspection.checkin',
'attendance.my_vacation_info', 'attendance.vacation_request',
'dashboard'].forEach(function(k) { allowed.add(k); });
}
}
// A: Welcome section
showWelcome(user, token);
// B: Today stats
loadTodayStats(token, allowed);
// Render banners + system cards
var userRole = user.role || 'user';
loadBanners(token, allowed);
renderSection('systemSection', 'systemGrid', SYSTEM_CARDS, allowed, systemAccess, true, userRole);
}
// ===== A: Welcome =====
function getGreeting() {
var h = new Date().getHours();
if (h >= 6 && h < 12) return '좋은 아침입니다';
if (h >= 12 && h < 18) return '좋은 오후입니다';
return '좋은 저녁입니다';
}
function getWeatherIcon(sky, precip) {
var p = Number(precip);
if (p === 1 || p === 2 || p === 4 || p === 5 || p === 6) return '\uD83C\uDF27\uFE0F';
if (p === 3 || p === 7) return '\u2744\uFE0F';
if (sky === 'overcast') return '\u2601\uFE0F';
if (sky === 'cloudy') return '\u26C5';
return '\u2600\uFE0F';
}
function getSkyLabel(sky) {
if (sky === 'clear') return '맑음';
if (sky === 'cloudy') return '구름많음';
if (sky === 'overcast') return '흐림';
return '';
}
function showWelcome(user, token) {
var section = document.getElementById('welcomeSection');
var name = user.name || user.username;
document.getElementById('welcomeGreeting').textContent = getGreeting() + ', ' + name + '님';
var now = new Date();
var days = ['일','월','화','수','목','금','토'];
var dateStr = now.getFullYear() + '년 ' + (now.getMonth()+1) + '월 ' + now.getDate() + '일 (' + days[now.getDay()] + ')';
document.getElementById('welcomeDate').textContent = dateStr;
section.style.display = '';
// Weather (async, hide on failure)
var weatherEl = document.getElementById('welcomeWeather');
weatherEl.textContent = '';
fetch('/api/tbm/weather/current', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
.then(function(json) {
var d = json.data;
if (!d) return;
var icon = getWeatherIcon(d.skyCondition, d.precipitationType);
var label = getSkyLabel(d.skyCondition);
var temp = d.temperature != null ? Math.round(d.temperature) + '\u00B0C' : '';
weatherEl.textContent = '| ' + icon + ' ' + label + ' ' + temp;
})
.catch(function() { weatherEl.textContent = ''; });
}
// ===== B: Today Stats =====
function loadTodayStats(token, allowed) {
var today = new Date().toISOString().slice(0, 10);
var cards = [];
var fetches = [];
// Attendance
if (allowed.has('attendance.daily') || allowed.has('attendance.vacation_management') || allowed.has('dashboard')) {
var idx = cards.length;
cards.push({ icon: '\uD83D\uDC77', label: '출근', value: '\u2013', color: '#1a56db', visible: true });
fetches.push(
fetch('/api/attendance/daily-status?date=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
.then(function(json) {
var data = json.data;
if (Array.isArray(data)) {
cards[idx].value = data.filter(function(d) { return d.status !== 'vacation'; }).length + '명';
}
})
.catch(function() { cards[idx].visible = false; })
);
}
// Work reports
if (allowed.has('dashboard')) {
var idx2 = cards.length;
cards.push({ icon: '\uD83D\uDD27', label: '작업', value: '\u2013', color: '#059669', visible: true });
fetches.push(
fetch('/api/work-analysis/dashboard?start=' + today + '&end=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
.then(function(json) {
var stats = json.data && json.data.stats;
if (stats) {
cards[idx2].value = (stats.totalReports || 0) + '건';
}
})
.catch(function() { cards[idx2].visible = false; })
);
}
// Issues
var idx3 = cards.length;
cards.push({ icon: '\u26A0\uFE0F', label: '이슈', value: '\u2013', color: '#dc2626', visible: true });
fetches.push(
fetch('/api/work-issues/stats/summary', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
.then(function(json) {
var data = json.data;
if (data && data.openCount != null) {
cards[idx3].value = data.openCount + '건';
} else if (data && data.total != null) {
cards[idx3].value = data.total + '건';
}
})
.catch(function() { cards[idx3].visible = false; })
);
if (fetches.length === 0) return;
Promise.allSettled(fetches).then(function() {
var visible = cards.filter(function(c) { return c.visible; });
if (visible.length === 0) return;
var grid = document.getElementById('statsGrid');
grid.innerHTML = '';
visible.forEach(function(c) {
var div = document.createElement('div');
div.className = 'stat-card';
div.innerHTML = '<div class="stat-icon">' + c.icon + '</div>'
+ '<div class="stat-label">' + c.label + '</div>'
+ '<div class="stat-value" style="color:' + c.color + '">' + c.value + '</div>';
grid.appendChild(div);
});
document.getElementById('statsSection').style.display = '';
});
}
// ===== Login =====
async function handleLogin(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var errEl = document.getElementById('errorMsg');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = '\uB85C\uADF8\uC778 \uC911...';
try {
var res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
var data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '\uB85C\uADF8\uC778\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4');
ssoCookie.set('sso_token', data.access_token, 7);
ssoCookie.set('sso_user', JSON.stringify(data.user), 7);
if (data.refresh_token) ssoCookie.set('sso_refresh_token', data.refresh_token, 30);
var redirect = new URLSearchParams(location.search).get('redirect');
if (redirect && isSafeRedirect(redirect)) {
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(data.access_token);
} else {
window.location.href = '/dashboard';
}
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = '\uB85C\uADF8\uC778';
}
}
// ===== Logout =====
function logout() {
ssoCookie.remove('sso_token');
ssoCookie.remove('sso_user');
ssoCookie.remove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
fetch('/auth/logout', { method: 'POST' }).catch(function(){});
window.location.href = '/dashboard?logout=1';
}
// ===== Auth cleanup helpers =====
function clearAllAuth() {
ssoCookie.remove('sso_token');
ssoCookie.remove('sso_user');
ssoCookie.remove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
}
// ===== Entry Point =====
(function init() {
var params = new URLSearchParams(location.search);
var isLogout = params.get('logout') === '1';
if (isLogout) clearAllAuth();
var token = isLogout ? null : getToken();
if (token && token !== 'undefined' && token !== 'null') {
if (isTokenValid(token)) {
var user = getUser();
if (user) {
// Partner redirect
if (user.partner_company_id) {
window.location.href = getSubdomainUrl('tkfb') + '/pages/partner/partner-portal.html';
return;
}
// Already logged in + redirect param
var redirect = params.get('redirect');
if (redirect && isSafeRedirect(redirect)) {
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(token);
return;
}
// Sync cookies
var existingUser = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
var existingRefresh = ssoCookie.get('sso_refresh_token') || localStorage.getItem('sso_refresh_token');
ssoCookie.set('sso_token', token, 7);
if (existingUser) ssoCookie.set('sso_user', existingUser, 7);
if (existingRefresh) ssoCookie.set('sso_refresh_token', existingRefresh, 30);
showDashboard(user, token);
return;
}
}
// Token invalid or no user data
clearAllAuth();
}
// Show login
document.getElementById('loginView').style.display = 'flex';
document.getElementById('dashboardView').style.display = 'none';
})();
</script>
</body>
</html>

View File

@@ -1,206 +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>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: white;
border-radius: 12px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
.login-box h1 {
text-align: center;
color: #1a56db;
font-size: 22px;
margin-bottom: 6px;
}
.login-box .sub {
text-align: center;
color: #6b7280;
font-size: 13px;
margin-bottom: 28px;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #1a56db;
box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
}
.btn-submit {
width: 100%;
padding: 12px;
background: #1a56db;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
margin-top: 8px;
}
.btn-submit:hover { background: #1e40af; }
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
.error-msg {
color: #dc2626;
font-size: 13px;
text-align: center;
margin-top: 12px;
display: none;
}
</style>
</head>
<body>
<div class="login-box">
<h1>TK 공장관리 시스템</h1>
<p class="sub">통합 로그인</p>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username">사용자명</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-submit" id="submitBtn">로그인</button>
<p class="error-msg" id="errorMsg"></p>
</form>
</div>
<script>
// SSO 쿠키 유틸리티
var ssoCookie = {
set: function(name, value, days) {
var cookie = name + '=' + encodeURIComponent(value) + '; path=/';
if (days) cookie += '; max-age=' + (days * 86400);
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
},
get: function(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
},
remove: function(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net';
}
document.cookie = cookie;
}
};
async function handleLogin(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var errEl = document.getElementById('errorMsg');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = '로그인 중...';
try {
var res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
var data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || '로그인에 실패했습니다');
}
// 쿠키에 토큰 저장 (서브도메인 공유)
ssoCookie.set('sso_token', data.access_token, 7);
ssoCookie.set('sso_user', JSON.stringify(data.user), 7);
if (data.refresh_token) {
ssoCookie.set('sso_refresh_token', data.refresh_token, 30);
}
// localStorage에도 저장 (같은 도메인 내 호환성)
localStorage.setItem('sso_token', data.access_token);
localStorage.setItem('sso_user', JSON.stringify(data.user));
if (data.refresh_token) {
localStorage.setItem('sso_refresh_token', data.refresh_token);
}
// redirect 파라미터가 있으면 해당 URL로, 없으면 포털로
var redirect = new URLSearchParams(location.search).get('redirect');
// Open redirect 방지: 같은 origin의 상대 경로만 허용
if (redirect && /^\/[a-zA-Z0-9]/.test(redirect) && !redirect.includes('://') && !redirect.includes('//')) {
window.location.href = redirect;
} else {
window.location.href = '/';
}
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = '';
} finally {
btn.disabled = false;
btn.textContent = '로그인';
}
}
// 토큰 만료 확인
function isTokenValid(token) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp > Math.floor(Date.now() / 1000);
} catch (e) {
return false;
}
}
// 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크 + 만료 확인)
var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
if (isTokenValid(existingToken)) {
var redirect = new URLSearchParams(location.search).get('redirect');
window.location.href = redirect || '/';
} else {
// 만료된 토큰 정리
ssoCookie.remove('sso_token');
ssoCookie.remove('sso_user');
ssoCookie.remove('sso_refresh_token');
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
}
}
</script>
</body>
</html>

View File

@@ -1,254 +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>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #1a56db;
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 { font-size: 20px; font-weight: 600; }
.user-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
}
.user-info span { opacity: 0.9; }
.btn-logout {
background: rgba(255,255,255,0.2);
color: white;
border: none;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.btn-logout:hover { background: rgba(255,255,255,0.3); }
.container {
max-width: 900px;
margin: 40px auto;
padding: 0 20px;
flex: 1;
}
.welcome {
text-align: center;
margin-bottom: 36px;
}
.welcome h2 { font-size: 26px; color: #1f2937; margin-bottom: 8px; }
.welcome p { color: #6b7280; font-size: 15px; }
.systems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.system-card {
background: white;
border-radius: 12px;
padding: 28px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
text-decoration: none;
color: inherit;
border: 2px solid transparent;
}
.system-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.12);
}
.system-card.s1 { border-top: 4px solid #1a56db; }
.system-card.s2 { border-top: 4px solid #dc2626; }
.system-card.s3 { border-top: 4px solid #059669; }
.system-icon { font-size: 36px; margin-bottom: 14px; }
.system-card h3 { font-size: 18px; color: #1f2937; margin-bottom: 6px; }
.system-card p { color: #6b7280; font-size: 13px; line-height: 1.5; }
.system-card .badge {
display: inline-block;
margin-top: 12px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.s1 .badge { background: #dbeafe; color: #1d4ed8; }
.s2 .badge { background: #fee2e2; color: #dc2626; }
.s3 .badge { background: #d1fae5; color: #059669; }
.footer {
text-align: center;
padding: 20px;
color: #9ca3af;
font-size: 12px;
}
.login-prompt {
text-align: center;
padding: 60px 20px;
}
.login-prompt h2 { margin-bottom: 16px; color: #1f2937; }
.btn-login {
display: inline-block;
background: #1a56db;
color: white;
padding: 12px 32px;
border-radius: 8px;
text-decoration: none;
font-size: 15px;
font-weight: 500;
}
.btn-login:hover { background: #1e40af; }
.no-access {
opacity: 0.5;
pointer-events: none;
}
</style>
</head>
<body>
<div class="header">
<h1>TK 공장관리 시스템</h1>
<div class="user-info" id="userInfo" style="display:none">
<span id="userName"></span>
<span id="userRole"></span>
<button class="btn-logout" onclick="logout()">로그아웃</button>
</div>
</div>
<div class="container">
<!-- 로그인 전 -->
<div class="login-prompt" id="loginPrompt">
<h2>시스템에 접속하려면 로그인하세요</h2>
<a href="/login" class="btn-login">로그인</a>
</div>
<!-- 로그인 후 -->
<div id="dashboard" style="display:none">
<div class="welcome">
<h2 id="welcomeText">환영합니다</h2>
<p>사용할 시스템을 선택하세요</p>
</div>
<div class="systems">
<a href="/pages/dashboard.html" class="system-card s1" id="card-s1">
<div class="system-icon">&#127981;</div>
<h3>공장관리</h3>
<p>작업보고, 근태관리, TBM, 순회점검, 장비관리 등 현장 운영 전반</p>
<span class="badge">System 1</span>
</a>
<a id="card-s2-link" class="system-card s2" id="card-s2">
<div class="system-icon">&#128680;</div>
<h3>신고 시스템</h3>
<p>안전/부적합 이슈 신고, 처리현황 추적, 부적합 자동 연동</p>
<span class="badge">System 2</span>
</a>
<a id="card-s3-link" class="system-card s3" id="card-s3">
<div class="system-icon">&#128203;</div>
<h3>부적합 관리</h3>
<p>부적합 이슈 접수, 처리, 리포트 생성, 프로젝트별 현황 관리</p>
<span class="badge">System 3</span>
</a>
</div>
</div>
</div>
<div class="footer">TK Factory Services v1.0</div>
<script src="/shared/nav-header.js"></script>
<script>
var ssoCookie = {
get: function(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
},
remove: function(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net';
}
document.cookie = cookie;
}
};
function getToken() {
return ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
}
function getUser() {
var raw = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
try { return JSON.parse(raw); } catch(e) { return null; }
}
function init() {
var token = getToken();
var user = getUser();
if (token && user) {
showDashboard(user);
} else {
document.getElementById('loginPrompt').style.display = '';
document.getElementById('dashboard').style.display = 'none';
}
}
function showDashboard(user) {
document.getElementById('loginPrompt').style.display = 'none';
document.getElementById('dashboard').style.display = '';
document.getElementById('userInfo').style.display = 'flex';
document.getElementById('userName').textContent = user.name || user.username;
document.getElementById('userRole').textContent = '(' + (user.role || '') + ')';
document.getElementById('welcomeText').textContent =
(user.name || user.username) + '님, 환영합니다';
// 접근 권한에 따라 카드 비활성화
const access = user.system_access || {};
if (access.system1 === false) document.getElementById('card-s1').classList.add('no-access');
if (access.system2 === false) document.getElementById('card-s2').classList.add('no-access');
if (access.system3 === false) document.getElementById('card-s3').classList.add('no-access');
}
function logout() {
ssoCookie.remove('sso_token');
ssoCookie.remove('sso_user');
ssoCookie.remove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
fetch('/auth/logout', { method: 'POST' }).catch(function(){});
location.reload();
}
// 서브도메인 링크 설정
function setupSystemLinks() {
var hostname = window.location.hostname;
var protocol = window.location.protocol;
var s2Link, s3Link;
if (hostname.includes('technicalkorea.net')) {
s2Link = protocol + '//tkreport.technicalkorea.net';
s3Link = protocol + '//tkqc.technicalkorea.net';
} else {
// 개발 환경: 포트 기반
s2Link = protocol + '//' + hostname + ':30180';
s3Link = protocol + '//' + hostname + ':30280';
}
document.getElementById('card-s2-link').href = s2Link;
document.getElementById('card-s3-link').href = s3Link;
}
setupSystemLinks();
init();
</script>
</body>
</html>

View File

@@ -21,7 +21,7 @@
function cookieRemove(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net';
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
}
@@ -31,11 +31,11 @@
*/
window.SSOAuth = {
getToken: function() {
return cookieGet('sso_token') || localStorage.getItem('sso_token');
return cookieGet('sso_token');
},
getUser: function() {
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
var raw = cookieGet('sso_user');
try { return JSON.parse(raw); } catch(e) { return null; }
},
@@ -51,7 +51,7 @@
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
window.location.href = this.getLoginUrl();
window.location.href = this.getLoginUrl(window.location.href) + '&logout=1';
},
/**
@@ -62,10 +62,10 @@
var loginUrl;
if (hostname.includes('technicalkorea.net')) {
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/login';
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
} else {
// 개발 환경: Gateway 포트 (30000)
loginUrl = window.location.protocol + '//' + hostname + ':30000/login';
// 개발 환경: tkds 포트 (30780)
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
}
if (redirect) {

View File

@@ -0,0 +1,559 @@
/**
* 공유 알림 벨 — 모든 서비스 헤더에 자동 삽입
*
* 사용법: initAuth() 성공 후 동적 <script> 로드
* const s = document.createElement('script');
* s.src = '/shared/notification-bell.js?v=1';
* document.head.appendChild(s);
*
* 요구사항: SSOAuth (nav-header.js) 또는 getToken() 함수 존재
*/
(function () {
'use strict';
/* ========== Config ========== */
var POLL_INTERVAL = 60000; // 60초
var DROPDOWN_LIMIT = 5;
var API_ORIGIN = (function () {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net';
return window.location.protocol + '//' + h + ':30300';
})();
var API_BASE = API_ORIGIN + '/api/notifications';
var PUSH_API_BASE = API_ORIGIN + '/api/push';
/* ========== Token helper ========== */
function _getToken() {
if (window.SSOAuth && window.SSOAuth.getToken) return window.SSOAuth.getToken();
if (typeof getToken === 'function') return getToken();
// cookie fallback
var m = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
function _authFetch(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
var token = _getToken();
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
return fetch(url, opts);
}
/* ========== State ========== */
var pollTimer = null;
var unreadCount = 0;
var dropdownOpen = false;
var pushSubscribed = false;
var ntfySubscribed = false;
/* ========== UI: Bell injection ========== */
function injectBell() {
var header = document.querySelector('header');
if (!header) return;
// 벨 컨테이너
var wrapper = document.createElement('div');
wrapper.id = 'notif-bell-wrapper';
wrapper.style.cssText = 'position:relative;display:inline-flex;align-items:center;cursor:pointer;margin-left:12px;';
wrapper.innerHTML =
'<div id="notif-bell-btn" style="position:relative;padding:6px;" title="알림">' +
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#4B5563;">' +
'<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>' +
'<path d="M13.73 21a2 2 0 0 1-3.46 0"/>' +
'</svg>' +
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
'</div>' +
'<div id="notif-dropdown" style="display:none;position:fixed;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;">' +
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
'<div style="display:flex;gap:8px;align-items:center;">' +
'<button id="notif-ntfy-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="ntfy 앱 알림">ntfy</button>' +
'<button id="notif-push-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="Push 알림 설정">Push</button>' +
'<button id="notif-read-all" style="font-size:12px;color:#3B82F6;background:none;border:none;cursor:pointer;">모두 읽음</button>' +
'</div>' +
'</div>' +
'<div id="notif-list" style="max-height:300px;overflow-y:auto;"></div>' +
'<a id="notif-view-all" href="' + _getAllNotificationsUrl() + '" style="display:block;text-align:center;padding:10px;font-size:13px;color:#3B82F6;text-decoration:none;border-top:1px solid #F3F4F6;">전체보기</a>' +
'</div>';
// 삽입 위치: header 내부, 우측 영역 찾기
var rightArea = header.querySelector('.flex.items-center.gap-3') ||
header.querySelector('.flex.items-center.gap-4') ||
header.querySelector('.flex.items-center.space-x-4') ||
header.querySelector('[class*="items-center"]');
if (rightArea) {
// 로그아웃 버튼 앞에 삽입
var logoutBtn = rightArea.querySelector('button[onclick*="Logout"], button[onclick*="logout"]');
if (logoutBtn) {
rightArea.insertBefore(wrapper, logoutBtn);
} else {
rightArea.insertBefore(wrapper, rightArea.firstChild);
}
} else {
header.appendChild(wrapper);
}
// Event listeners
document.getElementById('notif-bell-btn').addEventListener('click', toggleDropdown);
document.getElementById('notif-read-all').addEventListener('click', markAllRead);
document.getElementById('notif-push-toggle').addEventListener('click', handlePushToggle);
document.getElementById('notif-ntfy-toggle').addEventListener('click', handleNtfyToggle);
// 외부 클릭 시 닫기
document.addEventListener('click', function (e) {
if (dropdownOpen && !wrapper.contains(e.target)) {
closeDropdown();
}
});
}
function _getAllNotificationsUrl() {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net/?tab=notificationRecipients';
return window.location.protocol + '//' + h + ':30380/?tab=notificationRecipients';
}
/* ========== UI: Badge ========== */
function updateBadge(count) {
unreadCount = count;
var badge = document.getElementById('notif-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
/* ========== UI: Dropdown ========== */
function toggleDropdown(e) {
e && e.stopPropagation();
if (dropdownOpen) { closeDropdown(); return; }
openDropdown();
}
function onScrollWhileOpen() {
closeDropdown();
}
function openDropdown() {
dropdownOpen = true;
var dd = document.getElementById('notif-dropdown');
var btn = document.getElementById('notif-bell-btn');
var rect = btn.getBoundingClientRect();
// 드롭다운 너비: 뷰포트 좁으면 양쪽 8px 여백
var ddWidth = Math.min(340, window.innerWidth - 16);
dd.style.width = ddWidth + 'px';
dd.style.top = (rect.bottom + 4) + 'px';
// 우측 정렬 기본, 왼쪽 넘치면 보정
var rightOffset = window.innerWidth - rect.right;
if (rightOffset + ddWidth > window.innerWidth - 8) {
dd.style.right = 'auto';
dd.style.left = '8px';
} else {
dd.style.left = 'auto';
dd.style.right = Math.max(8, rightOffset) + 'px';
}
dd.style.display = 'block';
window.addEventListener('scroll', onScrollWhileOpen, { once: true });
loadNotifications();
updatePushToggleUI();
updateNtfyToggleUI();
}
function closeDropdown() {
dropdownOpen = false;
document.getElementById('notif-dropdown').style.display = 'none';
}
function loadNotifications() {
var list = document.getElementById('notif-list');
if (!list) return;
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">로딩 중...</div>';
_authFetch(API_BASE + '/unread')
.then(function (r) {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(function (data) {
if (!data.success || !data.data || data.data.length === 0) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
return;
}
var items = data.data.slice(0, DROPDOWN_LIMIT);
list.innerHTML = items.map(function (n) {
var timeAgo = _timeAgo(n.created_at);
var typeLabel = _typeLabel(n.type);
return '<div class="notif-item" data-id="' + n.notification_id + '" data-url="' + _escAttr(n.link_url || '') + '" style="padding:10px 16px;border-bottom:1px solid #F9FAFB;cursor:pointer;transition:background .15s;" onmouseover="this.style.background=\'#F9FAFB\'" onmouseout="this.style.background=\'transparent\'">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div style="flex:1;min-width:0;">' +
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
'<span style="font-size:10px;background:#EFF6FF;color:#3B82F6;padding:1px 6px;border-radius:3px;font-weight:500;">' + _escHtml(typeLabel) + '</span>' +
'<span style="font-size:11px;color:#9CA3AF;">' + _escHtml(timeAgo) + '</span>' +
'</div>' +
'<div style="font-size:13px;font-weight:500;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.title) + '</div>' +
(n.message ? '<div style="font-size:12px;color:#6B7280;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.message) + '</div>' : '') +
'</div>' +
'<div style="width:8px;height:8px;border-radius:50%;background:#3B82F6;flex-shrink:0;margin-top:6px;margin-left:8px;"></div>' +
'</div>' +
'</div>';
}).join('');
// 클릭 이벤트
list.querySelectorAll('.notif-item').forEach(function (el) {
el.addEventListener('click', function () {
var id = el.getAttribute('data-id');
var url = el.getAttribute('data-url');
_authFetch(API_BASE + '/' + id + '/read', { method: 'POST' })
.then(function () { fetchUnreadCount(); })
.catch(function () {});
if (url) {
// 같은 서비스 내 URL이면 직접 이동, 아니면 새 탭
if (url.startsWith('/')) window.location.href = url;
else window.open(url, '_blank');
}
closeDropdown();
});
});
})
.catch(function () {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#EF4444;font-size:13px;">잠시 후 다시 시도해주세요</div>';
});
}
function markAllRead(e) {
e && e.stopPropagation();
_authFetch(API_BASE + '/read-all', { method: 'POST' })
.then(function () {
updateBadge(0);
var list = document.getElementById('notif-list');
if (list) list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
})
.catch(function () {});
}
/* ========== Polling ========== */
function fetchUnreadCount() {
_authFetch(API_BASE + '/unread/count')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) updateBadge(data.data.count);
})
.catch(function () {});
}
function startPolling() {
fetchUnreadCount();
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
/* ========== Web Push ========== */
function handlePushToggle(e) {
e && e.stopPropagation();
if (pushSubscribed) {
unsubscribePush();
} else {
subscribePush();
}
}
function subscribePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('이 브라우저는 Push 알림을 지원하지 않습니다.');
return;
}
// VAPID 공개키 가져오기
fetch(PUSH_API_BASE + '/vapid-public-key')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !data.data.vapidPublicKey) {
alert('Push 설정을 불러올 수 없습니다.');
return;
}
var vapidKey = data.data.vapidPublicKey;
return navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) {
// push-sw.js 등록
return navigator.serviceWorker.register('/push-sw.js', { scope: '/' });
}
return reg;
}).then(function (reg) {
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey)
});
}).then(function (subscription) {
// 서버에 구독 저장
return _authFetch(PUSH_API_BASE + '/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() })
});
}).then(function (r) { return r.json(); })
.then(function (result) {
if (result.success) {
pushSubscribed = true;
stopPolling(); // Push 구독 성공 → 폴링 중단
updatePushToggleUI();
}
});
})
.catch(function (err) {
console.error('[notification-bell] Push subscribe error:', err);
if (err.name === 'NotAllowedError') {
alert('알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.');
}
});
}
function unsubscribePush() {
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (!sub) return;
var endpoint = sub.endpoint;
return sub.unsubscribe().then(function () {
return _authFetch(PUSH_API_BASE + '/unsubscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: endpoint })
});
});
}).then(function () {
pushSubscribed = false;
startPolling(); // Push 해제 → 폴링 복구
updatePushToggleUI();
}).catch(function (err) {
console.error('[notification-bell] Push unsubscribe error:', err);
});
}
function checkPushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (sub) {
pushSubscribed = true;
stopPolling(); // 이미 구독 상태면 폴링 불필요
// Push로 뱃지만 갱신 (초기 1회)
fetchUnreadCount();
}
}).catch(function () {});
}
function updatePushToggleUI() {
var btn = document.getElementById('notif-push-toggle');
if (!btn) return;
if (ntfySubscribed) {
// ntfy 활성화 시 Web Push 비활성화
btn.textContent = 'Push';
btn.style.borderColor = '#E5E7EB';
btn.style.color = '#D1D5DB';
btn.disabled = true;
btn.title = 'ntfy 사용 중에는 Push를 사용할 수 없습니다';
} else if (pushSubscribed) {
btn.textContent = 'Push 해제';
btn.style.borderColor = '#EF4444';
btn.style.color = '#EF4444';
btn.disabled = false;
btn.title = 'Push 알림 해제';
} else {
btn.textContent = 'Push';
btn.style.borderColor = '#D1D5DB';
btn.style.color = '#6B7280';
btn.disabled = false;
btn.title = 'Push 알림 설정';
}
}
/* ========== ntfy ========== */
function handleNtfyToggle(e) {
e && e.stopPropagation();
if (ntfySubscribed) {
unsubscribeNtfy();
} else {
subscribeNtfy();
}
}
function subscribeNtfy() {
_authFetch(PUSH_API_BASE + '/ntfy/subscribe', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) {
alert('ntfy 구독 등록 실패');
return;
}
ntfySubscribed = true;
updateNtfyToggleUI();
updatePushToggleUI();
showNtfySetupModal(data.data);
})
.catch(function (err) {
console.error('[notification-bell] ntfy subscribe error:', err);
});
}
function unsubscribeNtfy() {
_authFetch(PUSH_API_BASE + '/ntfy/unsubscribe', { method: 'DELETE' })
.then(function (r) { return r.json(); })
.then(function () {
ntfySubscribed = false;
updateNtfyToggleUI();
updatePushToggleUI();
checkPushStatus();
})
.catch(function (err) {
console.error('[notification-bell] ntfy unsubscribe error:', err);
});
}
function checkNtfyStatus() {
_authFetch(PUSH_API_BASE + '/ntfy/status')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success && data.data.subscribed) {
ntfySubscribed = true;
updateNtfyToggleUI();
updatePushToggleUI();
}
})
.catch(function () {});
}
function updateNtfyToggleUI() {
var btn = document.getElementById('notif-ntfy-toggle');
if (!btn) return;
if (ntfySubscribed) {
btn.textContent = 'ntfy 해제';
btn.style.borderColor = '#EF4444';
btn.style.color = '#EF4444';
} else {
btn.textContent = 'ntfy';
btn.style.borderColor = '#D1D5DB';
btn.style.color = '#6B7280';
}
}
function showNtfySetupModal(info) {
// 기존 모달 제거
var existing = document.getElementById('ntfy-setup-modal');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'ntfy-setup-modal';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
var modal = document.createElement('div');
modal.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:380px;width:90%;box-shadow:0 20px 40px rgba(0,0,0,.2);';
modal.innerHTML =
'<h3 style="margin:0 0 16px;font-size:16px;color:#111827;">ntfy 앱 설정 안내</h3>' +
'<div style="font-size:13px;color:#374151;line-height:1.6;">' +
'<p style="margin:0 0 12px;"><strong>1.</strong> ntfy 앱 설치<br>' +
'<span style="color:#6B7280;">Android: Play Store / iOS: App Store</span></p>' +
'<p style="margin:0 0 12px;"><strong>2.</strong> 앱에서 서버 추가<br>' +
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.serverUrl) + '</code></p>' +
'<p style="margin:0 0 12px;"><strong>3.</strong> 계정 로그인<br>' +
'아이디: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.username) + '</code><br>' +
'비밀번호: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.password) + '</code></p>' +
'<p style="margin:0;"><strong>4.</strong> 토픽 구독<br>' +
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.topic) + '</code></p>' +
'</div>' +
'<button id="ntfy-modal-close" style="margin-top:16px;width:100%;padding:10px;background:#3B82F6;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;">확인</button>';
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.getElementById('ntfy-modal-close').addEventListener('click', function () {
overlay.remove();
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) overlay.remove();
});
}
/* ========== Push SW message handler ========== */
function listenForPushMessages() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('message', function (e) {
if (e.data && e.data.type === 'NOTIFICATION_RECEIVED') {
// Push 수신 시 뱃지 즉시 갱신
fetchUnreadCount();
}
});
}
/* ========== Helpers ========== */
function _timeAgo(dateStr) {
if (!dateStr) return '';
var now = Date.now();
var then = new Date(dateStr).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 60) return '방금 전';
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
if (diff < 604800) return Math.floor(diff / 86400) + '일 전';
return dateStr.substring(0, 10);
}
function _typeLabel(type) {
var map = { system: '시스템', repair: '설비수리', safety: '안전', nonconformity: '부적합', partner_work: '협력업체', day_labor: '일용공' };
return map[type] || type || '알림';
}
function _escHtml(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _escAttr(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(base64);
var arr = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
/* ========== Init ========== */
function init() {
// 토큰 없으면 동작하지 않음
if (!_getToken()) return;
injectBell();
startPolling();
checkNtfyStatus();
checkPushStatus();
listenForPushMessages();
}
// DOM ready 또는 즉시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -1,34 +1,52 @@
server {
listen 80;
server_name _;
resolver 127.0.0.11 valid=10s ipv6=off;
client_max_body_size 50M;
# ===== Gateway 자체 페이지 (포털, 로그인) =====
root /usr/share/nginx/html;
# 로그인 페이지
location = /login {
try_files /login.html =404;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 대시보드 (로그인 포함)
location = /dashboard {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
try_files /dashboard.html =404;
}
# 공유 JS/CSS (nav-header 등)
location = / {
return 302 /dashboard$is_args$args;
}
# 레거시 /login → /dashboard 리다이렉트
location = /login {
return 302 /dashboard$is_args$args;
}
# 공유 JS (notification-bell.js, nav-header.js)
location /shared/ {
alias /usr/share/nginx/html/shared/;
expires 1h;
add_header Cache-Control "public, no-transform";
}
# ===== SSO Auth API =====
# SSO Auth 프록시
location /auth/ {
proxy_pass http://sso-auth:3000/api/auth/;
set $upstream http://sso-auth:3000;
rewrite ^/auth/(.*)$ /api/auth/$1 break;
proxy_pass $upstream;
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;
}
# ===== System 1 API 프록시 =====
# System1 API 프록시 (대시보드 배너용: 알림/TBM/휴가 카운트)
location /api/ {
proxy_pass http://system1-api:3005/api/;
set $upstream http://system1-api:3005;
proxy_pass $upstream$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -36,46 +54,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# ===== System 1 업로드 파일 =====
location /uploads/ {
proxy_pass http://system1-api:3005/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ===== System 1 FastAPI Bridge =====
location /fastapi/ {
proxy_pass http://system1-fastapi:8000/;
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;
}
# ===== AI Service API =====
location /ai-api/ {
proxy_pass http://ai-service:8000/api/ai/;
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;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# ===== System 1 Web (나머지 모든 경로) =====
location / {
proxy_pass http://system1-web:80;
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;
}
# ===== Health Check =====
# Health check
location /health {
access_log off;
return 200 '{"status":"ok","service":"gateway"}';
add_header Content-Type application/json;
}

167
ntfy/README.md Normal file
View File

@@ -0,0 +1,167 @@
# ntfy 푸시 알림 서버 — 운영 매뉴얼
## 개요
ntfy는 Web Push(VAPID)의 iOS 제한, 전송 보장 부재 등을 보완하는 푸시 알림 채널이다.
모바일 ntfy 앱으로 알림을 수신하고, 탭하면 딥링크로 해당 페이지로 이동한다.
- **Docker 서비스명**: `ntfy`
- **내부 URL**: `http://ntfy:80` (Docker 네트워크)
- **외부 URL**: `https://ntfy.technicalkorea.net` (Cloudflare Tunnel)
- **호스트 포트**: `30750`
- **설정 파일**: `ntfy/etc/server.yml`
- **데이터**: `ntfy_cache` Docker 볼륨 (`/var/cache/ntfy`)
## 초기 설정 (최초 1회)
### 1. Cloudflare Tunnel 설정
Zero Trust 대시보드 → Tunnels → Public Hostname 추가:
| Subdomain | Domain | Service |
|-----------|--------|---------|
| ntfy | technicalkorea.net | http://ntfy:80 |
### 2. 컨테이너 기동
```bash
docker compose up -d ntfy
```
### 3. 관리자 계정 + 토큰 발급
```bash
# 관리자 계정 생성 (비밀번호 입력 프롬프트)
docker exec -it tk-ntfy ntfy user add --role=admin admin
# API 토큰 발급
docker exec -it tk-ntfy ntfy token add admin
# 출력 예: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
```
### 4. .env에 토큰 설정
```env
NTFY_PUBLISH_TOKEN=<위에서 발급받은 토큰>
```
## 메시지 발행 (서버 → 사용자)
### curl로 테스트
```bash
# 기본 메시지
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
-d "테스트 메시지입니다." \
http://localhost:30750/tkfactory-test
# 제목 + 딥링크 포함
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
-H "Title: 설비수리 요청" \
-H "Tags: wrench" \
-H "Click: https://tkfb.technicalkorea.net/pages/admin/repair-management.html" \
-d "A동 CNC 설비 수리 요청이 접수되었습니다." \
http://localhost:30750/tkfactory-user-1
# 우선순위 높은 알림 (5=max, 3=default, 1=min)
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
-H "Title: 긴급 안전 알림" \
-H "Priority: 5" \
-H "Tags: warning" \
-d "즉시 확인이 필요합니다." \
http://localhost:30750/tkfactory-user-1
```
### 토픽 네이밍 규칙
| 토픽 | 용도 |
|------|------|
| `tkfactory-user-{userId}` | 사용자별 개인 알림 (Phase 2에서 사용) |
| `tkfactory-test` | 테스트용 |
### 주요 헤더
| 헤더 | 설명 | 예시 |
|------|------|------|
| `Title` | 알림 제목 | `설비수리 요청` |
| `Click` | 탭 시 열릴 URL (딥링크) | `https://tkfb.technicalkorea.net/pages/work/tbm.html` |
| `Tags` | 이모지 태그 ([목록](https://docs.ntfy.sh/emojis/)) | `wrench`, `warning`, `white_check_mark` |
| `Priority` | 1(min) ~ 5(max) | `5` |
| `Authorization` | 인증 토큰 | `Bearer tk_...` |
## 사용자 관리
```bash
# 사용자 목록
docker exec tk-ntfy ntfy user list
# 일반 사용자 추가
docker exec -it tk-ntfy ntfy user add username
# 사용자 삭제
docker exec tk-ntfy ntfy user del username
# 특정 토픽 접근 권한 부여 (read-write / read-only / write-only)
docker exec tk-ntfy ntfy access username 'tkfactory-user-*' read-only
# 토큰 발급
docker exec tk-ntfy ntfy token add username
# 토큰 목록
docker exec tk-ntfy ntfy token list
# 토큰 삭제
docker exec tk-ntfy ntfy token remove username tk_...
```
## 모바일 앱 설정 (수신자용)
### Android
1. Play Store에서 **ntfy** 설치
2. 설정(⚙️) → **Default server**`https://ntfy.technicalkorea.net` 입력
3. 우측 상단 사용자 아이콘 → 로그인 (발급받은 계정/비밀번호 또는 토큰)
4. **+** → 토픽 `tkfactory-user-{본인userId}` 구독
### iOS
1. App Store에서 **ntfy** 설치
2. Settings → **Default server**`https://ntfy.technicalkorea.net` 입력
3. 로그인 후 토픽 구독 (Android와 동일)
## 서버 설정 (server.yml)
| 항목 | 현재 값 | 설명 |
|------|---------|------|
| `auth-default-access` | `deny-all` | 인증 없이 접근 불가 |
| `cache-duration` | `72h` | 메시지 보관 기간 (주말 포함 3일) |
| `visitor-request-limit-burst` | `60` | 버스트 요청 한도 |
| `visitor-request-limit-replenish` | `5s` | 요청 한도 보충 주기 |
설정 변경 후 컨테이너 재시작:
```bash
docker compose restart ntfy
```
## 트러블슈팅
### 401 Unauthorized
- 토큰이 맞는지 확인: `docker exec tk-ntfy ntfy token list`
- `auth-default-access: deny-all` 상태에서 토큰 없이 요청하면 발생
### 모바일 앱에서 알림이 안 옴
- Cloudflare Tunnel Public Hostname에 `ntfy.technicalkorea.net` 등록되었는지 확인
- 앱의 Default server URL이 `https://ntfy.technicalkorea.net`인지 확인
- 앱에서 로그인했는지, 토픽을 구독했는지 확인
- 폰 설정에서 ntfy 앱 알림 권한이 켜져 있는지 확인
### 컨테이너 로그 확인
```bash
docker logs tk-ntfy --tail 50
docker logs tk-ntfy -f # 실시간
```
## Phase 2 예정 사항 (참고)
- `system1-factory/api/models/notificationModel.js``sendPushToUsers()`에서 ntfy 발송 연동
- `push_subscriptions` 테이블에 `channel` 컬럼 추가 (`web_push` | `ntfy`)
- ntfy 구독 사용자에게는 Web Push 미발송 (중복 방지)
- notification-bell.js에서 ntfy 구독 토글 UI 추가

23
ntfy/etc/server.yml Normal file
View File

@@ -0,0 +1,23 @@
# ntfy server configuration
# Docs: https://docs.ntfy.sh/config/
# Base URL (Cloudflare Tunnel 경유)
base-url: "https://ntfy.technicalkorea.net"
# Listen on port 80 inside the container
listen-http: ":80"
# Cache — 72시간 (금요일 알림 → 월요일 확인 가능)
cache-duration: "72h"
cache-file: "/var/cache/ntfy/cache.db"
# Auth — 기본 접근 거부, 토큰 인증 필수
auth-default-access: "deny-all"
auth-file: "/var/cache/ntfy/user.db"
# Attachment (비활성화 — Phase 1에서는 텍스트 알림만)
# attachment-cache-dir: "/var/cache/ntfy/attachments"
# Rate limiting
visitor-request-limit-burst: 60
visitor-request-limit-replenish: "5s"

87
scripts/check-version.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# ===================================================================
# TK Factory Services - 배포 상태 확인 (맥북에서 실행)
# ===================================================================
# 사용법: ./scripts/check-version.sh
# 설정: ~/.tk-deploy-config
# ===================================================================
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}"
exit 1
fi
source "$CONFIG_FILE"
DOCKER="/usr/local/bin/docker"
ssh_cmd() {
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
}
# === NAS 배포 버전 확인 ===
echo "=== TK Factory Services - 배포 상태 ==="
echo ""
echo -e "${CYAN}[NAS 배포 버전]${NC}"
NAS_INFO=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H|%s|%ci'" 2>/dev/null || echo "")
if [ -z "$NAS_INFO" ]; then
echo -e " ${RED}NAS에서 git 정보를 가져올 수 없습니다${NC}"
exit 1
fi
NAS_HASH=$(echo "$NAS_INFO" | cut -d'|' -f1)
NAS_MSG=$(echo "$NAS_INFO" | cut -d'|' -f2)
NAS_DATE=$(echo "$NAS_INFO" | cut -d'|' -f3)
NAS_SHORT="${NAS_HASH:0:7}"
echo " 커밋: ${NAS_SHORT} - ${NAS_MSG}"
echo " 날짜: ${NAS_DATE}"
# === origin/main 대비 상태 ===
echo ""
echo -e "${CYAN}[origin/main 대비]${NC}"
cd "$PROJECT_DIR"
git fetch origin --quiet
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
if [ -n "$ORIGIN_HASH" ]; then
if [ "$NAS_HASH" = "$ORIGIN_HASH" ]; then
echo -e " ${GREEN}최신 상태${NC} (origin/${LOCAL_BRANCH}과 동일)"
else
BEHIND_COUNT=$(git log "${NAS_HASH}..${ORIGIN_HASH}" --oneline 2>/dev/null | wc -l | tr -d ' ')
if [ "$BEHIND_COUNT" -gt 0 ]; then
echo -e " ${YELLOW}${BEHIND_COUNT}개 커밋 뒤처짐${NC}"
echo ""
echo " 미배포 커밋:"
git log "${NAS_HASH}..${ORIGIN_HASH}" --oneline --no-decorate | sed 's/^/ /'
else
echo -e " ${YELLOW}NAS가 origin보다 앞서 있거나 브랜치가 다릅니다${NC}"
fi
fi
fi
# === Docker 컨테이너 상태 ===
echo ""
echo -e "${CYAN}[Docker 컨테이너 상태]${NC}"
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} compose ps --format 'table {{.Name}}\t{{.Status}}'" 2>&1 | grep -v '^\[sudo\]' || echo " 컨테이너 상태를 가져올 수 없습니다"
# === 최근 배포 로그 ===
echo ""
echo -e "${CYAN}[최근 배포 로그]${NC}"
ssh_cmd "tail -5 ${NAS_DEPLOY_PATH}/DEPLOY_LOG" 2>/dev/null | sed 's/^/ /' || echo " 배포 로그가 없습니다"

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

@@ -0,0 +1,32 @@
-- migration-purchase-safety-patch.sql
-- 배포 후 스키마 보완 패치
-- 생성일: 2026-03-13
-- 4-a. check_in_time NOT NULL (체크인 시 시간은 항상 존재)
ALTER TABLE partner_work_checkins
MODIFY check_in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- 4-b. partner_work_reports 테이블 생성 (daily_work_reports 이름 충돌 → 별도 이름)
CREATE TABLE IF NOT EXISTS partner_work_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_id INT NOT NULL,
checkin_id INT NOT NULL,
company_id INT NOT NULL,
report_date DATE NOT NULL,
reporter_id INT NOT NULL,
actual_workers INT,
work_content TEXT,
progress_rate TINYINT,
issues TEXT,
next_plan TEXT,
confirmed_by INT,
confirmed_at DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_pwr_report_date (report_date),
INDEX idx_pwr_schedule (schedule_id),
UNIQUE INDEX uq_pwr_schedule_report_date (schedule_id, report_date),
CONSTRAINT fk_pwr_schedule FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id),
CONSTRAINT fk_pwr_checkin FOREIGN KEY (checkin_id) REFERENCES partner_work_checkins(id),
CONSTRAINT fk_pwr_company FOREIGN KEY (company_id) REFERENCES partner_companies(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,10 @@
-- migration-purchase-safety-patch2.sql
-- 협력업체 작업 신청 기능: status ENUM 확장 + requested_by 필드 추가
-- status ENUM에 requested, rejected 추가 (기존 값 모두 포함, DEFAULT 'scheduled' 유지)
ALTER TABLE partner_schedules
MODIFY COLUMN status ENUM('requested','scheduled','in_progress','completed','cancelled','rejected')
NOT NULL DEFAULT 'scheduled';
-- 신청자 필드 추가
ALTER TABLE partner_schedules ADD COLUMN requested_by INT NULL AFTER registered_by;

View File

@@ -0,0 +1,147 @@
-- migration-purchase-safety.sql
-- 협력업체/일용직 관리 및 안전교육 테이블 마이그레이션
-- MariaDB용, 재실행 안전 (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS)
-- 생성일: 2026-03-12
-- ============================================================
-- 1. sso_users 테이블에 협력업체 관련 컬럼 추가
-- ============================================================
ALTER TABLE sso_users
ADD COLUMN IF NOT EXISTS partner_company_id INT DEFAULT NULL
COMMENT '협력업체 소속 시 partner_companies.id, 내부직원은 NULL';
ALTER TABLE sso_users
ADD COLUMN IF NOT EXISTS account_expires_at DATETIME DEFAULT NULL
COMMENT '협력업체 계정 만료일, 내부직원은 NULL';
-- 외래키는 IF NOT EXISTS 구문이 없으므로 프로시저로 안전하게 추가
DELIMITER //
DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company//
CREATE PROCEDURE __add_fk_sso_users_partner_company()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_NAME = 'fk_sso_users_partner_company'
) THEN
ALTER TABLE sso_users
ADD CONSTRAINT fk_sso_users_partner_company
FOREIGN KEY (partner_company_id) REFERENCES partner_companies(id)
ON DELETE SET NULL;
END IF;
END//
DELIMITER ;
CALL __add_fk_sso_users_partner_company();
DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company;
-- ============================================================
-- 2. day_labor_requests (일용직 작업 요청)
-- ============================================================
CREATE TABLE IF NOT EXISTS day_labor_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
requester_id INT NOT NULL COMMENT 'sso_users.user_id',
department_id INT,
work_date DATE NOT NULL,
worker_count INT NOT NULL DEFAULT 1,
work_description TEXT,
workplace_name VARCHAR(100),
status ENUM('pending','approved','rejected','completed') DEFAULT 'pending',
approved_by INT,
approved_at DATETIME,
safety_reported BOOLEAN DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_day_labor_work_date (work_date),
INDEX idx_day_labor_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 3. partner_schedules (협력업체 작업 일정)
-- ============================================================
CREATE TABLE IF NOT EXISTS partner_schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
work_date DATE NOT NULL,
work_description TEXT,
workplace_name VARCHAR(100),
expected_workers INT DEFAULT 1,
registered_by INT NOT NULL,
status ENUM('scheduled','in_progress','completed','cancelled') DEFAULT 'scheduled',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_partner_sched_work_date (work_date),
INDEX idx_partner_sched_company_date (company_id, work_date),
CONSTRAINT fk_partner_schedules_company
FOREIGN KEY (company_id) REFERENCES partner_companies(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 4. partner_work_checkins (협력업체 출퇴근 체크)
-- ============================================================
CREATE TABLE IF NOT EXISTS partner_work_checkins (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_id INT NOT NULL,
company_id INT NOT NULL,
checked_by INT NOT NULL,
check_in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
check_out_time DATETIME,
worker_names TEXT,
actual_worker_count INT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_checkin_schedule_time (schedule_id, check_in_time),
CONSTRAINT fk_checkins_schedule
FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 5. partner_work_reports (협력업체 일일 작업 보고)
-- ============================================================
CREATE TABLE IF NOT EXISTS partner_work_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_id INT NOT NULL,
checkin_id INT NOT NULL,
company_id INT NOT NULL,
report_date DATE NOT NULL,
reporter_id INT NOT NULL,
actual_workers INT,
work_content TEXT,
progress_rate TINYINT CHECK (progress_rate BETWEEN 0 AND 100),
issues TEXT,
next_plan TEXT,
confirmed_by INT,
confirmed_at DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_daily_report_date (report_date),
INDEX idx_daily_report_schedule (schedule_id),
UNIQUE INDEX uq_schedule_report_date (schedule_id, report_date),
CONSTRAINT fk_daily_reports_schedule
FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id),
CONSTRAINT fk_daily_reports_checkin
FOREIGN KEY (checkin_id) REFERENCES partner_work_checkins(id),
CONSTRAINT fk_daily_reports_company
FOREIGN KEY (company_id) REFERENCES partner_companies(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 6. safety_education_reports (안전교육 보고)
-- ============================================================
CREATE TABLE IF NOT EXISTS safety_education_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type ENUM('day_labor','partner_schedule','manual') NOT NULL,
target_id INT,
education_date DATE NOT NULL,
educator VARCHAR(50),
attendees JSON,
status ENUM('planned','completed','cancelled') DEFAULT 'planned',
notes TEXT,
registered_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_safety_edu_date (education_date),
INDEX idx_safety_edu_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,23 @@
-- Migration: partner_schedules 기간 기반 + 프로젝트 연결
-- 실행 순서: 1단계 → 2단계 → 3단계 순서대로 실행할 것
-- ===== 1단계: 컬럼 변경 + end_date/project_id 추가 =====
ALTER TABLE partner_schedules
CHANGE COLUMN work_date start_date DATE NOT NULL,
ADD COLUMN end_date DATE DEFAULT NULL AFTER start_date,
ADD COLUMN project_id INT DEFAULT NULL AFTER company_id;
-- ===== 2단계: 기존 데이터 채우기 (단일 날짜 → 시작일=종료일) =====
UPDATE partner_schedules SET end_date = start_date WHERE end_date IS NULL;
-- ===== 3단계: NOT NULL로 변경 + 인덱스 재구성 =====
-- ※ 실행 전 SHOW INDEX FROM partner_schedules; 로 실제 인덱스명 확인 후 맞출 것
ALTER TABLE partner_schedules
MODIFY end_date DATE NOT NULL,
ADD INDEX idx_partner_sched_dates (start_date, end_date),
ADD INDEX idx_partner_sched_company (company_id, start_date),
ADD INDEX idx_partner_sched_project (project_id);
-- 기존 인덱스가 있다면 별도로 삭제:
-- ALTER TABLE partner_schedules DROP INDEX idx_work_date;
-- ALTER TABLE partner_schedules DROP INDEX idx_company_date;

View File

@@ -0,0 +1,26 @@
-- ============================================================
-- 업무현황 다건 입력 + 작업자 시간 추적 마이그레이션
-- 실행: MariaDB (tkpurchase DB)
-- 날짜: 2026-03-13
-- ============================================================
-- 1) 유니크 제약 제거 (1일정-1보고 제한 해제)
ALTER TABLE partner_work_reports DROP INDEX uq_pwr_schedule_report_date;
-- 2) 보고 순번 컬럼 추가
ALTER TABLE partner_work_reports
ADD COLUMN report_seq TINYINT NOT NULL DEFAULT 1 AFTER report_date;
-- 3) 작업자별 투입시간 테이블
CREATE TABLE IF NOT EXISTS work_report_workers (
id INT AUTO_INCREMENT PRIMARY KEY,
report_id INT NOT NULL,
partner_worker_id INT,
worker_name VARCHAR(100) NOT NULL,
hours_worked DECIMAL(4,1) DEFAULT 8.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_wrw_report FOREIGN KEY (report_id)
REFERENCES partner_work_reports(id) ON DELETE CASCADE,
CONSTRAINT fk_wrw_partner_worker FOREIGN KEY (partner_worker_id)
REFERENCES partner_workers(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

135
scripts/rollback-remote.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/bin/bash
# ===================================================================
# TK Factory Services - 원격 롤백 스크립트 (맥북에서 실행)
# ===================================================================
# 사용법: ./scripts/rollback-remote.sh <commit-hash>
# 설정: ~/.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'
# === 인자 확인 ===
TARGET_HASH="${1:-}"
if [ -z "$TARGET_HASH" ]; then
echo "사용법: ./scripts/rollback-remote.sh <commit-hash>"
echo ""
echo "예시:"
echo " ./scripts/rollback-remote.sh abc1234"
echo " ./scripts/rollback-remote.sh abc1234567890"
echo ""
echo "최근 커밋 목록:"
cd "$(dirname "$0")/.."
git log --oneline -10
exit 1
fi
# === 설정 로드 ===
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
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"
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
}
# === 커밋 유효성 확인 ===
cd "$PROJECT_DIR"
FULL_HASH=$(git rev-parse "$TARGET_HASH" 2>/dev/null || echo "")
if [ -z "$FULL_HASH" ]; then
echo -e "${RED}ERROR: 유효하지 않은 커밋 해시입니다: ${TARGET_HASH}${NC}"
exit 1
fi
TARGET_SHORT="${FULL_HASH:0:7}"
TARGET_MSG=$(git log -1 --format='%s' "$FULL_HASH")
TARGET_DATE=$(git log -1 --format='%ci' "$FULL_HASH")
# === 현재 NAS 상태 확인 ===
echo "=== TK Factory Services - 원격 롤백 ==="
echo ""
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
NAS_SHORT="${NAS_HASH:0:7}"
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
echo -e " 롤백 대상: ${CYAN}${TARGET_SHORT}${NC} - ${TARGET_MSG}"
echo -e " 커밋 날짜: ${TARGET_DATE}"
if [ "$FULL_HASH" = "$NAS_HASH" ]; then
echo ""
echo -e "${GREEN}이미 해당 버전입니다.${NC}"
exit 0
fi
echo ""
read -p "롤백을 진행하시겠습니까? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "롤백이 취소되었습니다."
exit 0
fi
# === 롤백 실행 ===
echo ""
echo -e "${CYAN}[1/3] NAS 코드 롤백${NC}"
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git checkout ${FULL_HASH}"
echo -e " ${GREEN}완료${NC}"
echo ""
echo -e "${CYAN}[2/3] Docker 컨테이너 재빌드${NC}"
echo " (빌드에 시간이 걸릴 수 있습니다...)"
echo ""
nas_docker "compose up -d --build"
echo ""
echo " nginx 프록시 컨테이너 재시작..."
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
echo ""
echo -e "${CYAN}[3/3] 배포 검증${NC} (15초 대기)"
sleep 15
echo ""
echo "=== Container Status ==="
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
# 배포 로그 기록
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
ssh_cmd "echo '${TIMESTAMP} | ${TARGET_SHORT} | ROLLBACK: ${TARGET_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
echo ""
echo -e "${GREEN}롤백 완료!${NC}"
echo " 버전: ${TARGET_SHORT} - ${TARGET_MSG}"
echo ""
echo -e "${YELLOW}주의: NAS가 detached HEAD 상태입니다.${NC}"
echo " 다음 정상 배포 시 자동으로 main 브랜치로 복귀합니다."

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 "$@"

28
shared/config/database.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 공유 데이터베이스 커넥션 풀
*
* Group B 서비스(tkuser, tkpurchase, tksafety, tksupport)용 동기 풀
* mysql2/promise 기반, 싱글톤 lazy initialization
*/
const mysql = require('mysql2/promise');
let pool;
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'hyungi_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'hyungi',
waitForConnections: true,
connectionLimit: parseInt(process.env.DB_CONN_LIMIT) || 10,
queueLimit: 0
});
}
return pool;
}
module.exports = { getPool };

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);
})();

360
shared/middleware/auth.js Normal file
View File

@@ -0,0 +1,360 @@
/**
* 통합 인증/인가 미들웨어
*
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const jwt = require('jsonwebtoken');
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
const logger = require('../utils/logger');
// JWT_SECRET: system1/system2는 JWT_SECRET, 나머지는 SSO_JWT_SECRET 사용
const JWT_SECRET = process.env.JWT_SECRET || process.env.SSO_JWT_SECRET;
/**
* 권한 레벨 계층 구조
* 숫자가 높을수록 상위 권한
*/
const ACCESS_LEVELS = {
worker: 1,
group_leader: 2,
support_team: 3,
admin: 4,
system: 5
};
/**
* JWT 토큰 검증 미들웨어
*
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
*
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
*
* @example
* router.get('/profile', requireAuth, getProfile);
*/
const requireAuth = (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
if (!authHeader) {
logger.warn('인증 실패: Authorization 헤더 없음', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('Authorization 헤더가 필요합니다');
}
const token = authHeader.split(' ')[1];
if (!token) {
logger.warn('인증 실패: Bearer 토큰 누락', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('Bearer 토큰이 필요합니다');
}
// JWT 검증 (SSO 공유 시크릿)
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
logger.debug('인증 성공', {
user_id: decoded.user_id || decoded.id,
username: decoded.username,
role: decoded.role,
access_level: decoded.access_level
});
next();
} catch (err) {
if (err.name === 'JsonWebTokenError') {
logger.warn('인증 실패: 유효하지 않은 토큰', {
error: err.message,
path: req.path,
ip: req.ip
});
throw new AuthenticationError('유효하지 않은 토큰입니다');
} else if (err.name === 'TokenExpiredError') {
logger.warn('인증 실패: 만료된 토큰', {
error: err.message,
path: req.path,
ip: req.ip
});
throw new AuthenticationError('토큰이 만료되었습니다');
} else if (err instanceof AuthenticationError) {
// 이미 AuthenticationError인 경우 그대로 throw
throw err;
} else {
logger.error('인증 처리 중 예상치 못한 오류', {
error: err.message,
stack: err.stack
});
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
}
}
};
/**
* 특정 역할(들) 권한 체크 미들웨어
*
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {...string} roles - 허용할 역할 목록
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 권한이 없는 경우
*
* @example
* // 단일 역할
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
*
* // 여러 역할
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
*/
const requireRole = (...roles) => {
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
const userRole = req.user.role;
const userRoleLower = userRole ? userRole.toLowerCase() : '';
const rolesLower = roles.map(r => r.toLowerCase());
if (!rolesLower.includes(userRoleLower)) {
logger.warn('권한 체크 실패: 역할 불일치', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
current_role: userRole,
required_roles: roles,
path: req.path
});
throw new ForbiddenError(
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
);
}
logger.debug('역할 권한 확인 성공', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
role: userRole,
required_roles: roles
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 최소 권한 레벨 체크 미들웨어 (계층적)
*
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 권한이 부족한 경우
*
* @example
* // admin 이상 필요 (admin, system만 허용)
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
*
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
*/
const requireMinLevel = (minLevel) => {
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
if (userLevel < requiredLevel) {
logger.warn('권한 레벨 체크 실패: 권한 부족', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
current_level: req.user.access_level,
current_level_value: userLevel,
required_level: minLevel,
required_level_value: requiredLevel,
path: req.path
});
throw new ForbiddenError(
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
);
}
logger.debug('권한 레벨 확인 성공', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
access_level: req.user.access_level,
required_level: minLevel
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
*
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {Object} options - 옵션 객체
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
*
* @example
* // URL 파라미터의 user_id로 체크
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
* resourceField: 'params.user_id'
* }), updateUser);
*
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
* resourceField: 'body.user_id',
* adminRoles: ['admin', 'system', 'support_team']
* }), deleteReport);
*/
const requireOwnerOrAdmin = (options = {}) => {
const {
resourceField = 'params.id',
userField = 'user_id',
adminRoles = ['admin', 'system']
} = options;
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
// 관리자 권한 체크
const userRole = req.user.role;
const isAdmin = adminRoles.includes(userRole);
if (isAdmin) {
logger.debug('관리자 권한으로 접근 허용', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
role: userRole,
path: req.path
});
return next();
}
// 리소스 ID 추출
const fieldParts = resourceField.split('.');
let resourceId = req;
for (const part of fieldParts) {
resourceId = resourceId[part];
if (resourceId === undefined) break;
}
// 사용자 ID (user_id 또는 id)
const userId = req.user[userField] || req.user.id || req.user.user_id;
// 소유자 체크
const isOwner = resourceId && String(resourceId) === String(userId);
if (!isOwner) {
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
user_id: userId,
username: req.user.username,
role: userRole,
resource_id: resourceId,
resource_field: resourceField,
is_admin: isAdmin,
is_owner: isOwner,
path: req.path
});
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
}
logger.debug('리소스 소유자로 접근 허용', {
user_id: userId,
username: req.user.username,
resource_id: resourceId,
path: req.path
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireAuth를 사용하세요
*/
const verifyToken = requireAuth;
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireRole('admin', 'system')을 사용하세요
*/
const requireAdmin = requireRole('admin', 'system');
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireRole('system')을 사용하세요
*/
const requireSystem = requireRole('system');
module.exports = {
// 주요 미들웨어
requireAuth,
requireRole,
requireMinLevel,
requireOwnerOrAdmin,
// 레거시 호환성
verifyToken,
requireAdmin,
requireSystem,
// 상수
ACCESS_LEVELS
};

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 };

186
shared/utils/errors.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* 커스텀 에러 클래스
*
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
*
* @author TK-FB-Project
* @since 2025-12-11
*/
/**
* 기본 애플리케이션 에러 클래스
* 모든 커스텀 에러의 부모 클래스
*/
class AppError extends Error {
/**
* @param {string} message - 에러 메시지
* @param {number} statusCode - HTTP 상태 코드
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
* @param {object} details - 추가 세부 정보
*/
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true; // 운영 에러 (예상된 에러)
Error.captureStackTrace(this, this.constructor);
}
/**
* JSON 형태로 에러 정보 반환
*/
toJSON() {
return {
name: this.name,
message: this.message,
statusCode: this.statusCode,
code: this.code,
details: this.details
};
}
}
/**
* 검증 에러 (400 Bad Request)
* 입력값 검증 실패 시 사용
*/
class ValidationError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {object} details - 검증 실패 세부 정보
*/
constructor(message = '입력값이 올바르지 않습니다', details = null) {
super(message, 400, 'VALIDATION_ERROR', details);
}
}
/**
* 인증 에러 (401 Unauthorized)
* 인증이 필요하거나 인증 실패 시 사용
*/
class AuthenticationError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '인증이 필요합니다') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
/**
* 권한 에러 (403 Forbidden)
* 권한이 부족할 때 사용
*/
class ForbiddenError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '권한이 없습니다') {
super(message, 403, 'FORBIDDEN');
}
}
/**
* 리소스 없음 에러 (404 Not Found)
* 요청한 리소스를 찾을 수 없을 때 사용
*/
class NotFoundError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {string} resource - 찾을 수 없는 리소스명
*/
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
}
}
/**
* 충돌 에러 (409 Conflict)
* 중복된 리소스 등 충돌 발생 시 사용
*/
class ConflictError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {object} details - 충돌 세부 정보
*/
constructor(message = '이미 존재하는 데이터입니다', details = null) {
super(message, 409, 'CONFLICT', details);
}
}
/**
* 서버 에러 (500 Internal Server Error)
* 예상하지 못한 서버 오류 시 사용
*/
class InternalServerError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '서버 오류가 발생했습니다') {
super(message, 500, 'INTERNAL_SERVER_ERROR');
}
}
/**
* 데이터베이스 에러 (500 Internal Server Error)
* DB 관련 오류 시 사용
*/
class DatabaseError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {Error} originalError - 원본 DB 에러
*/
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
super(
message,
500,
'DATABASE_ERROR',
originalError ? { originalMessage: originalError.message } : null
);
this.originalError = originalError;
}
}
/**
* 외부 API 에러 (502 Bad Gateway)
* 외부 서비스 호출 실패 시 사용
*/
class ExternalApiError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {string} service - 외부 서비스명
*/
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
}
}
/**
* 타임아웃 에러 (504 Gateway Timeout)
* 요청 처리 시간 초과 시 사용
*/
class TimeoutError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {number} timeout - 타임아웃 시간 (ms)
*/
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
ForbiddenError,
NotFoundError,
ConflictError,
InternalServerError,
DatabaseError,
ExternalApiError,
TimeoutError
};

199
shared/utils/logger.js Normal file
View File

@@ -0,0 +1,199 @@
/**
* 로깅 유틸리티
*
* 애플리케이션 전체에서 사용하는 통합 로거
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const fs = require('fs');
const path = require('path');
/**
* 로그 레벨 정의
*/
const LogLevel = {
ERROR: 'ERROR',
WARN: 'WARN',
INFO: 'INFO',
DEBUG: 'DEBUG'
};
/**
* 로그 레벨별 이모지
*/
const LogEmoji = {
ERROR: '❌',
WARN: '⚠️',
INFO: '',
DEBUG: '🔍'
};
/**
* 로그 레벨별 색상 (콘솔)
*/
const LogColor = {
ERROR: '\x1b[31m', // Red
WARN: '\x1b[33m', // Yellow
INFO: '\x1b[36m', // Cyan
DEBUG: '\x1b[90m', // Gray
RESET: '\x1b[0m'
};
class Logger {
constructor() {
// process.cwd() = /usr/src/app (컨테이너 WORKDIR)
// __dirname 대신 사용하여 shared/ 위치와 무관하게 서비스의 logs/ 디렉토리에 기록
this.logDir = path.join(process.cwd(), 'logs');
this.logFile = path.join(this.logDir, 'app.log');
this.errorFile = path.join(this.logDir, 'error.log');
this.ensureLogDirectory();
}
/**
* 로그 디렉토리 생성
*/
ensureLogDirectory() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
/**
* 타임스탬프 생성
*/
getTimestamp() {
return new Date().toISOString();
}
/**
* 로그 포맷팅
*/
formatLog(level, message, context = {}) {
const timestamp = this.getTimestamp();
const emoji = LogEmoji[level] || '';
const contextStr = Object.keys(context).length > 0
? `\n Context: ${JSON.stringify(context, null, 2)}`
: '';
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
}
/**
* 콘솔에 컬러 로그 출력
*/
logToConsole(level, message, context = {}) {
const color = LogColor[level] || LogColor.RESET;
const formattedLog = this.formatLog(level, message, context);
if (level === LogLevel.ERROR) {
console.error(`${color}${formattedLog}${LogColor.RESET}`);
} else if (level === LogLevel.WARN) {
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
} else {
console.log(`${color}${formattedLog}${LogColor.RESET}`);
}
}
/**
* 파일에 로그 기록
*/
logToFile(level, message, context = {}) {
const formattedLog = this.formatLog(level, message, context);
const logEntry = `${formattedLog}\n`;
try {
// 모든 로그를 app.log에 기록
fs.appendFileSync(this.logFile, logEntry, 'utf8');
// 에러는 error.log에도 기록
if (level === LogLevel.ERROR) {
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
}
} catch (err) {
console.error('로그 파일 기록 실패:', err);
}
}
/**
* 로그 기록 메인 함수
*/
log(level, message, context = {}) {
// 개발 환경에서는 콘솔에 출력
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
this.logToConsole(level, message, context);
}
// 프로덕션에서는 파일에만 기록
if (process.env.NODE_ENV === 'production') {
this.logToFile(level, message, context);
}
}
/**
* 에러 로그
*/
error(message, context = {}) {
this.log(LogLevel.ERROR, message, context);
}
/**
* 경고 로그
*/
warn(message, context = {}) {
this.log(LogLevel.WARN, message, context);
}
/**
* 정보 로그
*/
info(message, context = {}) {
this.log(LogLevel.INFO, message, context);
}
/**
* 디버그 로그
*/
debug(message, context = {}) {
// DEBUG 로그는 개발 환경에서만 출력
if (process.env.NODE_ENV === 'development') {
this.log(LogLevel.DEBUG, message, context);
}
}
/**
* HTTP 요청 로그
*/
http(method, url, statusCode, duration, user = 'anonymous') {
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
const context = {
method,
url,
statusCode,
duration,
user
};
this.log(level, message, context);
}
/**
* 데이터베이스 쿼리 로그
*/
query(sql, params = [], duration = 0) {
if (process.env.NODE_ENV === 'development') {
this.debug('DB Query', {
sql,
params,
duration: `${duration}ms`
});
}
}
}
// 싱글톤 인스턴스 생성 및 내보내기
const logger = new Logger();
module.exports = logger;

View File

@@ -0,0 +1,64 @@
// shared/utils/notifyHelper.js — 공용 알림 헬퍼
// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = {
/**
* 알림 전송
* @param {Object} opts
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system, purchase)
* @param {string} opts.title - 알림 제목
* @param {string} [opts.message] - 알림 내용
* @param {string} [opts.link_url] - 클릭 시 이동 URL
* @param {string} [opts.reference_type] - 연관 테이블명
* @param {number} [opts.reference_id] - 연관 레코드 ID
* @param {number} [opts.created_by] - 생성자 user_id
* @param {number[]} [opts.target_user_ids] - 특정 사용자 직접 알림 (생략 시 type 기반 브로드캐스트)
*/
async send(opts) {
try {
const body = JSON.stringify(opts);
const url = new URL(NOTIFY_URL);
return new Promise((resolve) => {
const req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Service-Key': SERVICE_KEY,
'Content-Length': Buffer.byteLength(body)
},
timeout: 5000
}, (res) => {
res.resume(); // drain
resolve(true);
});
req.on('error', (err) => {
console.error('[notifyHelper] 알림 전송 실패:', err.message);
resolve(false);
});
req.on('timeout', () => {
req.destroy();
console.error('[notifyHelper] 알림 전송 타임아웃');
resolve(false);
});
req.write(body);
req.end();
});
} catch (err) {
console.error('[notifyHelper] 알림 전송 오류:', err.message);
return false;
}
}
};
module.exports = notifyHelper;

View File

@@ -32,6 +32,7 @@ function createTokenPayload(user) {
role: user.role,
access_level: user.role,
sub: user.username,
partner_company_id: user.partner_company_id || null,
system_access: {
system1: user.system1_access,
system2: user.system2_access,
@@ -65,6 +66,11 @@ async function login(req, res, next) {
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
}
// 협력업체 계정 만료일 체크
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
return res.status(401).json({ success: false, error: '계정이 만료되었습니다. 관리자에게 문의하세요.' });
}
const valid = await userModel.verifyPassword(password, user.password_hash);
if (!valid) {
await redis.incr(attemptKey);
@@ -77,11 +83,11 @@ async function login(req, res, next) {
await userModel.updateLastLogin(user.user_id);
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(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
);
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
@@ -121,20 +127,39 @@ async function loginForm(req, res, next) {
return res.status(400).json({ detail: 'Missing username or password' });
}
// Rate limiting (동일 로직: /login과 공유)
const attemptKey = `login_attempts:${username}`;
const attempts = parseInt(await redis.get(attemptKey)) || 0;
if (attempts >= MAX_LOGIN_ATTEMPTS) {
return res.status(429).json({ detail: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
}
const user = await userModel.findByUsername(username);
if (!user) {
await redis.incr(attemptKey);
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
return res.status(401).json({ detail: 'Incorrect username or password' });
}
// 협력업체 계정 만료일 체크
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
return res.status(401).json({ detail: '계정이 만료되었습니다' });
}
const valid = await userModel.verifyPassword(password, user.password_hash);
if (!valid) {
await redis.incr(attemptKey);
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
return res.status(401).json({ detail: 'Incorrect username or password' });
}
// 로그인 성공 시 시도 횟수 초기화
await redis.del(attemptKey);
await userModel.updateLastLogin(user.user_id);
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({
access_token,
@@ -162,7 +187,8 @@ async function validate(req, res, next) {
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);
if (!user || !user.is_active) {
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
@@ -204,7 +230,7 @@ async function me(req, res, next) {
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);
if (!user || !user.is_active) {
return res.status(401).json({ detail: 'User not found or inactive' });
@@ -236,7 +262,7 @@ async function refresh(req, res, next) {
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') {
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
}
@@ -247,11 +273,11 @@ async function refresh(req, res, next) {
}
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(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
);
res.json({
@@ -340,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 토큰 또는 쿠키에서 토큰 추출
*/
@@ -362,5 +447,7 @@ module.exports = {
getUsers,
createUser,
updateUser,
deleteUser
deleteUser,
changePassword,
checkPasswordStrength
};

View File

@@ -20,14 +20,18 @@ const allowedOrigins = [
'https://tkreport.technicalkorea.net',
'https://tkqc.technicalkorea.net',
'https://tkuser.technicalkorea.net',
'https://tkpurchase.technicalkorea.net',
'https://tksafety.technicalkorea.net',
'https://tksupport.technicalkorea.net',
'https://tkfb.technicalkorea.net',
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
}
app.use(cors({
origin: function(origin, cb) {
if (!origin || allowedOrigins.includes(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
cb(new Error('CORS blocked: ' + origin));
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(null, false);
},
credentials: true
}));

View File

@@ -33,6 +33,10 @@ router.get('/me', authController.me);
router.post('/refresh', authController.refresh);
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.post('/users', requireAdmin, authController.createUser);

View File

@@ -1,36 +1,30 @@
# Node.js 공식 이미지 사용
FROM node:18-alpine
# 작업 디렉토리 설정
WORKDIR /usr/src/app
# 패키지 파일 복사 (캐싱 최적화)
COPY package*.json ./
# shared 모듈 복사
COPY shared/ ./shared/
# 프로덕션 의존성 설치 (sharp용 빌드 도구 포함)
# 패키지 파일 복사 + 프로덕션 의존성 설치 (sharp용 빌드 도구 포함)
COPY system1-factory/api/package*.json ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
npm install --omit=dev && \
npm install sharp && \
apk del .build-deps
# 앱 소스 복사
COPY . .
COPY system1-factory/api/ ./
# 로그 디렉토리 생성
RUN mkdir -p logs uploads
# 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 chown -R node:node /usr/src/app
# 보안을 위해 non-root 사용자로 실행
USER node
# 포트 노출
EXPOSE 3005
# 헬스체크 추가
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
# 앱 시작
CMD ["node", "index.js"]

View File

@@ -13,9 +13,14 @@ const logger = require('../utils/logger');
* 허용된 Origin 목록
*/
const allowedOrigins = [
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'https://tkuser.technicalkorea.net', // User Management
'https://tkpurchase.technicalkorea.net', // Purchase Management
'https://tksafety.technicalkorea.net', // Safety Management
'https://tksupport.technicalkorea.net', // Support Management
'http://localhost:20000', // 웹 UI (로컬)
'http://localhost:30080', // 웹 UI (Docker)
'http://localhost:3005', // API 서버
@@ -45,6 +50,12 @@ const corsOptions = {
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 허용
if (process.env.NODE_ENV === 'development') {
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
@@ -59,9 +70,9 @@ const corsOptions = {
return callback(null, true);
}
// 차단
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
logger.warn('CORS: 차단된 Origin', { origin });
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
callback(null, false);
},
/**
@@ -77,7 +88,7 @@ const corsOptions = {
/**
* 허용된 헤더
*/
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Internal-Service-Key'],
/**
* 노출할 헤더

View File

@@ -47,12 +47,20 @@ function setupRoutes(app) {
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const purchaseRoutes = require('../routes/purchaseRoutes');
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 meetingRoutes = require('../routes/meetingRoutes');
const proxyInputRoutes = require('../routes/proxyInputRoutes');
const monthlyComparisonRoutes = require('../routes/monthlyComparisonRoutes');
const dashboardRoutes = require('../routes/dashboardRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -107,7 +115,7 @@ function setupRoutes(app) {
'/api/setup/migrate-existing-data',
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details'
'/api/monthly-status/daily-details',
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
@@ -154,13 +162,21 @@ function setupRoutes(app) {
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
app.use('/api/purchases', purchaseRoutes); // 구매 내역
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/meetings', meetingRoutes); // 생산회의록
app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황
app.use('/api/monthly-comparison', monthlyComparisonRoutes); // 월간 비교·확인·정산
app.use('/api/dashboard', dashboardRoutes); // 대시보드 개인 요약
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -19,7 +19,7 @@ const helmetOptions = {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // 개발 중 unsafe-eval 허용
scriptSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://api.technicalkorea.com"],

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

@@ -135,7 +135,7 @@ const getDailyWorkReports = async (req, res) => {
try {
const userInfo = {
user_id: req.user?.user_id || req.user?.id,
role: req.user?.role || 'user'
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
};
if (!userInfo.user_id) {
@@ -303,7 +303,7 @@ const updateWorkReport = async (req, res) => {
const updateData = req.body;
const userInfo = {
user_id: req.user?.user_id || req.user?.id,
role: req.user?.role || 'user'
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
};
if (!userInfo.user_id) {

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

@@ -558,6 +558,19 @@ const EquipmentController = {
}
},
getRepairRequests: async (req, res) => {
try {
const results = await EquipmentModel.getRepairRequests(req.query.status || null);
res.json({ success: true, data: results });
} catch (err) {
logger.error('수리 요청 목록 조회 오류:', err);
res.status(500).json({
success: false,
message: '수리 요청 목록 조회 중 오류가 발생했습니다.'
});
}
},
getRepairCategories: async (req, res) => {
try {
const results = await EquipmentModel.getRepairCategories();

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,182 @@
const MeetingModel = require('../models/meetingModel');
const logger = require('../utils/logger');
const MeetingController = {
// 회의록 목록
getAll: async (req, res) => {
try {
const { year, month, search } = req.query;
const rows = await MeetingModel.getAll({ year, month, search });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Meeting getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 상세
getById: async (req, res) => {
try {
const meeting = await MeetingModel.getById(req.params.id);
if (!meeting) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
res.json({ success: true, data: meeting });
} catch (err) {
logger.error('Meeting getById error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 생성
create: async (req, res) => {
try {
const { meeting_date, title } = req.body;
if (!meeting_date || !title) {
return res.status(400).json({ success: false, message: '날짜와 제목은 필수입니다.' });
}
const id = await MeetingModel.create({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { meeting_id: id }, message: '회의록이 생성되었습니다.' });
} catch (err) {
logger.error('Meeting create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 수정
update: async (req, res) => {
try {
const meetingId = req.params.id;
const status = await MeetingModel.getStatus(meetingId);
if (!status) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
// published 상태면 admin만 수정 가능
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.update(meetingId, req.body);
res.json({ success: true, message: '회의록이 수정되었습니다.' });
} catch (err) {
logger.error('Meeting update error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 발행
publish: async (req, res) => {
try {
await MeetingModel.publish(req.params.id);
res.json({ success: true, message: '회의록이 발행되었습니다.' });
} catch (err) {
logger.error('Meeting publish error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 발행 취소 (admin only)
unpublish: async (req, res) => {
try {
await MeetingModel.unpublish(req.params.id);
res.json({ success: true, message: '발행이 취소되었습니다.' });
} catch (err) {
logger.error('Meeting unpublish error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 삭제
delete: async (req, res) => {
try {
await MeetingModel.delete(req.params.id);
res.json({ success: true, message: '회의록이 삭제되었습니다.' });
} catch (err) {
logger.error('Meeting delete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 안건 ===
addItem: async (req, res) => {
try {
const { content } = req.body;
if (!content) return res.status(400).json({ success: false, message: '안건 내용을 입력해주세요.' });
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
const id = await MeetingModel.addItem(req.params.id, req.body);
res.status(201).json({ success: true, data: { item_id: id }, message: '안건이 추가되었습니다.' });
} catch (err) {
logger.error('Meeting addItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateItem: async (req, res) => {
try {
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.updateItem(req.params.itemId, req.body);
res.json({ success: true, message: '안건이 수정되었습니다.' });
} catch (err) {
logger.error('Meeting updateItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteItem: async (req, res) => {
try {
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.deleteItem(req.params.itemId);
res.json({ success: true, message: '안건이 삭제되었습니다.' });
} catch (err) {
logger.error('Meeting deleteItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 조치상태 업데이트 (group_leader+)
updateItemStatus: async (req, res) => {
try {
const { status } = req.body;
if (!status) return res.status(400).json({ success: false, message: '상태를 선택해주세요.' });
await MeetingModel.updateItemStatus(req.params.itemId, status);
res.json({ success: true, message: '조치상태가 업데이트되었습니다.' });
} catch (err) {
logger.error('Meeting updateItemStatus error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 미완료 조치사항
getActionItems: async (req, res) => {
try {
const { status, responsible_user_id } = req.query;
const rows = await MeetingModel.getActionItems({ status, responsible_user_id });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Meeting getActionItems error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = MeetingController;

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

@@ -0,0 +1,126 @@
const PurchaseModel = require('../models/purchaseModel');
const PurchaseRequestModel = require('../models/purchaseRequestModel');
const logger = require('../utils/logger');
const PurchaseController = {
// 구매 처리 (신청 → 구매)
create: async (req, res) => {
try {
const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, register_to_master, notes } = req.body;
// item_id가 없으면 custom item → register_to_master로 자동 등록 가능
let effectiveItemId = item_id;
if (!effectiveItemId && request_id) {
// 미등록 품목의 구매 처리 — 마스터 등록 처리
const requestData = await PurchaseRequestModel.getById(request_id);
if (requestData && requestData.custom_item_name) {
if (register_to_master !== false) {
// 마스터에 등록
const newItemId = await PurchaseModel.registerToMaster(
requestData.custom_item_name,
requestData.custom_category,
null // maker
);
effectiveItemId = newItemId;
// purchase_requests.item_id 업데이트
await PurchaseRequestModel.updateItemId(request_id, newItemId);
}
}
}
if (!effectiveItemId) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' });
if (!unit_price) return res.status(400).json({ success: false, message: '구매 단가를 입력해주세요.' });
if (!purchase_date) return res.status(400).json({ success: false, message: '구매일을 입력해주세요.' });
// 구매 내역 생성
const purchaseId = await PurchaseModel.createFromRequest({
request_id: request_id || null,
item_id: effectiveItemId,
vendor_id: vendor_id || null,
quantity: quantity || 1,
unit_price,
purchase_date,
purchaser_id: req.user.id,
notes
});
// 기준가 업데이트 요청 시
if (update_base_price) {
const items = await PurchaseModel.getConsumableItems(false);
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
if (item) {
await PurchaseModel.updateBasePrice(effectiveItemId, unit_price, item.base_price, req.user.id);
}
}
// 설비 자동 등록 (category='equipment')
let equipmentResult = null;
if (request_id) {
const requestData = await PurchaseRequestModel.getById(request_id);
const category = requestData?.category || requestData?.custom_category;
if (category === 'equipment') {
equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({
item_name: requestData.item_name || requestData.custom_item_name,
maker: requestData.maker,
vendor_name: null,
unit_price,
purchase_date,
purchase_id: purchaseId,
purchaser_id: req.user.id
});
}
} else {
// 직접 구매 시에도 category 확인
const items = await PurchaseModel.getConsumableItems(false);
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
if (item && item.category === 'equipment') {
const vendors = await PurchaseModel.getVendors();
const vendor = vendors.find(v => v.vendor_id === parseInt(vendor_id));
equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({
item_name: item.item_name,
maker: item.maker,
vendor_name: vendor ? vendor.vendor_name : null,
unit_price,
purchase_date,
purchase_id: purchaseId,
purchaser_id: req.user.id
});
}
}
const result = { purchase_id: purchaseId };
if (equipmentResult) result.equipment = equipmentResult;
res.status(201).json({ success: true, data: result, message: '구매 처리가 완료되었습니다.' });
} catch (err) {
logger.error('Purchase create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구매 내역 목록
getAll: async (req, res) => {
try {
const { vendor_id, category, from_date, to_date, year_month } = req.query;
const rows = await PurchaseModel.getAll({ vendor_id, category, from_date, to_date, year_month });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Purchase getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 가격 변동 이력
getPriceHistory: async (req, res) => {
try {
const rows = await PurchaseModel.getPriceHistory(req.params.itemId);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('PriceHistory get error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = PurchaseController;

View File

@@ -0,0 +1,455 @@
const PurchaseRequestModel = require('../models/purchaseRequestModel');
const PurchaseModel = require('../models/purchaseModel');
const { saveBase64Image } = require('../services/imageUploadService');
const logger = require('../utils/logger');
const notifyHelper = require('../../../shared/utils/notifyHelper');
const koreanSearch = require('../utils/koreanSearch');
const PurchaseRequestController = {
// 구매신청 목록
getAll: async (req, res) => {
try {
const { status, category, from_date, to_date, batch_id } = req.query;
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
const filters = { status, category, from_date, to_date, batch_id };
if (!isAdmin) filters.requester_id = req.user.id;
const rows = await PurchaseRequestModel.getAll(filters);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('PurchaseRequest getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구매신청 상세
getById: async (req, res) => {
try {
const row = await PurchaseRequestModel.getById(req.params.id);
if (!row) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
res.json({ success: true, data: row });
} catch (err) {
logger.error('PurchaseRequest getById error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구매신청 생성
create: async (req, res) => {
try {
const { item_id, custom_item_name, custom_category, quantity, notes, photo } = req.body;
// item_id 또는 custom_item_name 중 하나 필수
if (!item_id && !custom_item_name) {
return res.status(400).json({ success: false, message: '소모품을 선택하거나 품목명을 입력해주세요.' });
}
if (!quantity || quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
if (!item_id && custom_item_name && !custom_category) {
return res.status(400).json({ success: false, message: '직접 입력 시 분류를 선택해주세요.' });
}
// 사진 업로드
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
const request = await PurchaseRequestModel.create({
item_id: item_id || null,
custom_item_name: custom_item_name || null,
custom_category: custom_category || null,
quantity,
requester_id: req.user.id,
request_date: new Date().toISOString().substring(0, 10),
notes,
photo_path
});
res.status(201).json({ success: true, data: request, message: '구매신청이 등록되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 보류 처리 (admin)
hold: async (req, res) => {
try {
const { hold_reason } = req.body;
const request = await PurchaseRequestModel.hold(req.params.id, hold_reason);
if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
res.json({ success: true, data: request, message: '보류 처리되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest hold error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// pending으로 되돌리기 (admin)
revert: async (req, res) => {
try {
const request = await PurchaseRequestModel.revertToPending(req.params.id);
if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
res.json({ success: true, data: request, message: '대기 상태로 되돌렸습니다.' });
} catch (err) {
logger.error('PurchaseRequest revert error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 삭제 (본인 + pending만)
delete: async (req, res) => {
try {
const existing = await PurchaseRequestModel.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
if (!isAdmin && existing.requester_id !== req.user.id) {
return res.status(403).json({ success: false, message: '본인의 신청만 삭제할 수 있습니다.' });
}
const deleted = await PurchaseRequestModel.delete(req.params.id);
if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 신청만 삭제할 수 있습니다.' });
res.json({ success: true, message: '삭제되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest delete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
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용)
getConsumableItems: async (req, res) => {
try {
const items = await PurchaseModel.getConsumableItems();
res.json({ success: true, data: items });
} catch (err) {
logger.error('ConsumableItems get error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 업체 목록 (select용)
getVendors: async (req, res) => {
try {
const vendors = await PurchaseModel.getVendors();
res.json({ success: true, data: vendors });
} catch (err) {
logger.error('Vendors get error:', err);
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: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = PurchaseRequestController;

View File

@@ -0,0 +1,259 @@
const ScheduleModel = require('../models/scheduleModel');
const logger = require('../utils/logger');
const ScheduleController = {
// === 공정 단계 ===
getPhases: async (req, res) => {
try {
const rows = await ScheduleModel.getPhases();
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getPhases error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createPhase: async (req, res) => {
try {
const { phase_name, display_order, color } = req.body;
if (!phase_name) return res.status(400).json({ success: false, message: '단계명을 입력해주세요.' });
const id = await ScheduleModel.createPhase({ phase_name, display_order, color });
res.status(201).json({ success: true, data: { phase_id: id }, message: '공정 단계가 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createPhase error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updatePhase: async (req, res) => {
try {
await ScheduleModel.updatePhase(req.params.id, req.body);
res.json({ success: true, message: '공정 단계가 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updatePhase error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 작업 템플릿 ===
getTemplates: async (req, res) => {
try {
const rows = await ScheduleModel.getTemplates(req.query.phase_id);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getTemplates error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 공정표 항목 ===
getEntries: async (req, res) => {
try {
const { project_id, year, month } = req.query;
const rows = await ScheduleModel.getEntries({ project_id, year, month });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getEntries error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
getGanttData: async (req, res) => {
try {
const year = req.query.year || new Date().getFullYear();
const data = await ScheduleModel.getGanttData(year);
res.json({ success: true, data });
} catch (err) {
logger.error('Schedule getGanttData error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createEntry: async (req, res) => {
try {
const { project_id, phase_id, task_name, start_date, end_date } = req.body;
if (!project_id || !phase_id || !task_name || !start_date || !end_date) {
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
}
const id = await ScheduleModel.createEntry({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { entry_id: id }, message: '공정표 항목이 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createBatchEntries: async (req, res) => {
try {
const { project_id, phase_id, entries } = req.body;
if (!project_id || !phase_id || !entries || entries.length === 0) {
return res.status(400).json({ success: false, message: '프로젝트, 단계, 항목 정보를 입력해주세요.' });
}
const ids = await ScheduleModel.createBatchEntries(
project_id, phase_id, entries, req.user.user_id || req.user.id
);
res.status(201).json({ success: true, data: { entry_ids: ids }, message: `${ids.length}개 항목이 일괄 추가되었습니다.` });
} catch (err) {
logger.error('Schedule createBatchEntries error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateEntry: async (req, res) => {
try {
await ScheduleModel.updateEntry(req.params.id, req.body);
res.json({ success: true, message: '공정표 항목이 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updateEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateProgress: async (req, res) => {
try {
const { progress } = req.body;
if (progress === undefined || progress < 0 || progress > 100) {
return res.status(400).json({ success: false, message: '진행률은 0~100 사이의 값이어야 합니다.' });
}
await ScheduleModel.updateProgress(req.params.id, progress);
res.json({ success: true, message: '진행률이 업데이트되었습니다.' });
} catch (err) {
logger.error('Schedule updateProgress error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteEntry: async (req, res) => {
try {
await ScheduleModel.deleteEntry(req.params.id);
res.json({ success: true, message: '공정표 항목이 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule deleteEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 의존관계 ===
addDependency: async (req, res) => {
try {
const { depends_on_entry_id } = req.body;
if (!depends_on_entry_id) {
return res.status(400).json({ success: false, message: '선행 작업을 선택해주세요.' });
}
await ScheduleModel.addDependency(req.params.id, depends_on_entry_id);
res.status(201).json({ success: true, message: '의존관계가 추가되었습니다.' });
} catch (err) {
logger.error('Schedule addDependency error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
removeDependency: async (req, res) => {
try {
await ScheduleModel.removeDependency(req.params.id, req.params.depId);
res.json({ success: true, message: '의존관계가 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule removeDependency error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 마일스톤 ===
getMilestones: async (req, res) => {
try {
const rows = await ScheduleModel.getMilestones({ project_id: req.query.project_id });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getMilestones error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createMilestone: async (req, res) => {
try {
const { project_id, milestone_name, milestone_date } = req.body;
if (!project_id || !milestone_name || !milestone_date) {
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
}
const id = await ScheduleModel.createMilestone({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { milestone_id: id }, message: '마일스톤이 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateMilestone: async (req, res) => {
try {
await ScheduleModel.updateMilestone(req.params.id, req.body);
res.json({ success: true, message: '마일스톤이 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updateMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteMilestone: async (req, res) => {
try {
await ScheduleModel.deleteMilestone(req.params.id);
res.json({ success: true, message: '마일스톤이 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule deleteMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 제품유형 ===
getProductTypes: async (req, res) => {
try {
const rows = await ScheduleModel.getProductTypes();
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getProductTypes error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 표준공정 자동 생성 ===
generateFromTemplate: async (req, res) => {
try {
const { project_id, product_type_code } = req.body;
if (!project_id || !product_type_code) {
return res.status(400).json({ success: false, message: '프로젝트와 제품유형을 선택해주세요.' });
}
const result = await ScheduleModel.generateFromTemplate(
project_id, product_type_code, req.user.user_id || req.user.id
);
if (result.error) {
return res.status(409).json({ success: false, message: result.error });
}
res.status(201).json({ success: true, data: result, message: `${result.created}개 표준공정이 생성되었습니다.` });
} catch (err) {
logger.error('Schedule generateFromTemplate error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 부적합 연동 ===
getNonconformance: async (req, res) => {
try {
const { project_id } = req.query;
if (!project_id) {
return res.status(400).json({ success: false, message: '프로젝트를 선택해주세요.' });
}
const rows = await ScheduleModel.getNonconformanceByProject(project_id);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getNonconformance error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = ScheduleController;

View File

@@ -0,0 +1,102 @@
const SettlementModel = require('../models/settlementModel');
const logger = require('../utils/logger');
const SettlementController = {
// 월간 요약 (분류별 + 업체별)
getMonthlySummary: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const [categorySummary, vendorSummary] = await Promise.all([
SettlementModel.getCategorySummary(year_month),
SettlementModel.getVendorSummary(year_month)
]);
res.json({ success: true, data: { categorySummary, vendorSummary } });
} catch (err) {
logger.error('Settlement summary error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 월간 상세 구매 목록
getMonthlyPurchases: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const rows = await SettlementModel.getMonthlyPurchases(year_month);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Settlement purchases error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 가격 변동 목록
getPriceChanges: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const rows = await SettlementModel.getPriceChanges(year_month);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Settlement priceChanges error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 입고일 기준 월간 요약
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) => {
try {
const { year_month, vendor_id, notes } = req.body;
if (!year_month || !vendor_id) return res.status(400).json({ success: false, message: '년월과 업체를 선택해주세요.' });
const result = await SettlementModel.completeSettlement(year_month, vendor_id, req.user.id, notes);
res.json({ success: true, data: result, message: '정산 완료 처리되었습니다.' });
} catch (err) {
logger.error('Settlement complete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 정산 취소
cancel: async (req, res) => {
try {
const { year_month, vendor_id } = req.body;
if (!year_month || !vendor_id) return res.status(400).json({ success: false, message: '년월과 업체를 선택해주세요.' });
const result = await SettlementModel.cancelSettlement(year_month, vendor_id);
res.json({ success: true, data: result, message: '정산이 취소되었습니다.' });
} catch (err) {
logger.error('Settlement cancel error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = SettlementController;

View File

@@ -213,7 +213,7 @@ const vacationBalanceController = {
let errorCount = 0;
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) {
errorCount++;
@@ -221,17 +221,17 @@ const vacationBalanceController = {
}
try {
const btype = balance_type || 'AUTO';
const query = `
INSERT INTO vacation_balance_details
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
INSERT INTO sp_vacation_balances
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, NULL)
ON DUPLICATE KEY UPDATE
total_days = VALUES(total_days),
notes = VALUES(notes),
updated_at = NOW()
notes = VALUES(notes)
`;
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++;
} catch (err) {
logger.error('휴가 잔액 저장 오류:', err);

View File

@@ -201,6 +201,15 @@ const vacationRequestController = {
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 });
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
} catch (error) {

View File

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

View File

@@ -0,0 +1,13 @@
-- Push 구독 테이블 생성
-- 실행: 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 (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
endpoint VARCHAR(1000) NOT NULL,
p256dh VARCHAR(500) NOT NULL,
auth VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_endpoint (endpoint(500)),
INDEX idx_push_user (user_id)
);

View File

@@ -0,0 +1,106 @@
-- 생산소모품 구매 관리 시스템 테이블
-- 업체 (tkuser에서 CRUD)
CREATE TABLE IF NOT EXISTS vendors (
vendor_id INT AUTO_INCREMENT PRIMARY KEY,
vendor_name VARCHAR(100) NOT NULL,
business_number VARCHAR(20),
representative VARCHAR(50),
contact_name VARCHAR(50),
contact_phone VARCHAR(20),
address VARCHAR(200),
bank_name VARCHAR(50),
bank_account VARCHAR(50),
notes TEXT,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 소모품 마스터 (tkuser에서 CRUD)
CREATE TABLE IF NOT EXISTS consumable_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
item_name VARCHAR(100) NOT NULL,
spec VARCHAR(200) DEFAULT NULL,
maker VARCHAR(100),
category ENUM('consumable','safety','repair','equipment') NOT NULL
COMMENT '소모품, 안전용품, 수선비, 설비',
base_price DECIMAL(12,0) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'EA',
photo_path VARCHAR(255),
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_name_spec_maker (item_name, spec, maker)
);
-- 구매신청 (tkfb에서 CRUD) — item_id NULL 허용 + 직접입력/사진 컬럼 추가
CREATE TABLE IF NOT EXISTS purchase_requests (
request_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NULL,
custom_item_name VARCHAR(100) NULL COMMENT '직접입력 품명',
custom_category ENUM('consumable','safety','repair','equipment') NULL COMMENT '직접입력 카테고리',
quantity INT NOT NULL DEFAULT 1,
requester_id INT NOT NULL COMMENT 'FK → sso_users.user_id',
request_date DATE NOT NULL,
status ENUM('pending','purchased','hold') DEFAULT 'pending'
COMMENT '대기, 구매완료, 보류',
hold_reason TEXT,
notes TEXT,
photo_path VARCHAR(255) NULL COMMENT '첨부 사진',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id),
FOREIGN KEY (requester_id) REFERENCES sso_users(user_id)
);
-- 구매 내역 (tkfb에서 CRUD)
CREATE TABLE IF NOT EXISTS purchases (
purchase_id INT AUTO_INCREMENT PRIMARY KEY,
request_id INT,
item_id INT NOT NULL,
vendor_id INT,
quantity INT NOT NULL DEFAULT 1,
unit_price DECIMAL(12,0) NOT NULL,
purchase_date DATE NOT NULL,
purchaser_id INT NOT NULL COMMENT 'FK → sso_users.user_id',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id),
FOREIGN KEY (request_id) REFERENCES purchase_requests(request_id),
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
FOREIGN KEY (purchaser_id) REFERENCES sso_users(user_id)
);
-- 가격 변동 이력
CREATE TABLE IF NOT EXISTS consumable_price_history (
history_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
old_price DECIMAL(12,0),
new_price DECIMAL(12,0) NOT NULL,
changed_by INT COMMENT 'FK → sso_users.user_id',
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id)
);
-- 월간 정산
CREATE TABLE IF NOT EXISTS monthly_settlements (
settlement_id INT AUTO_INCREMENT PRIMARY KEY,
`year_month` VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
vendor_id INT NOT NULL,
total_amount DECIMAL(12,0) DEFAULT 0,
status ENUM('pending','completed') DEFAULT 'pending',
completed_at TIMESTAMP NULL,
completed_by INT COMMENT 'FK → sso_users.user_id',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
UNIQUE KEY uq_ym_vendor (`year_month`, vendor_id)
);
-- 페이지 키 등록
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, is_admin_only, display_order) VALUES
('purchase.request', '구매신청', '/pages/purchase/request.html', 'purchase', 0, 40),
('purchase.analysis', '구매 분석', '/pages/admin/purchase-analysis.html', 'purchase', 1, 41);

View File

@@ -0,0 +1,5 @@
-- ntfy 구독 테이블
CREATE TABLE IF NOT EXISTS ntfy_subscriptions (
user_id INT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,182 @@
/**
* 공정표 + 생산회의록 시스템 마이그레이션
*
* 테이블 8개: schedule_phases, schedule_task_templates, schedule_entries,
* schedule_entry_dependencies, schedule_milestones, meeting_minutes,
* meeting_attendees, meeting_agenda_items
*/
exports.up = async (knex) => {
// 1. 공정 단계
await knex.schema.createTable('schedule_phases', (table) => {
table.increments('phase_id').primary();
table.string('phase_name', 100).notNullable();
table.integer('display_order').defaultTo(0);
table.string('color', 20).defaultTo('#3B82F6');
table.boolean('is_active').defaultTo(true);
});
// 2. 작업 템플릿
await knex.schema.createTable('schedule_task_templates', (table) => {
table.increments('template_id').primary();
table.integer('phase_id').unsigned().notNullable()
.references('phase_id').inTable('schedule_phases').onDelete('CASCADE');
table.string('task_name', 200).notNullable();
table.integer('default_duration_days').defaultTo(7);
table.integer('display_order').defaultTo(0);
table.boolean('is_active').defaultTo(true);
});
// 3. 공정표 항목
await knex.schema.createTable('schedule_entries', (table) => {
table.increments('entry_id').primary();
table.integer('project_id').notNullable()
.references('project_id').inTable('projects');
table.integer('phase_id').unsigned().notNullable()
.references('phase_id').inTable('schedule_phases');
table.string('task_name', 200).notNullable();
table.date('start_date').notNullable();
table.date('end_date').notNullable();
table.integer('progress').defaultTo(0);
table.string('status', 20).defaultTo('planned');
table.string('assignee', 100).nullable();
table.text('notes').nullable();
table.integer('display_order').defaultTo(0);
table.integer('created_by').nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 4. 작업 의존관계 (다대다)
await knex.schema.createTable('schedule_entry_dependencies', (table) => {
table.increments('id').primary();
table.integer('entry_id').unsigned().notNullable()
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
table.integer('depends_on_entry_id').unsigned().notNullable()
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
table.unique(['entry_id', 'depends_on_entry_id']);
});
// 5. 마일스톤
await knex.schema.createTable('schedule_milestones', (table) => {
table.increments('milestone_id').primary();
table.integer('project_id').notNullable()
.references('project_id').inTable('projects');
table.integer('entry_id').unsigned().nullable()
.references('entry_id').inTable('schedule_entries').onDelete('SET NULL');
table.string('milestone_name', 200).notNullable();
table.date('milestone_date').notNullable();
table.string('milestone_type', 30).defaultTo('deadline');
table.string('status', 20).defaultTo('upcoming');
table.text('notes').nullable();
table.integer('created_by').nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 6. 생산회의록
await knex.schema.createTable('meeting_minutes', (table) => {
table.increments('meeting_id').primary();
table.date('meeting_date').notNullable();
table.string('meeting_time', 10).nullable();
table.string('title', 300).notNullable();
table.string('location', 200).nullable();
table.text('summary').nullable();
table.string('status', 20).defaultTo('draft');
table.integer('created_by').nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 7. 회의 참석자 (정규화)
await knex.schema.createTable('meeting_attendees', (table) => {
table.increments('id').primary();
table.integer('meeting_id').unsigned().notNullable()
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
table.integer('user_id').notNullable()
.references('user_id').inTable('sso_users');
table.unique(['meeting_id', 'user_id']);
});
// 8. 회의 안건
await knex.schema.createTable('meeting_agenda_items', (table) => {
table.increments('item_id').primary();
table.integer('meeting_id').unsigned().notNullable()
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
table.integer('project_id').nullable()
.references('project_id').inTable('projects');
table.integer('milestone_id').unsigned().nullable()
.references('milestone_id').inTable('schedule_milestones').onDelete('SET NULL');
table.string('item_type', 30).defaultTo('other');
table.text('content').notNullable();
table.text('decision').nullable();
table.text('action_required').nullable();
table.integer('responsible_user_id').nullable()
.references('user_id').inTable('sso_users');
table.date('due_date').nullable();
table.string('status', 20).defaultTo('open');
table.integer('display_order').defaultTo(0);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// Seed: 공정 단계
await knex('schedule_phases').insert([
{ phase_name: 'Outsourcing', display_order: 1, color: '#3B82F6' },
{ phase_name: 'BASE', display_order: 2, color: '#10B981' },
{ phase_name: 'SHOP', display_order: 3, color: '#F59E0B' },
{ phase_name: 'PV/Heat Exchanger', display_order: 4, color: '#8B5CF6' },
]);
// Seed: 작업 템플릿
const phases = await knex('schedule_phases').select('phase_id', 'phase_name');
const phaseMap = {};
phases.forEach(p => { phaseMap[p.phase_name] = p.phase_id; });
const templates = [
// Outsourcing
{ phase_id: phaseMap['Outsourcing'], task_name: '용기입고', default_duration_days: 14, display_order: 1 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'PTK', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Tray and Instrument Wiring', default_duration_days: 7, display_order: 3 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Painting', default_duration_days: 5, display_order: 4 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Instrument Wiring', default_duration_days: 7, display_order: 5 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Packing', default_duration_days: 3, display_order: 6 },
// BASE
{ phase_id: phaseMap['BASE'], task_name: 'Base Fabrication', default_duration_days: 14, display_order: 1 },
{ phase_id: phaseMap['BASE'], task_name: 'Base 제작', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['BASE'], task_name: '용기설치', default_duration_days: 5, display_order: 3 },
// SHOP
{ phase_id: phaseMap['SHOP'], task_name: '배관자재입고', default_duration_days: 7, display_order: 1 },
{ phase_id: phaseMap['SHOP'], task_name: 'Pre-Fabrication for Piping', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['SHOP'], task_name: '1st Piping Assembly', default_duration_days: 14, display_order: 3 },
{ phase_id: phaseMap['SHOP'], task_name: 'Hydro. Test', default_duration_days: 3, display_order: 4 },
{ phase_id: phaseMap['SHOP'], task_name: 'Re-Assembly', default_duration_days: 7, display_order: 5 },
{ phase_id: phaseMap['SHOP'], task_name: 'Tubing', default_duration_days: 5, display_order: 6 },
{ phase_id: phaseMap['SHOP'], task_name: 'FAT', default_duration_days: 2, display_order: 7 },
];
await knex('schedule_task_templates').insert(templates);
// 페이지 접근 권한 등록
await knex.raw(`
INSERT IGNORE INTO pages (page_key, page_name, page_path, description) VALUES
('work.schedule', '공정표', '/pages/work/schedule.html', '프로젝트 공정표 Gantt 뷰'),
('work.meetings', '생산회의록', '/pages/work/meetings.html', '생산회의록 관리'),
('work.meeting_detail', '회의록 상세', '/pages/work/meeting-detail.html', '회의록 상세/작성')
`);
};
exports.down = async (knex) => {
await knex.schema.dropTableIfExists('meeting_agenda_items');
await knex.schema.dropTableIfExists('meeting_attendees');
await knex.schema.dropTableIfExists('meeting_minutes');
await knex.schema.dropTableIfExists('schedule_milestones');
await knex.schema.dropTableIfExists('schedule_entry_dependencies');
await knex.schema.dropTableIfExists('schedule_entries');
await knex.schema.dropTableIfExists('schedule_task_templates');
await knex.schema.dropTableIfExists('schedule_phases');
await knex.raw(`DELETE FROM pages WHERE page_key IN ('work.schedule', 'work.meetings', 'work.meeting_detail')`);
};

View File

@@ -0,0 +1,22 @@
-- schedule_entries 확장: 작업보고서 매핑 + 위험성평가 연결 + 생성 출처
ALTER TABLE schedule_entries ADD COLUMN work_type_id INT NULL COMMENT 'work_types FK (작업보고서 매핑)';
ALTER TABLE schedule_entries ADD COLUMN risk_assessment_id INT NULL COMMENT 'risk_projects FK';
ALTER TABLE schedule_entries ADD COLUMN source VARCHAR(20) DEFAULT 'manual' COMMENT '생성 출처 (manual/template)';
-- schedule_phases 확장: 제품유형별 phase 구분
ALTER TABLE schedule_phases ADD COLUMN product_type_id INT NULL COMMENT 'NULL=범용, 값=해당 제품유형 전용';
-- FK는 product_types 테이블 존재 시에만 생성 (tkuser 마이그레이션 의존)
-- work_type_id FK
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_work_type
FOREIGN KEY (work_type_id) REFERENCES work_types(id) ON DELETE SET NULL;
-- risk_assessment_id FK (같은 DB, 물리 FK)
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_risk_assessment
FOREIGN KEY (risk_assessment_id) REFERENCES risk_projects(id) ON DELETE SET NULL;
-- schedule_phases.product_type_id FK
ALTER TABLE schedule_phases ADD CONSTRAINT fk_phase_product_type
FOREIGN KEY (product_type_id) REFERENCES product_types(id) ON DELETE SET NULL

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

@@ -41,26 +41,59 @@ app.use((req, res) => {
});
});
// 서버 시작
const server = app.listen(PORT, () => {
// Startup: 마이그레이션 후 서버 시작
async function runStartupMigrations() {
try {
const { getDb } = require('./dbPool');
const fs = require('fs');
const path = require('path');
const db = await getDb();
const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql', '20260401_monthly_confirm_workflow.sql'];
for (const file of migrationFiles) {
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
if (!fs.existsSync(sqlPath)) continue;
const sql = fs.readFileSync(sqlPath, 'utf8');
const stmts = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
for (const stmt of stmts) {
try { await db.query(stmt); } catch (err) {
if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME', 'ER_FK_DUP_NAME'].includes(err.code)) {
// 이미 적용됨 — 무시
} else if (err.code === 'ER_NO_REFERENCED_ROW_2' || err.message.includes('Cannot add foreign key')) {
// product_types 테이블 미존재 (tkuser 미시작) — skip, 재시작 시 retry
logger.warn(`Migration FK skip (dependency not ready): ${err.message}`);
} else {
throw err;
}
}
}
logger.info(`[system1] Migration ${file} completed`);
}
} catch (err) {
logger.error('Migration error:', err.message);
}
}
let server;
runStartupMigrations().then(() => {
server = app.listen(PORT, () => {
logger.info(`서버 시작 완료`, {
port: PORT,
env: process.env.NODE_ENV || 'development',
nodeVersion: process.version
});
});
});
// Graceful Shutdown
const gracefulShutdown = (signal) => {
logger.info(`${signal} 신호 수신 - 서버 종료 시작`);
if (!server) return process.exit(0);
server.close(async () => {
logger.info('HTTP 서버 종료 완료');
// 리소스 정리
try {
// DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요
// Redis 종료 (사용 중인 경우)
if (cache.redis) {
await cache.redis.quit();
logger.info('캐시 시스템 종료 완료');
@@ -72,15 +105,12 @@ const gracefulShutdown = (signal) => {
process.exit(0);
});
// 30초 후 강제 종료
setTimeout(() => {
logger.error('강제 종료 - 정상 종료 시간 초과');
console.error(' 정상 종료 실패, 강제 종료합니다.');
process.exit(1);
}, 30000);
};
// 시그널 핸들러 등록
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -1,357 +1 @@
/**
* 통합 인증/인가 미들웨어
*
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const jwt = require('jsonwebtoken');
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
const logger = require('../utils/logger');
/**
* 권한 레벨 계층 구조
* 숫자가 높을수록 상위 권한
*/
const ACCESS_LEVELS = {
worker: 1,
group_leader: 2,
support_team: 3,
admin: 4,
system: 5
};
/**
* JWT 토큰 검증 미들웨어
*
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
*
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
*
* @example
* router.get('/profile', requireAuth, getProfile);
*/
const requireAuth = (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
if (!authHeader) {
logger.warn('인증 실패: Authorization 헤더 없음', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('Authorization 헤더가 필요합니다');
}
const token = authHeader.split(' ')[1];
if (!token) {
logger.warn('인증 실패: Bearer 토큰 누락', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('Bearer 토큰이 필요합니다');
}
// JWT 검증 (SSO 공유 시크릿 - docker-compose에서 JWT_SECRET=SSO_JWT_SECRET로 설정)
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
logger.debug('인증 성공', {
user_id: decoded.user_id || decoded.id,
username: decoded.username,
role: decoded.role,
access_level: decoded.access_level
});
next();
} catch (err) {
if (err.name === 'JsonWebTokenError') {
logger.warn('인증 실패: 유효하지 않은 토큰', {
error: err.message,
path: req.path,
ip: req.ip
});
throw new AuthenticationError('유효하지 않은 토큰입니다');
} else if (err.name === 'TokenExpiredError') {
logger.warn('인증 실패: 만료된 토큰', {
error: err.message,
path: req.path,
ip: req.ip
});
throw new AuthenticationError('토큰이 만료되었습니다');
} else if (err instanceof AuthenticationError) {
// 이미 AuthenticationError인 경우 그대로 throw
throw err;
} else {
logger.error('인증 처리 중 예상치 못한 오류', {
error: err.message,
stack: err.stack
});
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
}
}
};
/**
* 특정 역할(들) 권한 체크 미들웨어
*
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {...string} roles - 허용할 역할 목록
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 권한이 없는 경우
*
* @example
* // 단일 역할
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
*
* // 여러 역할
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
*/
const requireRole = (...roles) => {
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
const userRole = req.user.role;
const userRoleLower = userRole ? userRole.toLowerCase() : '';
const rolesLower = roles.map(r => r.toLowerCase());
if (!rolesLower.includes(userRoleLower)) {
logger.warn('권한 체크 실패: 역할 불일치', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
current_role: userRole,
required_roles: roles,
path: req.path
});
throw new ForbiddenError(
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
);
}
logger.debug('역할 권한 확인 성공', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
role: userRole,
required_roles: roles
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 최소 권한 레벨 체크 미들웨어 (계층적)
*
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 권한이 부족한 경우
*
* @example
* // admin 이상 필요 (admin, system만 허용)
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
*
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
*/
const requireMinLevel = (minLevel) => {
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
if (userLevel < requiredLevel) {
logger.warn('권한 레벨 체크 실패: 권한 부족', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
current_level: req.user.access_level,
current_level_value: userLevel,
required_level: minLevel,
required_level_value: requiredLevel,
path: req.path
});
throw new ForbiddenError(
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
);
}
logger.debug('권한 레벨 확인 성공', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
access_level: req.user.access_level,
required_level: minLevel
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
*
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
*
* @param {Object} options - 옵션 객체
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
* @returns {Function} Express 미들웨어 함수
* @throws {AuthenticationError} 인증되지 않은 경우
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
*
* @example
* // URL 파라미터의 user_id로 체크
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
* resourceField: 'params.user_id'
* }), updateUser);
*
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
* resourceField: 'body.user_id',
* adminRoles: ['admin', 'system', 'support_team']
* }), deleteReport);
*/
const requireOwnerOrAdmin = (options = {}) => {
const {
resourceField = 'params.id',
userField = 'user_id',
adminRoles = ['admin', 'system']
} = options;
return (req, res, next) => {
try {
if (!req.user) {
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
path: req.path,
method: req.method,
ip: req.ip
});
throw new AuthenticationError('인증이 필요합니다');
}
// 관리자 권한 체크
const userRole = req.user.role;
const isAdmin = adminRoles.includes(userRole);
if (isAdmin) {
logger.debug('관리자 권한으로 접근 허용', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,
role: userRole,
path: req.path
});
return next();
}
// 리소스 ID 추출
const fieldParts = resourceField.split('.');
let resourceId = req;
for (const part of fieldParts) {
resourceId = resourceId[part];
if (resourceId === undefined) break;
}
// 사용자 ID (user_id 또는 id)
const userId = req.user[userField] || req.user.id || req.user.user_id;
// 소유자 체크
const isOwner = resourceId && String(resourceId) === String(userId);
if (!isOwner) {
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
user_id: userId,
username: req.user.username,
role: userRole,
resource_id: resourceId,
resource_field: resourceField,
is_admin: isAdmin,
is_owner: isOwner,
path: req.path
});
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
}
logger.debug('리소스 소유자로 접근 허용', {
user_id: userId,
username: req.user.username,
resource_id: resourceId,
path: req.path
});
next();
} catch (err) {
next(err);
}
};
};
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireAuth를 사용하세요
*/
const verifyToken = requireAuth;
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireRole('admin', 'system')을 사용하세요
*/
const requireAdmin = requireRole('admin', 'system');
/**
* 레거시 호환성을 위한 별칭
* @deprecated requireRole('system')을 사용하세요
*/
const requireSystem = requireRole('system');
module.exports = {
// 주요 미들웨어
requireAuth,
requireRole,
requireMinLevel,
requireOwnerOrAdmin,
// 레거시 호환성
verifyToken,
requireAdmin,
requireSystem,
// 상수
ACCESS_LEVELS
};
module.exports = require('../shared/middleware/auth');

View File

@@ -150,9 +150,10 @@ class AttendanceModel {
static async initializeDailyRecords(date, createdBy) {
const db = await getDb();
// 1. 활성 작업자 조회
// 1. 활성 작업자 조회 (입사일 이전 제외)
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 };
@@ -269,8 +270,7 @@ class AttendanceModel {
w.job_type,
COALESCE(dar.total_work_hours, 0) as total_work_hours,
COALESCE(dar.status, 'incomplete') as status,
dar.is_vacation_processed,
dar.overtime_approved,
dar.is_overtime_approved,
wat.type_name as attendance_type_name,
wat.type_code as attendance_type_code,
vt.type_name as vacation_type_name,

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

@@ -1,6 +1,6 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel');
const notifyHelper = require('../shared/utils/notifyHelper');
const EquipmentModel = {
// CREATE - 설비 생성
@@ -669,17 +669,16 @@ const EquipmentModel = {
['repair_needed', requestData.equipment_id]
);
try {
await notificationModel.createRepairNotification({
equipment_id: requestData.equipment_id,
equipment_name: requestData.equipment_name || '설비',
repair_type: requestData.repair_type || '일반 수리',
request_id: result.insertId,
// fire-and-forget: 알림 실패가 수리 신청을 블로킹하면 안 됨
notifyHelper.send({
type: 'repair',
title: `수리 신청: ${requestData.equipment_name || '설비'}`,
message: `${requestData.repair_type || '일반 수리'} 수리가 신청되었습니다.`,
link_url: '/pages/admin/repair-management.html',
reference_type: 'work_issue_reports',
reference_id: result.insertId,
created_by: requestData.reported_by
});
} catch (notifError) {
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
}).catch(() => {});
return {
report_id: result.insertId,
@@ -711,6 +710,33 @@ const EquipmentModel = {
return rows;
},
getRepairRequests: async (status) => {
const db = await getDb();
let query = `
SELECT wir.report_id, wir.status, wir.additional_description, wir.created_at,
e.equipment_name, irc.category_name, iri.item_name,
u_rep.name AS reported_by_name, w.workplace_name
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN equipments e ON wir.equipment_id = e.equipment_id
LEFT JOIN users u_rep ON wir.reporter_id = u_rep.user_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE irc.category_name = '설비 수리'
`;
const values = [];
if (status) {
query += ' AND wir.status = ?';
values.push(status);
}
query += ' ORDER BY wir.created_at DESC';
const [rows] = await db.query(query, values);
return rows;
},
getRepairCategories: async () => {
const db = await getDb();
const [rows] = await db.query(

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;

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