Compare commits

...

298 Commits

Author SHA1 Message Date
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
Hyungi Ahn
9647ae0d56 refactor: TKQC AI 기능 재배치
- 현황판: AI 시맨틱 검색/Q&A 제거 (AI 어시스턴트로 이관)
- 관리함 진행중 카드: AI 해결방안 제안 버튼 추가
- aiSuggestSolutionInline() 인라인 카드용 함수 추가
- applyAiSuggestion() AI 제안 → textarea 적용 기능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:52:41 +09:00
Hyungi Ahn
e6cc466a0e feat: AI 어시스턴트 마크다운 렌더링 및 상태 표시 개선
- marked.js 라이브러리 추가, AI 답변 마크다운 렌더링
- AI 답변 prose 스타일 적용 (제목, 목록, 굵은글씨, 인용)
- health 응답 파싱 개선 (이중 중첩 구조 fallback 추가)
- JS/CSS 버전 캐시 무효화 (v=20260307)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:52:19 +09:00
Hyungi Ahn
617c51ca53 perf: RAG Q&A 속도 최적화 및 프롬프트 개선
- 검색 건수 15건 → 7건으로 축소 (컨텍스트 경량화)
- 프롬프트: 500자 이내 간결 답변 유도, 마크다운 포맷 지시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:52:08 +09:00
Hyungi Ahn
e9d73ee30e refactor: AI 서비스 조립컴 Ollama 단독 운영으로 전환
- Ollama 메인 → MLX fallback 순서로 변경 (기존 MLX 우선 제거)
- OLLAMA_BASE_URL을 gpu.hyungi.net으로 변경 (Docker 네트워크 호환)
- OLLAMA_TEXT_MODEL을 qwen3:8b → qwen3.5:9b-q8_0으로 업데이트
- health 엔드포인트: model 필드 직접 반환, 이중 중첩 해소
- health 체크 타임아웃 120초 → 5초로 단축
- Ollama API 호출에 think: false 추가 (thinking 토큰 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:51:53 +09:00
Hyungi Ahn
59cbcebb94 chore: docker-compose MLX 환경변수 추가 및 인프라 정비
- ai-service에 MLX_BASE_URL, MLX_TEXT_MODEL 환경변수 추가
- OLLAMA_TEXT_MODEL 기본값 qwen3:8b로 변경
- MariaDB healthcheck 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:18:31 +09:00
Hyungi Ahn
11cffbd920 refactor: System2/3, User Management SSO 인증 통합
- System2 신고: SSO JWT 인증 전환, API base 정리
- System3 부적합: SSO 인증 매니저 통합, 권한 체계 정비
- User Management: SSO 토큰 기반 사용자 관리 API 연동

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:18:23 +09:00
Hyungi Ahn
61c810bd47 refactor: 프론트엔드 SSO 인증 통합 및 API 경로 정리
- Gateway 로그인/포탈 페이지 SSO 연동
- System1 web/fastapi-bridge API base URL 동적 설정
- SSO 토큰 기반 인증 흐름 통일
- deprecated JS 파일 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:18:09 +09:00
Hyungi Ahn
ec755ed52f refactor: System1 API 인증 체계 SSO 전환 및 마이그레이션 정비
- SSO JWT 인증으로 전환 (auth.service.js)
- worker_id → user_id 마이그레이션 완료
- departments 연동, CORS 미들웨어 정리
- 불필요 파일 삭제 (tk_database.db, visitRequestController.js)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:18:00 +09:00
Hyungi Ahn
2f7e083db0 feat: AI 서비스 MLX 듀얼 백엔드 및 모델 최적화
- MLX(맥미니 27B) 우선 → Ollama(조립컴 9B) fallback 구조
- pydantic-settings 기반 config 전환
- health check에 MLX 상태 추가
- 텍스트 모델 qwen3:8b → qwen3.5:9b-q8_0 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:17:50 +09:00
Hyungi Ahn
cad662473b fix: SSO Auth CORS 정책 강화 및 Redis 세션 지원 추가
- CORS origin 검증 로직 추가 (운영 도메인 + localhost + 192.168.x.x)
- Redis 기반 세션/토큰 관리 유틸 추가
- departments 테이블 JOIN 지원 (findByUsername, findById)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:17:42 +09:00
Hyungi Ahn
b3012b8320 feat: AI 서비스 및 AI 어시스턴트 전용 페이지 추가
- ai-service: Ollama 기반 AI 서비스 (분류, 시맨틱 검색, RAG Q&A, 패턴 분석)
- AI 어시스턴트 페이지: 채팅형 Q&A, 시맨틱 검색, 패턴 분석, 분류 테스트
- 권한 시스템에 ai_assistant 페이지 등록 (기본 비활성)
- 기존 페이지에 AI 기능 통합 (대시보드, 수신함, 관리함)
- docker-compose, gateway, nginx 설정 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:38:30 +09:00
Hyungi Ahn
d385ce7ac1 refactor: worker_id 잔재 제거 - user_id 기반으로 완전 전환
- workerModel: remove()를 user_id 기반 cascading delete로 전환
- workerController: 계정 생성/해제를 workers.user_id 연결 방식으로 변경
- userController: JOIN 방향 전환 (u.worker_id→w.worker_id 에서 w.user_id→u.user_id)
- authController, systemController, authRoutes: 모든 CRUD에서 worker_id 참조 제거
- DB: UNIQUE KEY 5개 교체, FK 7개 삭제, daily_worker_summary user_id 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:47:01 +09:00
Hyungi Ahn
7089548722 fix: 작업자 배정 조회 쿼리 수정 및 작업보고서 시간 자동입력
- tbmTransferModel: leader_id → leader_user_id 컬럼명 수정, department 필터 제거
- daily-work-report: TBM 출근유형(정상/연장/조퇴)에 따른 시간 자동 계산

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:55:37 +09:00
Hyungi Ahn
e18983ac06 feat: TBM 중복 배정 방지, 설비 배치도 좌표계 통일, 구역 상세 CSS 수정
- TBM 팀원 추가 시 중복 배정 검증 및 409 에러 처리 (tbmController, tbmModel, tbm-create.js, tbm.js, tbm/api.js)
- tkuser/tkfb 설비 배치도 좌표계를 좌상단 기준으로 통일 (CSS left/top 방식)
- tkuser 설비 배치도에 드래그 이동, 코너 리사이즈, 배치 버튼 추가
- 대분류 지도 영역 수정 버튼 추가 (workplace-layout-map.js, tkuser-layout-map.js)
- tkfb workplace-status 캔버스 maxWidth 800 통일
- zone-detail.css object-fit:contain 제거 → height:auto로 마커 위치 정확도 개선
- imageUploadService 업로드 경로 Docker 볼륨 마운트 경로로 수정
- repair-management 카테고리 필터 nonconformity → facility 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:38:11 +09:00
Hyungi Ahn
7a12869d26 fix: 지도 작업자 수 중복 카운트 수정 (team_member_count + leader 이중 합산 제거)
- system1 workplace-status.js: leader_id에 의한 +1 제거
- system1 mobile-dashboard.js: +1 제거 (2곳)
- system2 issue-report.js: leader_id에 의한 +1 제거
- dashboard.html: JS 캐시 버스팅 버전 파라미터 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:35:38 +09:00
Hyungi Ahn
9b81a52283 feat(system3): TKQC 모바일 전용 페이지 구현 및 데스크탑 관리함 반응형 개선
- 모바일 전용 페이지 신규: /m/dashboard, /m/inbox, /m/management
- 공통 모바일 CSS/JS: m-common.css, m-common.js (바텀시트, 바텀네비, 터치 최적화)
- nginx.conf에 /m/ location 블록 추가
- 데스크탑 HTML에 모바일 뷰포트 리다이렉트 추가 (<=768px)
- 데스크탑 관리함 카드 헤더 반응형 레이아웃 (flex-wrap, 1280px 브레이크포인트)
- collapse-content overflow:hidden → overflow:visible 수정 (내용 잘림 해결)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:34:52 +09:00
827 changed files with 114566 additions and 37685 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'hyung-ddfdf3-D341@' 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

@@ -84,6 +84,14 @@ PMA_USER=root
PMA_PASSWORD=change_this_root_password_min_12_chars
UPLOAD_LIMIT=50M
# -------------------------------------------------------------------
# AI Service
# -------------------------------------------------------------------
OLLAMA_BASE_URL=http://your-ollama-server:11434
OLLAMA_TEXT_MODEL=qwen2.5:14b-instruct-q4_K_M
OLLAMA_EMBED_MODEL=bge-m3
OLLAMA_TIMEOUT=120
# -------------------------------------------------------------------
# Cloudflare Tunnel
# -------------------------------------------------------------------

1
.gitignore vendored
View File

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

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
## 아키텍처 개요
@@ -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: 기존 서비스 중지
@@ -220,13 +237,60 @@ 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
cd /volume1/docker_1/tk-factory-services
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
# TK-FB 복원

11
ai-service/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.11-slim
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
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"]

36
ai-service/config.py Normal file
View File

@@ -0,0 +1,36 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# GPU서버 Ollama (텍스트 생성)
OLLAMA_BASE_URL: str = "http://192.168.1.186:11434"
OLLAMA_TEXT_MODEL: str = "qwen3.5:9b-q8_0"
OLLAMA_TIMEOUT: int = 120
# 맥미니 Ollama (임베딩) — OrbStack: host.internal / Docker Desktop: host.docker.internal
OLLAMA_EMBED_URL: str = "http://host.internal:11434"
OLLAMA_EMBED_MODEL: str = "bge-m3"
# 맥미니 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"
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
# ds923 System1 API (Tailscale)
SYSTEM1_API_URL: str = "http://100.71.132.52:30005"
METADATA_DB_PATH: str = "/app/data/metadata.db"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,39 @@
import sqlite3
from config import settings
class MetadataStore:
def __init__(self):
self.db_path = settings.METADATA_DB_PATH
def initialize(self):
conn = sqlite3.connect(self.db_path)
conn.execute(
"CREATE TABLE IF NOT EXISTS sync_state ("
" key TEXT PRIMARY KEY,"
" value TEXT"
")"
)
conn.commit()
conn.close()
def get_last_synced_id(self) -> int:
conn = sqlite3.connect(self.db_path)
cur = conn.execute(
"SELECT value FROM sync_state WHERE key = 'last_synced_id'"
)
row = cur.fetchone()
conn.close()
return int(row[0]) if row else 0
def set_last_synced_id(self, issue_id: int):
conn = sqlite3.connect(self.db_path)
conn.execute(
"INSERT OR REPLACE INTO sync_state (key, value) VALUES ('last_synced_id', ?)",
(str(issue_id),),
)
conn.commit()
conn.close()
metadata_store = MetadataStore()

View File

@@ -0,0 +1,107 @@
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 = settings.QDRANT_COLLECTION # "tk_qc_issues"
def initialize(self):
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,
document: str,
embedding: list[float],
metadata: dict = 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(
self,
embedding: list[float],
n_results: int = 5,
where: dict = None,
) -> list[dict]:
query_filter = self._build_filter(where) if where else None
try:
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 = []
for hit in response.points:
payload = hit.payload or {}
item = {
"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),
}
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):
point_id = self._to_uuid(doc_id)
self.client.delete(
collection_name=self.collection,
points_selector=[point_id],
)
def count(self) -> int:
info = self.client.get_collection(collection_name=self.collection)
return info.points_count
def stats(self) -> dict:
return {
"total_documents": self.count(),
"collection_name": self.collection,
}
vector_store = VectorStore()

94
ai-service/main.py Normal file
View File

@@ -0,0 +1,94 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
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, 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"}
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method == "OPTIONS" or request.url.path in PUBLIC_PATHS:
return await call_next(request)
try:
request.state.user = await verify_token(request)
except Exception as e:
return JSONResponse(status_code=401, content={"detail": str(e.detail) if hasattr(e, "detail") else "인증 실패"})
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()
app = FastAPI(
title="TK AI Service",
description="AI 서비스 (유사 검색, 분류, 보고서)",
version="1.0.0",
lifespan=lifespan,
)
ALLOWED_ORIGINS = [
"https://tkfb.technicalkorea.net",
"https://tkreport.technicalkorea.net",
"https://tkqc.technicalkorea.net",
"https://tkuser.technicalkorea.net",
]
if os.getenv("ENV", "production") == "development":
ALLOWED_ORIGINS += ["http://localhost:30080", "http://localhost:30180", "http://localhost:30280"]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuthMiddleware)
app.include_router(health.router, prefix="/api/ai")
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("/")
async def root():
return {"message": "TK AI Service", "version": "1.0.0"}

View File

View File

@@ -0,0 +1,24 @@
from fastapi import Request, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError, ExpiredSignatureError
from config import settings
security = HTTPBearer(auto_error=False)
async def verify_token(request: Request) -> dict:
"""JWT 토큰 검증. SSO 서비스와 동일한 시크릿 사용."""
auth: HTTPAuthorizationCredentials = await security(request)
if not auth:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authorization 헤더가 필요합니다")
if not settings.SECRET_KEY:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서버 인증 설정 오류")
try:
payload = jwt.decode(auth.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="토큰이 만료되었습니다")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="유효하지 않은 토큰입니다")

View File

@@ -0,0 +1,18 @@
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고 내용을 분석하여 판별하세요.
부적합 내용:
{description}
상세 내용:
{detail_notes}
다음 JSON 형식으로만 응답하세요:
{{
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
"category_confidence": 0.0~1.0,
"responsible_department": "production|quality|purchasing|design|sales",
"department_confidence": 0.0~1.0,
"severity": "low|medium|high|critical",
"summary": "한줄 요약 (30자 이내)",
"reasoning": "판단 근거 (2-3문장)"
}}

View File

@@ -0,0 +1,22 @@
당신은 공장 관리 보고서 작성자입니다. 아래 데이터를 바탕으로 일일 브리핑을 작성하세요.
날짜: {date}
[근태 현황]
{attendance_data}
[작업 현황]
{work_report_data}
[부적합 현황]
{qc_issue_data}
[순회점검 현황]
{patrol_data}
다음 형식으로 작성하세요:
1. 오늘의 요약 (2-3문장)
2. 주요 이슈 및 관심사항
3. 부적합 현황 (신규/진행/지연)
4. 내일 주의사항

View File

@@ -0,0 +1,23 @@
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고를 분류하세요.
[신고 내용]
{description}
[상세 내용]
{detail_notes}
[참고: 과거 유사 사례]
{retrieved_cases}
위 과거 사례의 분류 패턴을 참고하여, 현재 부적합을 판별하세요.
다음 JSON 형식으로만 응답하세요:
{{
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
"category_confidence": 0.0~1.0,
"responsible_department": "production|quality|purchasing|design|sales",
"department_confidence": 0.0~1.0,
"severity": "low|medium|high|critical",
"summary": "한줄 요약 (30자 이내)",
"reasoning": "판단 근거 — 과거 사례 참고 내용 포함 (2-3문장)"
}}

View File

@@ -0,0 +1,16 @@
당신은 공장 품질관리(QC) 데이터 분석가입니다. 아래 부적합에 대해 패턴을 분석하세요.
[분석 대상]
{description}
[유사 부적합 {total_similar}건]
{retrieved_cases}
다음을 분석하세요:
1. **반복 여부**: 이 문제가 과거에도 발생했는지, 반복 빈도는 어느 정도인지
2. **공통 패턴**: 유사 사례들의 공통 원인, 공통 부서, 공통 시기 등
3. **근본 원인 추정**: 반복되는 원인이 있다면 근본 원인은 무엇인지
4. **개선 제안**: 재발 방지를 위한 구조적 개선 방안
데이터 기반으로 객관적으로 분석하세요.

View File

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

View File

@@ -0,0 +1,18 @@
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈에 대한 해결방안을 제안하세요.
[현재 부적합]
분류: {category}
내용: {description}
상세: {detail_notes}
[과거 유사 사례]
{retrieved_cases}
위 과거 사례들을 참고하여 다음을 제안하세요:
1. **권장 해결방안**: 과거 유사 사례에서 효과적이었던 해결 방법을 기반으로 구체적인 조치를 제안
2. **예상 원인**: 유사 사례에서 확인된 원인 패턴을 바탕으로 가능한 원인 분석
3. **담당 부서**: 어느 부서에서 처리해야 하는지
4. **주의사항**: 과거 사례에서 배운 교훈이나 주의할 점
간결하고 실용적으로 작성하세요. 과거 사례가 없는 부분은 일반적인 QC 지식으로 보완하세요.

View File

@@ -0,0 +1,17 @@
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈를 간결하게 요약하세요.
부적합 내용:
{description}
상세 내용:
{detail_notes}
해결 방법:
{solution}
다음 JSON 형식으로만 응답하세요:
{{
"summary": "핵심 요약 (50자 이내)",
"key_points": ["요점1", "요점2", "요점3"],
"suggested_action": "권장 조치사항 (선택)"
}}

View File

@@ -0,0 +1,10 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.27.0
qdrant-client>=1.7.0
numpy==1.26.2
pydantic==2.5.0
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
pymysql==1.1.0
sqlalchemy==2.0.23

View File

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

@@ -0,0 +1,47 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.classification_service import (
classify_issue,
summarize_issue,
classify_and_summarize,
)
router = APIRouter(tags=["classification"])
class ClassifyRequest(BaseModel):
description: str
detail_notes: str = ""
class SummarizeRequest(BaseModel):
description: str
detail_notes: str = ""
solution: str = ""
@router.post("/classify")
async def classify(req: ClassifyRequest):
try:
result = await classify_issue(req.description, req.detail_notes)
return {"available": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/summarize")
async def summarize(req: SummarizeRequest):
try:
result = await summarize_issue(req.description, req.detail_notes, req.solution)
return {"available": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/classify-and-summarize")
async def classify_and_summarize_endpoint(req: ClassifyRequest):
try:
result = await classify_and_summarize(req.description, req.detail_notes)
return {"available": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from services.report_service import generate_daily_report
from datetime import date
router = APIRouter(tags=["daily_report"])
class DailyReportRequest(BaseModel):
date: str | None = None
project_id: int | None = None
@router.post("/report/daily")
async def daily_report(req: DailyReportRequest, request: Request):
report_date = req.date or date.today().isoformat()
token = request.headers.get("authorization", "").replace("Bearer ", "")
try:
result = await generate_daily_report(report_date, req.project_id, token)
return {"available": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/report/preview")
async def report_preview(req: DailyReportRequest, request: Request):
report_date = req.date or date.today().isoformat()
token = request.headers.get("authorization", "").replace("Bearer ", "")
try:
result = await generate_daily_report(report_date, req.project_id, token)
return {"available": True, "preview": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from pydantic import BaseModel
from services.embedding_service import (
sync_all_issues,
sync_single_issue,
sync_incremental,
search_similar_by_id,
search_similar_by_text,
)
from db.vector_store import vector_store
router = APIRouter(tags=["embeddings"])
class SyncSingleRequest(BaseModel):
issue_id: int
class SearchRequest(BaseModel):
query: str
n_results: int = 5
project_id: int | None = None
category: str | None = None
@router.post("/embeddings/sync")
async def sync_embeddings(background_tasks: BackgroundTasks):
background_tasks.add_task(sync_all_issues)
return {"status": "sync_started", "message": "전체 임베딩 동기화가 시작되었습니다"}
@router.post("/embeddings/sync-full")
async def sync_embeddings_full():
result = await sync_all_issues()
return {"status": "completed", **result}
@router.post("/embeddings/sync-single")
async def sync_single(req: SyncSingleRequest):
result = await sync_single_issue(req.issue_id)
return result
@router.post("/embeddings/sync-incremental")
async def sync_incr():
result = await sync_incremental()
return result
@router.get("/similar/{issue_id}")
async def get_similar(issue_id: int, n_results: int = Query(default=5, le=20)):
try:
results = await search_similar_by_id(issue_id, n_results)
return {"available": True, "results": results, "query_issue_id": issue_id}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/similar/search")
async def search_similar(req: SearchRequest):
filters = {}
if req.project_id is not None:
filters["project_id"] = str(req.project_id)
if req.category:
filters["category"] = req.category
try:
results = await search_similar_by_text(
req.query, req.n_results, filters or None
)
return {"available": True, "results": results}
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.get("/embeddings/stats")
async def embedding_stats():
return vector_store.stats()

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter
from services.ollama_client import ollama_client
from db.vector_store import vector_store
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check():
backends = await ollama_client.check_health()
stats = vector_store.stats()
# 메인 텍스트 모델명 결정
model_name = None
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_text": backends.get("ollama_text", {}),
"ollama_embed": backends.get("ollama_embed", {}),
"embeddings": stats,
}
@router.get("/models")
async def list_models():
return await ollama_client.check_health()

57
ai-service/routers/rag.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.rag_service import (
rag_suggest_solution,
rag_ask,
rag_analyze_pattern,
rag_classify_with_context,
)
router = APIRouter(tags=["rag"])
class AskRequest(BaseModel):
question: str
project_id: int | None = None
class PatternRequest(BaseModel):
description: str
n_results: int = 10
class ClassifyRequest(BaseModel):
description: str
detail_notes: str = ""
@router.post("/rag/suggest-solution/{issue_id}")
async def suggest_solution(issue_id: int):
try:
return await rag_suggest_solution(issue_id)
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/rag/ask")
async def ask_question(req: AskRequest):
try:
return await rag_ask(req.question, req.project_id)
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/rag/pattern")
async def analyze_pattern(req: PatternRequest):
try:
return await rag_analyze_pattern(req.description, req.n_results)
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
@router.post("/rag/classify")
async def classify_with_rag(req: ClassifyRequest):
try:
return await rag_classify_with_context(req.description, req.detail_notes)
except Exception as e:
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")

View File

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

@@ -0,0 +1,56 @@
import json
from services.ollama_client import ollama_client
from services.utils import load_prompt, parse_json_response
from config import settings
CLASSIFY_PROMPT_PATH = "prompts/classify_issue.txt"
SUMMARIZE_PROMPT_PATH = "prompts/summarize_issue.txt"
async def classify_issue(description: str, detail_notes: str = "") -> dict:
template = load_prompt(CLASSIFY_PROMPT_PATH)
prompt = template.format(
description=description or "",
detail_notes=detail_notes or "",
)
raw = await ollama_client.generate_text(prompt)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(raw[start:end])
except json.JSONDecodeError:
pass
return {"raw_response": raw, "parse_error": True}
async def summarize_issue(
description: str, detail_notes: str = "", solution: str = ""
) -> dict:
template = load_prompt(SUMMARIZE_PROMPT_PATH)
prompt = template.format(
description=description or "",
detail_notes=detail_notes or "",
solution=solution or "",
)
raw = await ollama_client.generate_text(prompt)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(raw[start:end])
except json.JSONDecodeError:
pass
return {"summary": raw.strip()}
async def classify_and_summarize(
description: str, detail_notes: str = ""
) -> dict:
classification = await classify_issue(description, detail_notes)
summary_result = await summarize_issue(description, detail_notes)
return {
"classification": classification,
"summary": summary_result.get("summary", ""),
}

View File

@@ -0,0 +1,129 @@
from urllib.parse import quote_plus
from sqlalchemy import create_engine, text
from config import settings
def get_engine():
password = quote_plus(settings.DB_PASSWORD)
url = (
f"mysql+pymysql://{settings.DB_USER}:{password}"
f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
)
return create_engine(url, pool_pre_ping=True, pool_size=5)
engine = get_engine()
def get_all_issues() -> list[dict]:
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT id, category, description, detail_notes, "
"final_description, final_category, solution, "
"management_comment, cause_detail, project_id, "
"review_status, report_date, responsible_department, "
"location_info "
"FROM qc_issues ORDER BY id"
)
)
return [dict(row._mapping) for row in result]
def get_issue_by_id(issue_id: int) -> dict | None:
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT id, category, description, detail_notes, "
"final_description, final_category, solution, "
"management_comment, cause_detail, project_id, "
"review_status, report_date, responsible_department, "
"location_info "
"FROM qc_issues WHERE id = :id"
),
{"id": issue_id},
)
row = result.fetchone()
return dict(row._mapping) if row else None
def get_issues_since(last_id: int) -> list[dict]:
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT id, category, description, detail_notes, "
"final_description, final_category, solution, "
"management_comment, cause_detail, project_id, "
"review_status, report_date, responsible_department, "
"location_info "
"FROM qc_issues WHERE id > :last_id ORDER BY id"
),
{"last_id": last_id},
)
return [dict(row._mapping) for row in result]
def get_daily_qc_stats(date_str: str) -> dict:
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT "
" COUNT(*) as total, "
" SUM(CASE WHEN DATE(report_date) = :d THEN 1 ELSE 0 END) as new_today, "
" SUM(CASE WHEN review_status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, "
" SUM(CASE WHEN review_status = 'completed' THEN 1 ELSE 0 END) as completed, "
" SUM(CASE WHEN review_status = 'pending_review' THEN 1 ELSE 0 END) as pending "
"FROM qc_issues"
),
{"d": date_str},
)
row = result.fetchone()
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(
text(
"SELECT id, category, description, detail_notes, "
"review_status, responsible_department, solution "
"FROM qc_issues "
"WHERE DATE(report_date) = :d "
"ORDER BY id"
),
{"d": date_str},
)
return [dict(row._mapping) for row in result]

View File

@@ -0,0 +1,157 @@
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"):
parts.append(issue["final_description"])
if issue.get("detail_notes"):
parts.append(issue["detail_notes"])
if issue.get("solution"):
parts.append(f"해결: {issue['solution']}")
if issue.get("management_comment"):
parts.append(f"의견: {issue['management_comment']}")
if issue.get("cause_detail"):
parts.append(f"원인: {issue['cause_detail']}")
return " ".join(parts)
def build_metadata(issue: dict) -> dict:
meta = {"issue_id": issue["id"]}
for key in [
"category", "project_id", "review_status",
"responsible_department", "location_info",
]:
val = issue.get(key)
if val is not None:
meta[key] = str(val)
rd = issue.get("report_date")
if rd:
meta["report_date"] = str(rd)[:10]
meta["has_solution"] = "true" if issue.get("solution") else "false"
return meta
BATCH_SIZE = 10
async def _sync_issues_batch(issues: list[dict]) -> tuple[int, int]:
"""배치 단위로 임베딩 생성 후 벡터 스토어에 저장"""
synced = 0
skipped = 0
# 유효한 이슈와 텍스트 준비
valid = []
for issue in issues:
doc_text = build_document_text(issue)
if not doc_text.strip():
skipped += 1
continue
valid.append((issue, doc_text))
# 배치 단위로 임베딩 생성
for i in range(0, len(valid), BATCH_SIZE):
batch = valid[i:i + BATCH_SIZE]
texts = [doc_text for _, doc_text in batch]
try:
embeddings = await ollama_client.batch_embeddings(texts)
for (issue, doc_text), embedding in zip(batch, embeddings):
vector_store.upsert(
doc_id=f"issue_{issue['id']}",
document=doc_text,
embedding=embedding,
metadata=build_metadata(issue),
)
synced += 1
except Exception:
skipped += len(batch)
return synced, skipped
async def sync_all_issues() -> dict:
issues = get_all_issues()
synced, skipped = await _sync_issues_batch(issues)
if issues:
max_id = max(i["id"] for i in issues)
metadata_store.set_last_synced_id(max_id)
return {"synced": synced, "skipped": skipped, "total": len(issues)}
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"}
doc_text = build_document_text(issue)
if not doc_text.strip():
return {"status": "empty_text"}
embedding = await ollama_client.generate_embedding(doc_text)
vector_store.upsert(
doc_id=f"issue_{issue['id']}",
document=doc_text,
embedding=embedding,
metadata=build_metadata(issue),
)
return {"status": "synced", "issue_id": issue_id}
async def sync_incremental() -> dict:
last_id = metadata_store.get_last_synced_id()
issues = get_issues_since(last_id)
synced, skipped = await _sync_issues_batch(issues)
if issues:
max_id = max(i["id"] for i in issues)
metadata_store.set_last_synced_id(max_id)
return {"synced": synced, "skipped": skipped, "new_issues": len(issues)}
async def search_similar_by_id(issue_id: int, n_results: int = 5) -> list[dict]:
issue = get_issue_by_id(issue_id)
if not issue:
return []
doc_text = build_document_text(issue)
if not doc_text.strip():
return []
embedding = await ollama_client.generate_embedding(doc_text)
results = vector_store.query(
embedding=embedding,
n_results=n_results + 1,
)
# exclude self
filtered = []
for r in results:
if r["id"] != f"issue_{issue_id}":
filtered.append(r)
return filtered[:n_results]
async def search_similar_by_text(query: str, n_results: int = 5, filters: dict = None) -> list[dict]:
embedding = await ollama_client.generate_embedding(query)
where = None
if filters:
conditions = []
for k, v in filters.items():
if v is not None:
conditions.append({k: str(v)})
if len(conditions) == 1:
where = conditions[0]
elif len(conditions) > 1:
where = {"$and": conditions}
return vector_store.query(
embedding=embedding,
n_results=n_results,
where=where,
)

View File

@@ -0,0 +1,82 @@
import asyncio
import httpx
from config import settings
class OllamaClient:
def __init__(self):
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
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=self.timeout)
return self._client
async def close(self):
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def generate_embedding(self, text: str) -> list[float]:
client = await self._get_client()
response = await client.post(
f"{self.embed_url}/api/embeddings",
json={"model": settings.OLLAMA_EMBED_MODEL, "prompt": text},
)
response.raise_for_status()
return response.json()["embedding"]
async def batch_embeddings(self, texts: list[str], concurrency: int = 5) -> list[list[float]]:
semaphore = asyncio.Semaphore(concurrency)
async def _embed(text: str) -> list[float]:
async with semaphore:
return await self.generate_embedding(text)
return await asyncio.gather(*[_embed(t) for t in texts])
async def generate_text(self, prompt: str, system: str = None) -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
client = await self._get_client()
response = await client.post(
f"{self.text_url}/api/chat",
json={
"model": settings.OLLAMA_TEXT_MODEL,
"messages": messages,
"stream": False,
"think": False,
"options": {"temperature": 0.3, "num_predict": 2048},
},
)
response.raise_for_status()
return response.json()["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.text_url}/api/tags")
models = response.json().get("models", [])
result["ollama_text"] = {"status": "connected", "url": self.text_url, "models": [m["name"] for m in models]}
except Exception:
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"{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["ollama_embed"] = {"status": "disconnected", "url": self.embed_url}
return result
ollama_client = OllamaClient()

View File

@@ -0,0 +1,211 @@
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, 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:
return "관련 과거 사례가 없습니다."
lines = []
for i, r in enumerate(results, 1):
meta = r.get("metadata", {})
similarity = round(r.get("similarity", 0) * 100)
doc = (r.get("document", ""))[:500]
cat = meta.get("category", "")
dept = meta.get("responsible_department", "")
status = meta.get("review_status", "")
has_sol = meta.get("has_solution", "false")
date = meta.get("report_date", "")
issue_id = meta.get("issue_id", r["id"])
lines.append(
f"[사례 {i}] No.{issue_id} (유사도 {similarity}%)\n"
f" 분류: {cat} | 부서: {dept} | 상태: {status} | 날짜: {date} | 해결여부: {'O' if has_sol == 'true' else 'X'}\n"
f" 내용: {doc}"
)
return "\n\n".join(lines)
async def rag_suggest_solution(issue_id: int) -> dict:
"""과거 유사 이슈의 해결 사례를 참고하여 해결방안을 제안"""
issue = get_issue_by_id(issue_id)
if not issue:
return {"available": False, "error": "이슈를 찾을 수 없습니다"}
doc_text = build_document_text(issue)
if not doc_text.strip():
return {"available": False, "error": "이슈 내용이 비어있습니다"}
# 해결 완료된 유사 이슈 검색
similar = await search_similar_by_text(
doc_text, n_results=5, filters={"has_solution": "true"}
)
# 해결 안 된 것도 포함 (참고용)
if len(similar) < 3:
all_similar = await search_similar_by_text(doc_text, n_results=5)
seen = {r["id"] for r in similar}
for r in all_similar:
if r["id"] not in seen:
similar.append(r)
if len(similar) >= 5:
break
context = _format_retrieved_issues(similar)
template = load_prompt("prompts/rag_suggest_solution.txt")
prompt = template.format(
description=issue.get("description", ""),
detail_notes=issue.get("detail_notes", ""),
category=issue.get("category", ""),
retrieved_cases=context,
)
response = await ollama_client.generate_text(prompt)
return {
"available": True,
"issue_id": issue_id,
"suggestion": response,
"referenced_issues": [
{
"id": r.get("metadata", {}).get("issue_id", r["id"]),
"similarity": round(r.get("similarity", 0) * 100),
"has_solution": r.get("metadata", {}).get("has_solution", "false") == "true",
}
for r in similar
],
}
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,
)
response = await ollama_client.generate_text(prompt)
return {
"available": True,
"answer": response,
"sources": [
{
"id": r.get("metadata", {}).get("issue_id", r["id"]),
"similarity": round(r.get("similarity", 0) * 100),
"snippet": (r.get("document", ""))[:100],
}
for r in results
],
}
async def rag_analyze_pattern(description: str, n_results: int = 10) -> dict:
"""유사 부적합 패턴 분석 — 반복되는 문제인지, 근본 원인은 무엇인지"""
results = await search_similar_by_text(description, n_results=n_results)
context = _format_retrieved_issues(results)
template = load_prompt("prompts/rag_pattern.txt")
prompt = template.format(
description=description,
retrieved_cases=context,
total_similar=len(results),
)
response = await ollama_client.generate_text(prompt)
return {
"available": True,
"analysis": response,
"similar_count": len(results),
"sources": [
{
"id": r.get("metadata", {}).get("issue_id", r["id"]),
"similarity": round(r.get("similarity", 0) * 100),
"category": r.get("metadata", {}).get("category", ""),
}
for r in results
],
}
async def rag_classify_with_context(description: str, detail_notes: str = "") -> dict:
"""과거 사례를 참고하여 더 정확한 분류 수행 (기존 classify 강화)"""
query = f"{description} {detail_notes}".strip()
similar = await search_similar_by_text(query, n_results=5)
context = _format_retrieved_issues(similar)
template = load_prompt("prompts/rag_classify.txt")
prompt = template.format(
description=description,
detail_notes=detail_notes,
retrieved_cases=context,
)
raw = await ollama_client.generate_text(prompt)
import json
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
result = json.loads(raw[start:end])
result["rag_enhanced"] = True
result["referenced_count"] = len(similar)
return {"available": True, **result}
except json.JSONDecodeError:
pass
return {"available": True, "raw_response": raw, "rag_enhanced": True}

View File

@@ -0,0 +1,102 @@
import asyncio
import httpx
from services.ollama_client import ollama_client
from services.db_client import get_daily_qc_stats, get_issues_for_date
from services.utils import load_prompt
from config import settings
REPORT_PROMPT_PATH = "prompts/daily_report.txt"
async def _fetch_one(client: httpx.AsyncClient, url: str, params: dict, headers: dict):
try:
r = await client.get(url, params=params, headers=headers)
if r.status_code == 200:
return r.json()
except Exception:
pass
return None
async def _fetch_system1_data(date_str: str, token: str) -> dict:
headers = {"Authorization": f"Bearer {token}"}
params = {"date": date_str}
base = settings.SYSTEM1_API_URL
try:
async with httpx.AsyncClient(timeout=15.0) as client:
attendance, work_reports, patrol = await asyncio.gather(
_fetch_one(client, f"{base}/api/attendance/daily-status", params, headers),
_fetch_one(client, f"{base}/api/daily-work-reports/summary", params, headers),
_fetch_one(client, f"{base}/api/patrol/today-status", params, headers),
)
except Exception:
attendance = work_reports = patrol = None
return {"attendance": attendance, "work_reports": work_reports, "patrol": patrol}
def _format_attendance(data) -> str:
if not data:
return "데이터 없음"
if isinstance(data, dict):
parts = []
for k, v in data.items():
parts.append(f" {k}: {v}")
return "\n".join(parts)
return str(data)
def _format_work_reports(data) -> str:
if not data:
return "데이터 없음"
return str(data)
def _format_qc_issues(issues: list[dict], stats: dict) -> str:
lines = []
lines.append(f"전체: {stats.get('total', 0)}")
lines.append(f"금일 신규: {stats.get('new_today', 0)}")
lines.append(f"진행중: {stats.get('in_progress', 0)}")
lines.append(f"완료: {stats.get('completed', 0)}")
lines.append(f"미검토: {stats.get('pending', 0)}")
if issues:
lines.append("\n금일 신규 이슈:")
for iss in issues[:10]:
cat = iss.get("category", "")
desc = (iss.get("description") or "")[:50]
status = iss.get("review_status", "")
lines.append(f" - [{cat}] {desc} (상태: {status})")
return "\n".join(lines)
def _format_patrol(data) -> str:
if not data:
return "데이터 없음"
return str(data)
async def generate_daily_report(
date_str: str, project_id: int = None, token: str = ""
) -> dict:
system1_data = await _fetch_system1_data(date_str, token)
qc_stats = get_daily_qc_stats(date_str)
qc_issues = get_issues_for_date(date_str)
template = load_prompt(REPORT_PROMPT_PATH)
prompt = template.format(
date=date_str,
attendance_data=_format_attendance(system1_data["attendance"]),
work_report_data=_format_work_reports(system1_data["work_reports"]),
qc_issue_data=_format_qc_issues(qc_issues, qc_stats),
patrol_data=_format_patrol(system1_data["patrol"]),
)
report_text = await ollama_client.generate_text(prompt)
return {
"date": date_str,
"report": report_text,
"stats": {
"qc": qc_stats,
"new_issues_count": len(qc_issues),
},
}

View File

@@ -0,0 +1,22 @@
import json
import os
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def load_prompt(path: str) -> str:
full_path = os.path.join(_BASE_DIR, path)
with open(full_path, "r", encoding="utf-8") as f:
return f.read()
def parse_json_response(raw: str) -> dict:
"""LLM 응답에서 JSON을 추출합니다."""
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
return {}
try:
return json.loads(raw[start:end])
except json.JSONDecodeError:
return {}

View File

@@ -19,36 +19,14 @@ 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"]
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
timeout: 20s
retries: 10
networks:
- tk-network
postgres:
image: postgres:15-alpine
container_name: tk-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-mproject}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-mproject}
TZ: Asia/Seoul
PGTZ: Asia/Seoul
volumes:
- postgres_data:/var/lib/postgresql/data
- ./system3-nonconformance/api/migrations:/docker-entrypoint-initdb.d
ports:
- "30432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mproject}"]
timeout: 10s
retries: 5
networks:
- tk-network
redis:
image: redis:6-alpine
container_name: tk-redis
@@ -88,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
@@ -102,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:
@@ -125,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
@@ -147,7 +138,10 @@ services:
volumes:
- ./system1-factory/web:/usr/share/nginx/html:ro
depends_on:
- system1-api
system1-api:
condition: service_healthy
sso-auth:
condition: service_healthy
networks:
- tk-network
@@ -161,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
@@ -172,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:
@@ -194,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
@@ -214,7 +222,8 @@ services:
ports:
- "30180:80"
depends_on:
- system2-api
system2-api:
condition: service_healthy
networks:
- tk-network
@@ -242,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:
@@ -261,7 +276,8 @@ services:
volumes:
- system3_uploads:/usr/share/nginx/html/uploads
depends_on:
- system3-api
system3-api:
condition: service_healthy
networks:
- tk-network
@@ -271,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:
@@ -286,6 +302,20 @@ 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}
- 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:
@@ -303,12 +333,254 @@ services:
ports:
- "30380:80"
depends_on:
- tkuser-api
tkuser-api:
condition: service_healthy
networks:
- tk-network
# =================================================================
# Gateway
# Purchase Management (tkpurchase)
# =================================================================
tkpurchase-api:
build:
context: .
dockerfile: tkpurchase/api/Dockerfile
container_name: tk-tkpurchase-api
restart: unless-stopped
ports:
- "30400: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
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
# =================================================================
# 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:
@@ -320,10 +592,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
@@ -336,7 +608,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}
@@ -361,8 +633,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
@@ -370,18 +648,19 @@ volumes:
mariadb_data:
external: true
name: tkfb-package_db_data
postgres_data:
external: true
name: tkqc-package_postgres_data
system1_uploads:
external: true
name: tkfb_api_uploads
system1_logs:
system2_uploads:
system2_logs:
tksafety_uploads:
system3_uploads:
external: true
name: tkqc-package_uploads
tkeg_postgres_data:
tkeg_uploads:
ntfy_cache:
networks:
tk-network:
driver: bridge

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 방지: 상대 경로 또는 같은 도메인만 허용
if (redirect && (redirect.startsWith('/') && !redirect.startsWith('//')) && !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');
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('sso_refresh_token');
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; }
},
@@ -48,10 +48,10 @@
cookieRemove('sso_token');
cookieRemove('sso_user');
cookieRemove('sso_refresh_token');
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('sso_refresh_token');
window.location.href = this.getLoginUrl();
['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) + '&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,34 +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;
}
# ===== 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,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 브랜치로 복귀합니다."

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

@@ -6,12 +6,16 @@
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel');
const redis = require('../utils/redis');
const JWT_SECRET = process.env.SSO_JWT_SECRET;
const JWT_EXPIRES_IN = process.env.SSO_JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.SSO_JWT_REFRESH_SECRET;
const JWT_REFRESH_EXPIRES_IN = process.env.SSO_JWT_REFRESH_EXPIRES_IN || '30d';
const MAX_LOGIN_ATTEMPTS = 5;
const LOGIN_LOCKOUT_SECONDS = 300; // 5분
/**
* JWT 토큰 페이로드 생성 (모든 시스템 공통 구조)
*/
@@ -28,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,
@@ -47,24 +52,42 @@ async function login(req, res, next) {
return res.status(400).json({ success: false, error: '사용자명과 비밀번호를 입력하세요' });
}
// 로그인 시도 횟수 확인
const attemptKey = `login_attempts:${username}`;
const attempts = parseInt(await redis.get(attemptKey)) || 0;
if (attempts >= MAX_LOGIN_ATTEMPTS) {
return res.status(429).json({ success: false, error: '로그인 시도 횟수를 초과했습니다. 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({ 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);
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
}
// 로그인 성공 시 시도 횟수 초기화
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' });
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으로 설정
@@ -104,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,
@@ -145,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: '유효하지 않은 사용자입니다' });
@@ -187,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' });
@@ -219,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 토큰입니다' });
}
@@ -230,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({
@@ -323,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 토큰 또는 쿠키에서 토큰 추출
*/
@@ -345,5 +447,7 @@ module.exports = {
getUsers,
createUser,
updateUser,
deleteUser
deleteUser,
changePassword,
checkPasswordStrength
};

View File

@@ -10,12 +10,29 @@
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/authRoutes');
const { initRedis } = require('./utils/redis');
const app = express();
const PORT = process.env.PORT || 3000;
const allowedOrigins = [
'https://tkfb.technicalkorea.net',
'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: true,
origin: function(origin, cb) {
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
}));
app.use(express.json());
@@ -42,7 +59,8 @@ app.use((err, req, res, next) => {
});
});
app.listen(PORT, () => {
app.listen(PORT, async () => {
await initRedis();
console.log(`SSO Auth Service running on port ${PORT}`);
});

1246
sso-auth-service/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"mysql2": "^3.14.1"
"mysql2": "^3.14.1",
"redis": "^4.6.0"
}
}

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

@@ -0,0 +1,85 @@
/**
* Redis 클라이언트 (로그인 시도 제한, 토큰 블랙리스트)
*
* Redis 미연결 시 메모리 폴백으로 동작
*/
const { createClient } = require('redis');
const REDIS_HOST = process.env.REDIS_HOST || 'redis';
const REDIS_PORT = process.env.REDIS_PORT || 6379;
let client = null;
let connected = false;
// 메모리 폴백 (Redis 미연결 시)
const memoryStore = new Map();
async function initRedis() {
try {
client = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}` });
client.on('error', () => { connected = false; });
client.on('connect', () => { connected = true; });
await client.connect();
console.log('Redis 연결 성공');
} catch {
console.warn('Redis 연결 실패 - 메모리 폴백 사용');
connected = false;
}
}
async function get(key) {
if (connected) {
return await client.get(key);
}
const entry = memoryStore.get(key);
if (!entry) return null;
if (entry.expiry && entry.expiry < Date.now()) {
memoryStore.delete(key);
return null;
}
return entry.value;
}
async function set(key, value, ttlSeconds) {
if (connected) {
await client.set(key, value, { EX: ttlSeconds });
} else {
memoryStore.set(key, {
value,
expiry: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
});
}
}
async function del(key) {
if (connected) {
await client.del(key);
} else {
memoryStore.delete(key);
}
}
async function incr(key) {
if (connected) {
return await client.incr(key);
}
const entry = memoryStore.get(key);
const current = entry ? parseInt(entry.value) || 0 : 0;
const next = current + 1;
memoryStore.set(key, { value: String(next), expiry: entry?.expiry || null });
return next;
}
async function expire(key, ttlSeconds) {
if (connected) {
await client.expire(key, ttlSeconds);
} else {
const entry = memoryStore.get(key);
if (entry) {
entry.expiry = Date.now() + ttlSeconds * 1000;
}
}
}
module.exports = { initRedis, get, set, del, incr, expire };

View File

@@ -1,33 +1,30 @@
# Node.js 공식 이미지 사용
FROM node:18-alpine
# 작업 디렉토리 설정
WORKDIR /usr/src/app
# 패키지 파일 복사 (캐싱 최적화)
COPY package*.json ./
# shared 모듈 복사
COPY shared/ ./shared/
# 프로덕션 의존성 설치
RUN npm install --omit=dev
# 패키지 파일 복사 + 프로덕션 의존성 설치 (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"]
CMD ["node", "index.js"]

View File

@@ -13,9 +13,14 @@ const logger = require('../utils/logger');
* 허용된 Origin 목록
*/
const allowedOrigins = [
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'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

@@ -64,12 +64,7 @@ function setupMiddlewares(app) {
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false,
// 인증된 사용자는 더 많은 요청 허용
skip: (req) => {
// Authorization 헤더가 있으면 Rate Limit 완화
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
}
legacyHeaders: false
});
// 로그인 시도 제한 (브루트포스 방지)

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

@@ -33,7 +33,7 @@ const login = asyncHandler(async (req, res) => {
// ✅ 사용자 등록 기능 추가
const register = async (req, res) => {
try {
const { username, password, name, access_level, worker_id } = req.body;
const { username, password, name, access_level } = req.body;
const db = await getDb();
// 필수 필드 검증
@@ -72,9 +72,9 @@ const register = async (req, res) => {
// 사용자 등록
const [result] = await db.query(
`INSERT INTO users (username, password, name, role, access_level, worker_id)
VALUES (?, ?, ?, ?, ?, ?)`,
[username, hashedPassword, name, role, access_level, worker_id]
`INSERT INTO users (username, password, name, role, access_level)
VALUES (?, ?, ?, ?, ?)`,
[username, hashedPassword, name, role, access_level]
);
console.log('[사용자 등록 성공]', username);
@@ -141,8 +141,8 @@ const getAllUsers = async (req, res) => {
// 비밀번호 제외하고 조회
const [rows] = await db.query(
`SELECT user_id, username, name, role, access_level, worker_id, created_at
FROM users
`SELECT user_id, username, name, role, access_level, created_at
FROM users
ORDER BY created_at DESC`
);

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

@@ -243,21 +243,20 @@ exports.getAllUsers = asyncHandler(async (req, res) => {
const db = await getDb();
const [users] = await db.query(`
SELECT
SELECT
user_id,
username,
name,
email,
role,
access_level,
worker_id,
is_active,
last_login_at,
failed_login_attempts,
locked_until,
created_at,
updated_at
FROM users
FROM users
ORDER BY created_at DESC
`);
@@ -272,20 +271,20 @@ exports.getAllUsers = asyncHandler(async (req, res) => {
* 사용자 생성
*/
exports.createUser = asyncHandler(async (req, res) => {
const { username, password, name, email, role, access_level, worker_id } = req.body;
const { username, password, name, email, role, access_level } = req.body;
// 스키마 기반 유효성 검사
validateSchema(req.body, schemas.createUser);
try {
const db = await getDb();
// 사용자명 중복 확인
const [existing] = await db.query('SELECT user_id FROM users WHERE username = ?', [username]);
if (existing.length > 0) {
throw new ApiError('이미 존재하는 사용자명입니다.', 409);
}
// 이메일 중복 확인 (이메일이 제공된 경우)
if (email) {
const [existingEmail] = await db.query('SELECT user_id FROM users WHERE email = ?', [email]);
@@ -293,15 +292,15 @@ exports.createUser = asyncHandler(async (req, res) => {
throw new ApiError('이미 사용 중인 이메일입니다.', 409);
}
}
// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const [result] = await db.query(`
INSERT INTO users (username, password, name, email, role, access_level, worker_id, is_active, created_at, password_changed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW())
`, [username, hashedPassword, name, email || null, role, access_level || role, worker_id || null]);
INSERT INTO users (username, password, name, email, role, access_level, is_active, created_at, password_changed_at)
VALUES (?, ?, ?, ?, ?, ?, 1, NOW(), NOW())
`, [username, hashedPassword, name, email || null, role, access_level || role]);
// 비밀번호 변경 로그 기록
await db.query(`
@@ -322,9 +321,9 @@ exports.createUser = asyncHandler(async (req, res) => {
exports.updateUser = async (req, res) => {
try {
const { id } = req.params;
const { name, email, role, access_level, is_active, worker_id } = req.body;
const { name, email, role, access_level, is_active } = req.body;
const db = await getDb();
// 사용자 존재 확인
const [user] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [id]);
if (user.length === 0) {
@@ -333,11 +332,11 @@ exports.updateUser = async (req, res) => {
error: '해당 사용자를 찾을 수 없습니다.'
});
}
// 이메일 중복 확인 (다른 사용자가 사용 중인지)
if (email) {
const [existingEmail] = await db.query(
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, id]
);
if (existingEmail.length > 0) {
@@ -347,13 +346,13 @@ exports.updateUser = async (req, res) => {
});
}
}
// 사용자 정보 업데이트
await db.query(`
UPDATE users
SET name = ?, email = ?, role = ?, access_level = ?, is_active = ?, worker_id = ?, updated_at = NOW()
UPDATE users
SET name = ?, email = ?, role = ?, access_level = ?, is_active = ?, updated_at = NOW()
WHERE user_id = ?
`, [name, email || null, role, access_level || role, is_active ? 1 : 0, worker_id || null, id]);
`, [name, email || null, role, access_level || role, is_active ? 1 : 0, id]);
res.json({
success: true,

View File

@@ -194,6 +194,32 @@ const TbmController = {
return res.status(400).json({ success: false, message: '팀원 목록이 필요합니다.' });
}
// 중복 배정 검증
const sessionRows = await TbmModel.getSessionById(sessionId);
if (sessionRows.length > 0) {
const sessionDate = sessionRows[0].session_date;
let dateStr;
if (sessionDate instanceof Date) {
dateStr = sessionDate.toISOString().split('T')[0];
} else if (typeof sessionDate === 'string') {
dateStr = sessionDate.split('T')[0];
} else {
dateStr = new Date(sessionDate).toISOString().split('T')[0];
}
const userIds = members.map(m => m.user_id);
const duplicates = await TbmModel.checkDuplicateAssignments(dateStr, userIds, sessionId);
if (duplicates.length > 0) {
const names = duplicates.map(d => d.worker_name).join(', ');
return res.status(409).json({
success: false,
message: `다음 작업자가 이미 다른 TBM에 배정되어 있습니다: ${names}`,
duplicates: duplicates
});
}
}
await TbmModel.addTeamMembers(sessionId, members);
res.json({
success: true,

View File

@@ -43,7 +43,6 @@ const getAllUsers = asyncHandler(async (req, res) => {
r.name as role,
u._access_level_old as access_level,
u.is_active,
u.worker_id,
w.worker_name,
w.department_id,
d.department_name,
@@ -52,7 +51,7 @@ const getAllUsers = asyncHandler(async (req, res) => {
u.last_login_at as last_login
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN workers w ON u.worker_id = w.worker_id
LEFT JOIN workers w ON w.user_id = u.user_id
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY u.created_at DESC
`;
@@ -224,7 +223,7 @@ const updateUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { username, name, email, role, role_id, password, worker_id } = req.body;
const { username, name, email, role, role_id, password } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
@@ -233,7 +232,7 @@ const updateUser = asyncHandler(async (req, res) => {
logger.info('사용자 수정 요청', { userId: id, body: req.body });
// 최소 하나의 수정 필드가 필요
if (!username && !name && email === undefined && !role && !role_id && !password && worker_id === undefined) {
if (!username && !name && email === undefined && !role && !role_id && !password) {
throw new ValidationError('수정할 필드가 없습니다');
}
@@ -324,22 +323,6 @@ const updateUser = asyncHandler(async (req, res) => {
values.push(hashedPassword);
}
// worker_id 업데이트 (null도 허용 - 연결 해제)
if (worker_id !== undefined) {
if (worker_id !== null) {
// worker_id가 유효한지 확인
const [workerCheck] = await db.execute('SELECT worker_id, worker_name FROM workers WHERE worker_id = ?', [worker_id]);
if (workerCheck.length === 0) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
logger.info('작업자 연결', { userId: id, worker_id, worker_name: workerCheck[0].worker_name });
} else {
logger.info('작업자 연결 해제', { userId: id });
}
updates.push('worker_id = ?');
values.push(worker_id);
}
updates.push('updated_at = NOW()');
values.push(id);
@@ -699,8 +682,7 @@ const resetUserPassword = asyncHandler(async (req, res) => {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
// 비밀번호를 000000으로 초기화
const hashedPassword = await bcrypt.hash('000000', 10);
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
await db.execute(
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
[hashedPassword, id]

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

@@ -21,37 +21,32 @@ const getAnalysisFilters = asyncHandler(async (req, res) => {
const db = await getDb();
try {
// 프로젝트 목록
const [projects] = await db.query(`
SELECT DISTINCT p.project_id, p.project_name
FROM projects p
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
ORDER BY p.project_name
`);
// 작업자 목록
const [workers] = await db.query(`
SELECT DISTINCT w.user_id, w.worker_name
FROM workers w
INNER JOIN daily_work_reports dwr ON w.user_id = dwr.user_id
ORDER BY w.worker_name
`);
// 작업 유형 목록
const [workTypes] = await db.query(`
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
FROM work_types wt
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
ORDER BY wt.name
`);
// 날짜 범위
const [dateRange] = await db.query(`
SELECT
MIN(report_date) as min_date,
MAX(report_date) as max_date
FROM daily_work_reports
`);
const [[projects], [workers], [workTypes], [dateRange]] = await Promise.all([
db.query(`
SELECT DISTINCT p.project_id, p.project_name
FROM projects p
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
ORDER BY p.project_name
`),
db.query(`
SELECT DISTINCT w.user_id, w.worker_name
FROM workers w
INNER JOIN daily_work_reports dwr ON w.user_id = dwr.user_id
ORDER BY w.worker_name
`),
db.query(`
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
FROM work_types wt
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
ORDER BY wt.name
`),
db.query(`
SELECT
MIN(report_date) as min_date,
MAX(report_date) as max_date
FROM daily_work_reports
`),
]);
logger.info('분석 필터 데이터 조회 성공', {
projects: projects.length,
@@ -131,115 +126,108 @@ const getAnalyticsByPeriod = asyncHandler(async (req, res) => {
WHERE ${whereClause}
`;
const [overallStats] = await db.query(overallSql, queryParams);
// 2. 일별 통계
const dailyStatsSql = `
SELECT
dwr.report_date,
SUM(dwr.work_hours) as daily_hours,
COUNT(*) as daily_entries,
COUNT(DISTINCT dwr.user_id) as daily_workers
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`;
const [dailyStats] = await db.query(dailyStatsSql, queryParams);
// 3. 일별 에러 통계
const dailyErrorStatsSql = `
SELECT
dwr.report_date,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
COUNT(*) as daily_total,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as daily_error_rate
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`;
const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams);
// 4. 에러 유형별 분석
const errorAnalysisSql = `
SELECT
et.id as error_type_id,
et.name as error_type_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as error_hours,
ROUND((COUNT(*) / (SELECT COUNT(*) FROM daily_work_reports WHERE error_type_id IS NOT NULL)) * 100, 2) as error_percentage
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
WHERE ${whereClause} AND dwr.error_type_id IS NOT NULL
GROUP BY et.id, et.name
ORDER BY error_count DESC
`;
const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams);
// 5. 작업 유형별 분석
const workTypeAnalysisSql = `
SELECT
wt.id as work_type_id,
wt.name as work_type_name,
COUNT(*) as work_count,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE ${whereClause}
GROUP BY wt.id, wt.name
ORDER BY total_hours DESC
`;
const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams);
// 6. 작업자별 성과 분석
const workerAnalysisSql = `
SELECT
w.user_id,
w.worker_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(DISTINCT dwr.project_id) as projects_worked,
COUNT(DISTINCT dwr.report_date) as working_days,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.user_id = w.user_id
WHERE ${whereClause}
GROUP BY w.user_id, w.worker_name
ORDER BY total_hours DESC
`;
const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams);
// 7. 프로젝트별 분석
const projectAnalysisSql = `
SELECT
p.project_id,
p.project_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.user_id) as workers_count,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
WHERE ${whereClause}
GROUP BY p.project_id, p.project_name
ORDER BY total_hours DESC
`;
const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams);
const [
[overallStats],
[dailyStats],
[dailyErrorStats],
[errorAnalysis],
[workTypeAnalysis],
[workerAnalysis],
[projectAnalysis],
] = await Promise.all([
// 1. 전체 요약 통계
db.query(overallSql, queryParams),
// 2. 일별 통계
db.query(`
SELECT
dwr.report_date,
SUM(dwr.work_hours) as daily_hours,
COUNT(*) as daily_entries,
COUNT(DISTINCT dwr.user_id) as daily_workers
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`, queryParams),
// 3. 일별 에러 통계
db.query(`
SELECT
dwr.report_date,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
COUNT(*) as daily_total,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as daily_error_rate
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`, queryParams),
// 4. 에러 유형별 분석
db.query(`
SELECT
et.id as error_type_id,
et.name as error_type_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as error_hours,
ROUND((COUNT(*) / (SELECT COUNT(*) FROM daily_work_reports WHERE error_type_id IS NOT NULL)) * 100, 2) as error_percentage
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
WHERE ${whereClause} AND dwr.error_type_id IS NOT NULL
GROUP BY et.id, et.name
ORDER BY error_count DESC
`, queryParams),
// 5. 작업 유형별 분석
db.query(`
SELECT
wt.id as work_type_id,
wt.name as work_type_name,
COUNT(*) as work_count,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE ${whereClause}
GROUP BY wt.id, wt.name
ORDER BY total_hours DESC
`, queryParams),
// 6. 작업자별 성과 분석
db.query(`
SELECT
w.user_id,
w.worker_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(DISTINCT dwr.project_id) as projects_worked,
COUNT(DISTINCT dwr.report_date) as working_days,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.user_id = w.user_id
WHERE ${whereClause}
GROUP BY w.user_id, w.worker_name
ORDER BY total_hours DESC
`, queryParams),
// 7. 프로젝트별 분석
db.query(`
SELECT
p.project_id,
p.project_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.user_id) as workers_count,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
WHERE ${whereClause}
GROUP BY p.project_id, p.project_name
ORDER BY total_hours DESC
`, queryParams),
]);
logger.info('기간별 분석 데이터 조회 성공', {
start_date,

View File

@@ -28,24 +28,28 @@ exports.createWorker = asyncHandler(async (req, res) => {
const lastID = await workerModel.create(workerData);
// 계정 생성 요청이 있으면 users 테이블에 계정 생성
// 계정 생성 요청이 있으면 users 테이블에 계정 생성 + workers.user_id 연결
if (createAccount && workerData.worker_name) {
try {
const db = await getDb();
const username = await generateUniqueUsername(workerData.worker_name, db);
const hashedPassword = await bcrypt.hash('1234', 10);
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
// User 역할 조회
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
if (userRole && userRole.length > 0) {
await db.query(
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, lastID, userRole[0].id]
const [insertResult] = await db.query(
`INSERT INTO users (username, password, name, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, userRole[0].id]
);
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, username });
// workers.user_id 연결
const newUserId = insertResult.insertId;
await db.query('UPDATE workers SET user_id = ? WHERE worker_id = ?', [newUserId, lastID]);
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, user_id: newUserId, username });
}
} catch (accountError) {
logger.error('계정 생성 실패 (작업자는 생성됨)', { worker_id: lastID, error: accountError.message });
@@ -109,7 +113,7 @@ exports.getWorkerById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const row = await workerModel.getById(id);
const row = await workerModel.getByUserId(id);
if (!row) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
@@ -132,18 +136,11 @@ exports.updateWorker = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const workerData = { ...req.body, worker_id: id };
const workerData = { ...req.body, user_id: id };
const createAccount = req.body.create_account;
console.log('🔧 작업자 수정 요청:', {
worker_id: id,
받은데이터: req.body,
처리할데이터: workerData,
create_account: createAccount
});
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
const currentWorker = await workerModel.getById(id);
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용, user_id 기준)
const currentWorker = await workerModel.getByUserId(id);
if (!currentWorker) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
@@ -158,61 +155,43 @@ exports.updateWorker = asyncHandler(async (req, res) => {
let accountAction = null;
let accountUsername = null;
console.log('🔍 계정 생성 체크:', {
createAccount,
hasAccount,
currentWorker_user_id: currentWorker.user_id,
worker_name: workerData.worker_name
});
if (createAccount && !hasAccount && workerData.worker_name) {
// 계정 생성
console.log('✅ 계정 생성 로직 시작');
try {
console.log('🔑 사용자명 생성 중...');
const username = await generateUniqueUsername(workerData.worker_name, db);
console.log('🔑 생성된 사용자명:', username);
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
const hashedPassword = await bcrypt.hash('1234', 10);
console.log('🔒 비밀번호 해싱 완료');
// User 역할 조회
console.log('👤 User 역할 조회 중...');
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
console.log('👤 User 역할 조회 결과:', userRole);
if (userRole && userRole.length > 0) {
console.log('💾 계정 DB 삽입 시작...');
await db.query(
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
const [insertResult] = await db.query(
`INSERT INTO users (username, password, name, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, userRole[0].id]
);
console.log('✅ 계정 DB 삽입 완료');
// workers.user_id 연결
const newUserId = insertResult.insertId;
await db.query('UPDATE workers SET user_id = ? WHERE user_id = ?', [newUserId, id]);
accountAction = 'created';
accountUsername = username;
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
} else {
console.log('❌ User 역할을 찾을 수 없음');
logger.info('작업자 계정 생성 성공', { user_id: id, new_user_id: newUserId, username });
}
} catch (accountError) {
console.error('계정 생성 오류:', accountError);
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
logger.error('계정 생성 실패', { user_id: id, error: accountError.message });
accountAction = 'failed';
}
} else {
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
}
if (!createAccount && hasAccount) {
// 계정 연동 해제 (users.worker_id = NULL)
// 계정 연동 해제 (workers.user_id = NULL)
try {
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
await db.query('UPDATE workers SET user_id = NULL WHERE user_id = ?', [id]);
accountAction = 'unlinked';
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
logger.info('작업자 계정 연동 해제 성공', { user_id: id });
} catch (unlinkError) {
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
logger.error('계정 연동 해제 실패', { user_id: id, error: unlinkError.message });
accountAction = 'unlink_failed';
}
} else if (createAccount && hasAccount) {
@@ -220,10 +199,10 @@ exports.updateWorker = asyncHandler(async (req, res) => {
}
// 작업자 관련 캐시 무효화
logger.info('작업자 수정 후 캐시 무효화', { worker_id: id });
logger.info('작업자 수정 후 캐시 무효화', { user_id: id });
await cache.invalidateCache.worker();
logger.info('작업자 수정 성공', { worker_id: id });
logger.info('작업자 수정 성공', { user_id: id });
// 응답 메시지 구성
let message = '작업자 정보가 성공적으로 수정되었습니다';
@@ -265,11 +244,11 @@ exports.removeWorker = asyncHandler(async (req, res) => {
}
// 작업자 관련 캐시 무효화
logger.info('작업자 삭제 후 캐시 무효화 시작', { worker_id: id });
logger.info('작업자 삭제 후 캐시 무효화 시작', { user_id: id });
await cache.invalidateCache.worker();
await cache.delPattern('workers:*');
await cache.flush();
logger.info('작업자 삭제 후 캐시 무효화 완료', { worker_id: id });
logger.info('작업자 삭제 후 캐시 무효화 완료', { user_id: id });
res.json({
success: true,

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