Compare commits

..

158 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:51:08 +09:00
Hyungi Ahn
1ceeef2a65 refactor(tkeg): print→logging 교체 + 레거시 파일 정리 (-5,447줄)
- 6개 파일 디버그 print문 102건 → logger.info/warning/error 교체
- DashboardPage.old.jsx 삭제 (미사용)
- NewMaterialsPage.jsx/css 삭제 (dead import)
- spool_manager_v2.py 삭제 (미사용)
- App.jsx dead import 제거
- main.py 구 주석 블록 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:41:39 +09:00
Hyungi Ahn
d6dd03a52f feat(schedule): 공정표 제품유형 + 표준공정 자동생성 백엔드
- product_types 참조 테이블 + projects.product_type_id FK (tkuser 마이그레이션)
- schedule_entries에 work_type_id, risk_assessment_id, source 컬럼 추가
- schedule_phases에 product_type_id 추가 (phase 오염 방지)
- generateFromTemplate: tksafety 템플릿 기반 공정 자동 생성 (트랜잭션)
- phase 매칭 3단계 우선순위 (전용→범용→신규)
- 간트 데이터 NULL 날짜 guard 추가
- system1 startup 마이그레이션 러너 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:39:12 +09:00
440 changed files with 13093 additions and 7424 deletions

View File

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

View File

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

View File

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

5
.githooks/pre-commit Executable file
View File

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

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

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

24
.securityignore Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

39
SECURITY-CHECKLIST.md Normal file
View File

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

View File

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

View File

@@ -2,6 +2,13 @@ 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 도우미입니다.
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
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_text}"
사용자 입력:
<user_input>{safe_text}</user_input>
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
async def summarize_report(data: dict) -> dict:
"""최종 신고 내용을 요약"""
prompt = f"""신고 정보:
- 설명: {data.get('description', '')}
- 유형: {data.get('type', '')}
- 카테고리: {data.get('category', '')}
- 항목: {data.get('item', '')}
- 위치: {data.get('location', '')}
- 프로젝트: {data.get('project', '')}
<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으로 응답하세요."""

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -9,13 +9,14 @@ const notifyHelper = {
/**
* 알림 전송
* @param {Object} opts
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system)
* @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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ const logger = require('../utils/logger');
*/
const allowedOrigins = [
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
'https://tkds.technicalkorea.net', // Gateway/Dashboard
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'https://tkuser.technicalkorea.net', // User Management
@@ -50,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')) {
@@ -64,9 +70,9 @@ const corsOptions = {
return callback(null, true);
}
// 차단
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
logger.warn('CORS: 차단된 Origin', { origin });
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
callback(null, false);
},
/**

View File

@@ -53,8 +53,14 @@ function setupRoutes(app) {
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const 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');
@@ -163,8 +169,14 @@ function setupRoutes(app) {
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,16 @@ const PurchaseRequestModel = require('../models/purchaseRequestModel');
const PurchaseModel = require('../models/purchaseModel');
const { 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 } = req.query;
const { status, category, from_date, to_date, batch_id } = req.query;
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
const filters = { status, category, from_date, to_date };
const filters = { status, category, from_date, to_date, batch_id };
if (!isAdmin) filters.requester_id = req.user.id;
const rows = await PurchaseRequestModel.getAll(filters);
res.json({ success: true, data: rows });
@@ -113,6 +115,188 @@ const PurchaseRequestController = {
}
},
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
registerAndRequest: async (req, res) => {
const { getDb } = require('../dbPool');
let conn;
try {
const { item_name, spec, maker, category, quantity, notes, photo } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' });
}
if (!quantity || quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
// 1. 소모품 마스터 등록 (중복 확인)
const [existing] = await conn.query(
`SELECT item_id FROM consumable_items
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
[item_name.trim(), spec || null, spec || null, maker || null, maker || null]
);
let itemId;
if (existing.length > 0) {
itemId = existing[0].item_id;
} else {
const [insertResult] = await conn.query(
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
[item_name.trim(), spec || null, maker || null, category || 'consumable']
);
itemId = insertResult.insertId;
}
// 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에)
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
// 3. 구매 신청 생성
const [reqResult] = await conn.query(
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path)
VALUES (?, ?, ?, CURDATE(), ?, ?)`,
[itemId, quantity, req.user.id, notes || null, photo_path]
);
await conn.commit();
// 검색 캐시 무효화
koreanSearch.clearCache();
const request = await PurchaseRequestModel.getById(reqResult.insertId);
res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' });
} catch (err) {
if (conn) await conn.rollback().catch(() => {});
logger.error('registerAndRequest error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} finally {
if (conn) conn.release();
}
},
// 일괄 신청 (장바구니, 트랜잭션)
bulkCreate: async (req, res) => {
const { getDb } = require('../dbPool');
let conn;
try {
const { items, photo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
}
for (const item of items) {
if (!item.item_id && !item.item_name) {
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
}
if (!item.quantity || item.quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
}
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
const createdIds = [];
let newItemRegistered = false;
for (const item of items) {
let itemId = item.item_id || null;
// 신규 품목 등록 (is_new)
if (item.is_new && item.item_name) {
const [existing] = await conn.query(
`SELECT item_id FROM consumable_items
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
);
if (existing.length > 0) {
itemId = existing[0].item_id;
} else {
// 신규 품목 사진 저장 (마스터에)
let itemPhotoPath = null;
if (item.item_photo) {
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
}
const [ins] = await conn.query(
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
);
itemId = ins.insertId;
newItemRegistered = true;
}
}
// purchase_request 생성
const [result] = await conn.query(
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
item.quantity, req.user.id, item.notes || null, photo_path]
);
createdIds.push(result.insertId);
}
await conn.commit();
if (newItemRegistered) koreanSearch.clearCache();
res.status(201).json({
success: true,
data: { request_ids: createdIds, count: createdIds.length },
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
});
} catch (err) {
if (conn) await conn.rollback().catch(() => {});
logger.error('bulkCreate error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} finally {
if (conn) conn.release();
}
},
// 품목 마스터 사진 등록/업데이트
updateItemPhoto: async (req, res) => {
try {
const { photo } = req.body;
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
const { getDb } = require('../dbPool');
const db = await getDb();
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
koreanSearch.clearCache();
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
} catch (err) {
logger.error('updateItemPhoto error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 스마트 검색 (초성 + 별칭 + substring)
search: async (req, res) => {
try {
const { q } = req.query;
const results = await koreanSearch.search(q || '');
res.json({ success: true, data: results });
} catch (err) {
logger.error('PurchaseRequest search error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 소모품 목록 (select용)
getConsumableItems: async (req, res) => {
try {
@@ -133,6 +317,138 @@ const PurchaseRequestController = {
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: '서버 오류가 발생했습니다.' });
}
}
};

View File

@@ -209,6 +209,37 @@ const ScheduleController = {
}
},
// === 제품유형 ===
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,11 +49,12 @@ const ScheduleModel = {
const db = await getDb();
let sql = `
SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.job_no AS project_code,
su.name AS created_by_name
su.name AS created_by_name, wt.name AS work_type_name
FROM schedule_entries e
JOIN schedule_phases p ON e.phase_id = p.phase_id
JOIN projects pr ON e.project_id = pr.project_id
LEFT JOIN sso_users su ON e.created_by = su.user_id
LEFT JOIN work_types wt ON e.work_type_id = wt.id
WHERE 1=1
`;
const params = [];
@@ -80,7 +81,8 @@ const ScheduleModel = {
FROM schedule_entries e
JOIN schedule_phases p ON e.phase_id = p.phase_id
JOIN projects pr ON e.project_id = pr.project_id
WHERE (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL
AND (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
AND e.status != 'cancelled'
ORDER BY pr.job_no, p.display_order, e.display_order
`, [year, year]);
@@ -112,11 +114,12 @@ const ScheduleModel = {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO schedule_entries
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by, source, work_type_id, risk_assessment_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date,
data.progress || 0, data.status || 'planned', data.assignee || null,
data.notes || null, data.display_order || 0, data.created_by || null]
data.notes || null, data.display_order || 0, data.created_by || null,
data.source || 'manual', data.work_type_id || null, data.risk_assessment_id || null]
);
return result.insertId;
},
@@ -141,7 +144,7 @@ const ScheduleModel = {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id'];
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id', 'work_type_id', 'risk_assessment_id'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
@@ -247,6 +250,100 @@ const ScheduleModel = {
await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]);
},
// === 제품유형 ===
async getProductTypes() {
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order'
);
return rows;
},
// === 표준공정 자동 생성 ===
async generateFromTemplate(projectId, productTypeCode, createdBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 1. 중복 체크
const [existing] = await conn.query(
"SELECT COUNT(*) AS cnt FROM schedule_entries WHERE project_id = ? AND source = 'template'",
[projectId]
);
if (existing[0].cnt > 0) {
await conn.rollback();
return { error: '이미 표준공정이 생성되었습니다' };
}
// 2. product_type_id 조회
const [ptRows] = await conn.query(
'SELECT id FROM product_types WHERE code = ?', [productTypeCode]
);
if (ptRows.length === 0) {
await conn.rollback();
return { error: '존재하지 않는 제품유형입니다' };
}
const productTypeId = ptRows[0].id;
// 3. tksafety risk_process_templates 조회
const [templates] = await conn.query(
'SELECT * FROM risk_process_templates WHERE product_type = ? ORDER BY display_order',
[productTypeCode]
);
if (templates.length === 0) {
await conn.rollback();
return { error: '해당 제품유형의 공정 템플릿이 없습니다' };
}
// 4. 각 템플릿 → phase 매칭/생성 → entry 생성
let createdCount = 0;
for (const tmpl of templates) {
// phase 매칭: 1순위 전용, 2순위 범용, 3순위 신규
const [specificPhase] = await conn.query(
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id = ?',
[tmpl.process_name, productTypeId]
);
let phaseId;
if (specificPhase.length > 0) {
phaseId = specificPhase[0].phase_id;
} else {
const [genericPhase] = await conn.query(
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id IS NULL',
[tmpl.process_name]
);
if (genericPhase.length > 0) {
phaseId = genericPhase[0].phase_id;
} else {
// 신규 phase 생성 (제품유형 전용)
const [newPhase] = await conn.query(
'INSERT INTO schedule_phases (phase_name, display_order, product_type_id) VALUES (?, ?, ?)',
[tmpl.process_name, tmpl.display_order, productTypeId]
);
phaseId = newPhase.insertId;
}
}
// entry 생성 (날짜 NULL — 관리자가 나중에 입력)
await conn.query(
`INSERT INTO schedule_entries
(project_id, phase_id, task_name, start_date, end_date, status, progress, source, display_order, created_by)
VALUES (?, ?, ?, NULL, NULL, 'planned', 0, 'template', ?, ?)`,
[projectId, phaseId, tmpl.process_name, tmpl.display_order, createdBy]
);
createdCount++;
}
await conn.commit();
return { created: createdCount };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// === 부적합 연동 (격리 함수) ===
// 향후 System3 API 호출로 전환 시 이 함수만 수정
async getNonconformanceByProject(projectId) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

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