Compare commits
251 Commits
bf9254170b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312369d9ac | ||
|
|
9d2179e47a | ||
|
|
ba9ef32808 | ||
|
|
bbffa47a9d | ||
|
|
bf0d7fd87a | ||
|
|
56f626911a | ||
|
|
178155df6b | ||
|
|
d49aa01bd5 | ||
|
|
f28922a3ae | ||
|
|
7db072ed14 | ||
|
|
0de9d5bb48 | ||
|
|
de6d918d42 | ||
|
|
1cfd4da8ba | ||
|
|
46a1f8310d | ||
|
|
28a5924e76 | ||
|
|
d3487fd8fd | ||
|
|
48e3b58865 | ||
|
|
697af50963 | ||
|
|
b70904a4de | ||
|
|
cc69b452ab | ||
|
|
05d9e90c39 | ||
|
|
72e4a8b277 | ||
|
|
3f870b247d | ||
|
|
4063eba5bb | ||
|
|
118dc29c95 | ||
|
|
52e6ec16f8 | ||
|
|
cbddf5a7a4 | ||
|
|
6e3c5d6748 | ||
|
|
35b140aa38 | ||
|
|
7cf0614e3b | ||
|
|
ca86dc316c | ||
|
|
02e9f8c0ab | ||
|
|
30222df0ef | ||
|
|
708beb7fe5 | ||
|
|
bf11ccebf5 | ||
|
|
59242177d6 | ||
|
|
7e78f66838 | ||
|
|
9f181644f9 | ||
|
|
6cd613c071 | ||
|
|
ba2e3481e9 | ||
|
|
58b756d973 | ||
|
|
e9ece8c6f1 | ||
|
|
39c333bb39 | ||
|
|
bc92b0d5b0 | ||
|
|
9efb8c881a | ||
|
|
661523e963 | ||
|
|
7c1369a1be | ||
|
|
2c032bd9ea | ||
|
|
2308499668 | ||
|
|
f09c86ee01 | ||
|
|
766cb90e8f | ||
|
|
5832755475 | ||
|
|
41bb755181 | ||
|
|
5e22ff75e7 | ||
|
|
dcd40e692f | ||
|
|
798cc38945 | ||
|
|
cf75462380 | ||
|
|
0cc37d7773 | ||
|
|
a5f96dfe17 | ||
|
|
fdd28d63b2 | ||
|
|
c9249da944 | ||
|
|
80eb018caa | ||
|
|
4309d308bc | ||
|
|
4bb4fbd225 | ||
|
|
10fd65ba9e | ||
|
|
65e5530a6a | ||
|
|
1340918f8e | ||
|
|
798ccc62ad | ||
|
|
0ebe6e5a31 | ||
|
|
f7adbabb0f | ||
|
|
617b6f5c6f | ||
|
|
ca09f89cda | ||
|
|
c37ca24788 | ||
|
|
242dca83b5 | ||
|
|
b855ac973a | ||
|
|
c71286b52b | ||
|
|
7ccec81615 | ||
|
|
f68c66e696 | ||
|
|
77b66f49ae | ||
|
|
3cc38791c8 | ||
|
|
b8d3a516e1 | ||
|
|
972fc07f8d | ||
|
|
ab9e5a46cc | ||
|
|
1c47505b0d | ||
|
|
71132a1e8d | ||
|
|
f728f84117 | ||
|
|
5054398f4f | ||
|
|
755e4142e1 | ||
|
|
492843342a | ||
|
|
c9524d9958 | ||
|
|
b9e3b868bd | ||
|
|
df688879a4 | ||
|
|
76e4224b32 | ||
|
|
01f27948e4 | ||
|
|
d45466ad77 | ||
|
|
f3b7f1a34f | ||
|
|
1980c83377 | ||
|
|
f58dd115c9 | ||
|
|
408bf1af62 | ||
|
|
ec7699b270 | ||
|
|
0c8801849c | ||
|
|
d96a75adc2 | ||
|
|
9cbf4c98a5 | ||
|
|
8016237038 | ||
|
|
d16b2f68ba | ||
|
|
d7408ce603 | ||
|
|
9bd3888738 | ||
|
|
9528a544c6 | ||
|
|
c615d0f121 | ||
|
|
6a721258b8 | ||
|
|
53596ba540 | ||
|
|
b67e8f2c9f | ||
|
|
b2ce691ef9 | ||
|
|
a30482ec34 | ||
|
|
71ef40c26c | ||
|
|
5dee4fd600 | ||
|
|
3c611daa29 | ||
|
|
666f0f2df4 | ||
|
|
2357744b02 | ||
|
|
4dd39ceab7 | ||
|
|
d3cef659ce | ||
|
|
5ac7af7b04 | ||
|
|
f434b4d66f | ||
|
|
517fef46a9 | ||
|
|
31adc39d89 | ||
|
|
e3b7626e07 | ||
|
|
65b2bbe552 | ||
|
|
46cd98c6ea | ||
|
|
eb9266d83a | ||
|
|
6b584f9881 | ||
|
|
0afe864ba3 | ||
|
|
913ab2fcfd | ||
|
|
60b2fd1b8d | ||
|
|
1fd6253fbc | ||
|
|
295928c725 | ||
|
|
7aaac1e334 | ||
|
|
672a7039df | ||
|
|
8683787a01 | ||
|
|
658474af71 | ||
|
|
1040adee10 | ||
|
|
822c654ce5 | ||
|
|
eea99359b5 | ||
|
|
549e78ba61 | ||
|
|
c769fa040d | ||
|
|
afb63e4e94 | ||
|
|
4d783e47c9 | ||
|
|
07a6253692 | ||
|
|
b7771f8232 | ||
|
|
943ed63d77 | ||
|
|
6411eab210 | ||
|
|
66676ac923 | ||
|
|
02e39f1102 | ||
|
|
ac2a2e7eed | ||
|
|
ea6f7c3013 | ||
|
|
ce47865890 | ||
|
|
5cae2362cc | ||
|
|
1ceeef2a65 | ||
|
|
d6dd03a52f | ||
|
|
7abf62620b | ||
|
|
280efc46ed | ||
|
|
a6724b2a20 | ||
|
|
d663b9bfa6 | ||
|
|
05c9f22bdf | ||
|
|
d46e509e42 | ||
|
|
08a629f662 | ||
|
|
71289be375 | ||
|
|
66db012754 | ||
|
|
a40c1e0f18 | ||
|
|
f09aa0875a | ||
|
|
1f3eb14128 | ||
|
|
3d314c1fb4 | ||
|
|
2afcc4448b | ||
|
|
a2bb157111 | ||
|
|
36cf9d553d | ||
|
|
19e668a56a | ||
|
|
b3ff87b151 | ||
|
|
36391c02e1 | ||
|
|
a3f7a324b1 | ||
|
|
c158da7832 | ||
|
|
b44ae36329 | ||
|
|
fa4199a277 | ||
|
|
0c149673fb | ||
|
|
84cf222b81 | ||
|
|
afa10c044f | ||
|
|
862a2683d3 | ||
|
|
f548a95767 | ||
|
|
1cef745cc9 | ||
|
|
e50ff3fb63 | ||
|
|
184cdd6aa8 | ||
|
|
adf3a197fd | ||
|
|
49949bda62 | ||
|
|
d7cc568c01 | ||
|
|
b5dc9c2f20 | ||
|
|
0910f5d0a6 | ||
|
|
9a2b682b18 | ||
|
|
1e1d2f631a | ||
|
|
2699242d1f | ||
|
|
9b586da720 | ||
|
|
5cc3191871 | ||
|
|
ec59efcdb6 | ||
|
|
c2e8b58849 | ||
|
|
573ef74246 | ||
|
|
65839e94a4 | ||
|
|
0a05bd8d76 | ||
|
|
cc47d25851 | ||
|
|
817002f798 | ||
|
|
4108a6e64a | ||
|
|
f711a721ec | ||
|
|
5a911f1d4b | ||
|
|
457c74084f | ||
|
|
509691eebb | ||
|
|
5d24584553 | ||
|
|
8ed0b832ab | ||
|
|
73bd13a7cd | ||
|
|
5398581b87 | ||
|
|
54bb26dbd6 | ||
|
|
17f7c6f3b0 | ||
|
|
e9b69ed87b | ||
|
|
fe5f7cd155 | ||
|
|
2d8ac92404 | ||
|
|
e42a08e74d | ||
|
|
cc626a408e | ||
|
|
cea72b1858 | ||
|
|
aacd18be1c | ||
|
|
cae735f243 | ||
|
|
13e177e818 | ||
|
|
3623551a6b | ||
|
|
1abdb92a71 | ||
|
|
07aac305d6 | ||
|
|
e8076a8550 | ||
|
|
3e50639914 | ||
|
|
e236883c64 | ||
|
|
7e10a90a1a | ||
|
|
054518f4fc | ||
|
|
0fd202dcbb | ||
|
|
12367dd3a1 | ||
|
|
86312c1af7 | ||
|
|
7161351607 | ||
|
|
a66656b1c3 | ||
|
|
ccdb1087d7 | ||
|
|
2a8ae8572f | ||
|
|
f4999df334 | ||
|
|
baf68ca065 | ||
|
|
4b68431d2d | ||
|
|
be24c12551 | ||
|
|
3011495e6d | ||
|
|
fa61bdbb30 | ||
|
|
b1154a8bc7 | ||
|
|
0a712813e2 | ||
|
|
7fd646e9ba | ||
|
|
1ad82fd52c |
92
.claude/WORKFLOW-GUIDE.md
Normal file
92
.claude/WORKFLOW-GUIDE.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Claude Code 워크플로우 가이드 — TK Factory Services
|
||||
|
||||
## 1. CLAUDE.md 활용
|
||||
- 프로젝트 루트의 `CLAUDE.md`가 매 세션 자동 로드 → 프로젝트 컨텍스트 즉시 파악
|
||||
- 서비스별 CLAUDE.md 추가 가능 (예: `system1-factory/CLAUDE.md`) → 해당 디렉토리 작업 시 추가 로드
|
||||
- git으로 관리되므로 팀원 간 공유 가능
|
||||
|
||||
## 2. 슬래시 커맨드 (.claude/commands/)
|
||||
|
||||
| 커맨드 | 설명 | 사용 예 |
|
||||
|--------|------|---------|
|
||||
| `/deploy` | 원클릭 NAS 배포 | `/deploy system1-web` |
|
||||
| `/check-deploy` | NAS 서비스 상태 점검 | `/check-deploy` |
|
||||
| `/cache-bust` | 변경된 JS/CSS 캐시 버스팅 일괄 갱신 | `/cache-bust` |
|
||||
| `/add-page` | System1 새 HTML 페이지 스캐폴드 | `/add-page consumable/stock 소모품 재고` |
|
||||
| `/add-api` | System1 새 API 엔드포인트 스캐폴드 | `/add-api consumable-stock` |
|
||||
|
||||
커스텀 추가: `.claude/commands/`에 `.md` 파일 추가하면 자동 인식.
|
||||
|
||||
## 3. Plan 모드 활용
|
||||
|
||||
적합한 작업:
|
||||
- 여러 서비스에 걸친 기능 추가 (예: 새 마이크로서비스)
|
||||
- DB 스키마 변경 + API + 프론트엔드 동시 수정
|
||||
- 대규모 리팩터링 (30+ 파일 변경)
|
||||
|
||||
사용법:
|
||||
1. `/plan`으로 Plan 모드 진입
|
||||
2. Claude가 코드 탐색 → 구현 계획 작성
|
||||
3. 계획 검토 · 수정 → 승인
|
||||
4. 자동 실행
|
||||
|
||||
## 4. 서브에이전트 활용 패턴
|
||||
|
||||
| 에이전트 | 용도 | 예시 |
|
||||
|----------|------|------|
|
||||
| Explore | 코드베이스 탐색 | "system1과 system2에서 인증 처리 방식 비교해줘" |
|
||||
| Plan | 구현 설계 | "tkeg에 새 모듈 추가 계획 세워줘" |
|
||||
|
||||
- 독립적 탐색은 병렬 실행 가능 (최대 3개)
|
||||
- 넓은 범위 검색에 유용 (단순 파일 찾기는 직접 검색이 빠름)
|
||||
|
||||
## 5. 검증 피드백 루프 (Boris Cherny 패턴)
|
||||
|
||||
코드 변경 후 검증 사이클:
|
||||
1. **빌드 확인** → `docker compose build <서비스>`
|
||||
2. **배포** → `/deploy <서비스>`
|
||||
3. **health check** → 자동 실행 (deploy에 포함)
|
||||
4. **수동 확인** → 브라우저에서 모바일 + 데스크탑 테스트
|
||||
|
||||
## 6. 효율적 작업 패턴
|
||||
|
||||
### 프론트엔드 수정 (Vanilla JS 서비스)
|
||||
1. HTML/JS/CSS 수정
|
||||
2. `/cache-bust`로 캐시 버스팅 갱신
|
||||
3. `git commit` + `/deploy system1-web`
|
||||
|
||||
### API 수정 (Node.js 서비스)
|
||||
1. model → controller → route 수정
|
||||
2. `git commit` + `/deploy system1-api`
|
||||
3. `curl`로 API 테스트
|
||||
|
||||
### tkeg 수정 (React + FastAPI)
|
||||
1. 프론트: `npm run build` (Vite 자동 해시, 캐시 버스팅 불필요)
|
||||
2. 백엔드: FastAPI 코드 수정
|
||||
3. `git commit` + `/deploy tkeg-api tkeg-web`
|
||||
|
||||
### 여러 서비스 동시 수정
|
||||
1. `/plan`으로 계획 수립
|
||||
2. 변경 실행
|
||||
3. 영향받는 서비스 모두 재배포: `/deploy system1-api system1-web system2-api`
|
||||
|
||||
## 7. 자주 쓰는 디버깅 명령
|
||||
|
||||
```bash
|
||||
# DB 접속 (NAS)
|
||||
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p\"\$MYSQL_PASSWORD\" hyungi"
|
||||
|
||||
# 로그 확인 (NAS)
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"
|
||||
|
||||
# 컨테이너 상태 (NAS)
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose ps"
|
||||
|
||||
# PostgreSQL 접속 (tkeg)
|
||||
ssh hyungi@100.71.132.52 "docker exec tk-tkeg-postgres psql -U tkbom_user -d tk_bom"
|
||||
```
|
||||
|
||||
## 8. 팁
|
||||
- **작은 단위로 커밋**: 서비스별로 나눠서 커밋하면 롤백이 쉬움
|
||||
- **CLAUDE.md 업데이트**: 새 서비스 추가 시 서비스 맵 테이블 갱신
|
||||
- **메모리 활용**: 반복되는 피드백은 Claude가 자동 저장 → 다음 세션에도 적용
|
||||
30
.claude/commands/add-api.md
Normal file
30
.claude/commands/add-api.md
Normal file
@@ -0,0 +1,30 @@
|
||||
System1(system1-factory) 새 API 엔드포인트를 생성합니다.
|
||||
|
||||
인자: $ARGUMENTS (예: "consumable-stock 소모품 재고" 또는 리소스명)
|
||||
|
||||
절차:
|
||||
1. 기존 패턴 확인:
|
||||
- system1-factory/api/ 아래 기존 model, controller, route 파일 하나씩 읽어 패턴 파악
|
||||
|
||||
2. Model 생성 (`api/models/<리소스>.js`):
|
||||
- Knex 기반 DB 쿼리 패턴
|
||||
- CRUD 기본 메서드: getAll, getById, create, update, delete
|
||||
|
||||
3. Controller 생성 (`api/controllers/<리소스>Controller.js`):
|
||||
- responseFormatter (res.success, res.error, res.paginated) 사용
|
||||
- try/catch + 에러 핸들링 패턴
|
||||
- 페이지네이션 지원 (GET list)
|
||||
|
||||
4. Route 생성 (`api/routes/<리소스>Routes.js`):
|
||||
- Express Router
|
||||
- auth 미들웨어 적용
|
||||
- RESTful 패턴: GET /, GET /:id, POST /, PUT /:id, DELETE /:id
|
||||
|
||||
5. 라우트 등록:
|
||||
- `api/config/routes.js` (또는 app.js/server.js)에 새 라우트 추가
|
||||
|
||||
6. 생성된 파일 목록 + API 엔드포인트 정리 보고
|
||||
|
||||
주의:
|
||||
- 다른 서비스(system2, tkpurchase 등)의 API 추가 시 해당 서비스의 패턴을 먼저 확인
|
||||
- System3, tkeg는 FastAPI 패턴 (Python)
|
||||
25
.claude/commands/add-page.md
Normal file
25
.claude/commands/add-page.md
Normal file
@@ -0,0 +1,25 @@
|
||||
System1(system1-factory) 새 HTML 페이지를 생성합니다.
|
||||
|
||||
인자: $ARGUMENTS (예: "consumable/stock 소모품 재고 관리")
|
||||
→ 경로와 페이지 제목을 파싱
|
||||
|
||||
절차:
|
||||
1. 기존 페이지를 템플릿으로 사용:
|
||||
- system1-factory/web/ 아래 비슷한 기능의 기존 HTML 페이지를 찾아 참조
|
||||
- 공통 구조: 헤더(nav), 사이드네비, 메인 콘텐츠 영역
|
||||
|
||||
2. 새 HTML 파일 생성 (`system1-factory/web/<경로>.html`):
|
||||
- 공통 CSS: tkfb.css, Tailwind CDN, Font Awesome
|
||||
- 공통 JS: tkfb-core.js, api-base.js
|
||||
- 페이지 제목, 설명 커스터마이즈
|
||||
- 사이드네비에 현재 페이지 active 표시
|
||||
|
||||
3. 대응 JS 컨트롤러 파일 생성 (필요 시):
|
||||
- `system1-factory/web/js/<경로>.js`
|
||||
- 기존 JS 패턴 따르기 (DOMContentLoaded, API 호출 등)
|
||||
|
||||
4. 생성된 파일 목록 보고 + 캐시 버스팅 안내
|
||||
|
||||
주의:
|
||||
- 다른 서비스(system2, system3 등)에 페이지 추가 시 해당 서비스의 패턴을 먼저 확인
|
||||
- HTML의 script/link 태그에 ?v= 버전 포함
|
||||
20
.claude/commands/cache-bust.md
Normal file
20
.claude/commands/cache-bust.md
Normal file
@@ -0,0 +1,20 @@
|
||||
커밋 전 변경된 JS/CSS 파일에 대해 캐시 버스팅 버전을 갱신합니다.
|
||||
|
||||
절차:
|
||||
1. `git diff --name-only HEAD`로 아직 커밋 안 된 변경사항 중 .js, .css 파일 목록 추출
|
||||
- staged + unstaged 모두 포함: `git diff --name-only HEAD` + `git diff --name-only --cached`
|
||||
- untracked 파일도 포함: `git ls-files --others --exclude-standard -- '*.js' '*.css'`
|
||||
|
||||
2. 각 변경된 JS/CSS 파일에 대해:
|
||||
- 해당 파일을 참조하는 HTML 파일을 grep으로 검색 (같은 서비스 디렉토리 내)
|
||||
- `<script src="...파일명...?v=...">` 또는 `<link href="...파일명...?v=...">` 패턴 찾기
|
||||
|
||||
3. 찾은 HTML 참조의 `?v=` 버전을 오늘 날짜 기반으로 갱신:
|
||||
- 포맷: `?v=YYYYMMDD01` (같은 날 여러 번이면 NN 증가)
|
||||
- 기존 ?v= 없으면 추가
|
||||
|
||||
4. 변경된 HTML 파일 목록과 갱신된 버전 번호를 보고
|
||||
|
||||
주의사항:
|
||||
- tkeg는 Vite 빌드이므로 제외 (자동 해시)
|
||||
- 같은 파일의 ?v=를 여러 HTML에서 참조할 수 있으므로 모두 갱신
|
||||
25
.claude/commands/check-deploy.md
Normal file
25
.claude/commands/check-deploy.md
Normal file
@@ -0,0 +1,25 @@
|
||||
NAS 서비스 상태 점검:
|
||||
|
||||
1. SSH로 docker compose ps 실행:
|
||||
```
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose ps"
|
||||
```
|
||||
|
||||
2. 각 주요 서비스 health check (curl로 확인):
|
||||
- Gateway (30000): curl http://100.71.132.52:30000/
|
||||
- SSO Auth (30050): curl http://100.71.132.52:30050/health
|
||||
- System1 API (30005): curl http://100.71.132.52:30005/api/health
|
||||
- System2 API (30105): curl http://100.71.132.52:30105/api/health
|
||||
- System3 API (30200): curl http://100.71.132.52:30200/health
|
||||
- tkuser API (30300): curl http://100.71.132.52:30300/api/health
|
||||
- tkpurchase API (30400): curl http://100.71.132.52:30400/api/health
|
||||
- tksafety API (30500): curl http://100.71.132.52:30500/api/health
|
||||
- tksupport API (30600): curl http://100.71.132.52:30600/api/health
|
||||
- tkeg API (30700): curl http://100.71.132.52:30700/health
|
||||
|
||||
3. 로컬 vs NAS git 커밋 비교:
|
||||
- 로컬: `git rev-parse HEAD`
|
||||
- NAS: `ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git rev-parse HEAD"`
|
||||
- 차이가 있으면 어떤 커밋이 빠져있는지 보고
|
||||
|
||||
4. 결과를 표로 정리하여 보고 (서비스명 | 상태 | health check | 비고)
|
||||
26
.claude/commands/deploy.md
Normal file
26
.claude/commands/deploy.md
Normal file
@@ -0,0 +1,26 @@
|
||||
전제조건: Tailscale SSH 키 인증 (hyungi@100.71.132.52) 설정 완료
|
||||
|
||||
서비스명: $ARGUMENTS
|
||||
|
||||
배포 실행 절차:
|
||||
1. `git status`로 커밋 안 된 변경사항 확인 → 있으면 사용자에게 경고하고 계속할지 확인
|
||||
2. `git push` 실행
|
||||
3. SSH로 NAS에서 배포:
|
||||
```
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build $ARGUMENTS"
|
||||
```
|
||||
4. 30초 대기 후 health check:
|
||||
- API 서비스면: `curl -s http://100.71.132.52:<포트>/api/health` 또는 `/health`
|
||||
- Web 서비스면: `curl -s -o /dev/null -w '%{http_code}' http://100.71.132.52:<포트>/`
|
||||
5. 결과 보고 (성공/실패, 걸린 시간)
|
||||
|
||||
서비스별 포트 매핑:
|
||||
- system1-api: 30005, system1-web: 30080
|
||||
- system2-api: 30105, system2-web: 30180
|
||||
- system3-api: 30200, system3-web: 30280
|
||||
- tkuser-api: 30300, tkuser-web: 30380
|
||||
- tkpurchase-api: 30400, tkpurchase-web: 30480
|
||||
- tksafety-api: 30500, tksafety-web: 30580
|
||||
- tksupport-api: 30600, tksupport-web: 30680
|
||||
- tkeg-api: 30700, tkeg-web: 30780
|
||||
- gateway: 30000, sso-auth: 30050
|
||||
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Sprint 004: 월간 근무 비교·확인·정산
|
||||
|
||||
> 작성: Cowork | 초안: 2026-03-30 | 갱신: 2026-03-31 (출근부 양식 반영)
|
||||
|
||||
## 목표
|
||||
1. **작업보고서 vs 근태관리 비교 페이지**: 그룹장이 입력한 작업보고서와 근태관리 데이터를 일별로 비교하여 불일치를 시각화
|
||||
2. **월말 확인 프로세스**: 작업자가 자신의 월간 근무 내역을 확인(승인/반려)하는 워크플로우
|
||||
3. **출근부 엑셀 다운로드**: 전원 확인 완료 시, 업로드된 양식에 맞는 출근부 엑셀 내보내기
|
||||
4. **연차 잔액 연동**: sp_vacation_balances 기반 신규/사용/잔여 데이터 엑셀 포함
|
||||
|
||||
## 현재 구현 상태 (2026-03-31)
|
||||
|
||||
| 항목 | 파일 | 상태 | 비고 |
|
||||
|------|------|------|------|
|
||||
| DB 마이그레이션 | `api/db/migrations/20260330_create_monthly_work_confirmations.sql` | ✅ 완료 | |
|
||||
| 모델 | `api/models/monthlyComparisonModel.js` (232줄) | ✅ 완료 | getExportData 추가됨 |
|
||||
| 컨트롤러 | `api/controllers/monthlyComparisonController.js` (560줄) | ✅ 완료 | exportExcel 출근부 양식 재작성 |
|
||||
| 라우트 | `api/routes/monthlyComparisonRoutes.js` (33줄) | ✅ 완료 | |
|
||||
| 라우트 등록 | `api/routes.js` 157줄 | ✅ 완료 | |
|
||||
| 프론트엔드 HTML | `web/pages/attendance/monthly-comparison.html` | ✅ 완료 | |
|
||||
| 프론트엔드 JS | `web/js/monthly-comparison.js` (558줄) | ✅ 완료 | |
|
||||
| 프론트엔드 CSS | `web/css/monthly-comparison.css` | ✅ 완료 | |
|
||||
| 대시보드 연동 | `web/js/production-dashboard.js` | ✅ 완료 | PAGE_ICONS 추가 |
|
||||
|
||||
> 모든 파일이 존재하고 라우트가 등록되어 있으나, **실 서버 배포 및 통합 테스트 미완료**.
|
||||
|
||||
## 배경
|
||||
- monthly.html에서 그룹장이 등록한 근태정보를 각 작업자가 개별 확인·승인
|
||||
- 전원 confirmed 완료 시 지원팀이 출근부 엑셀 다운로드 가능
|
||||
- 출근부 양식: 업로드된 `출근부_2026.02_TK 생산팀.xlsx` 기준
|
||||
- attendanceService.js에서 연차 자동 차감/복원이 이미 구현되어 있으므로 sp_vacation_balances가 소스 오브 트루스
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ monthly.html (그룹장 → 근태 입력) │
|
||||
│ ↓ │
|
||||
│ daily_attendance_records 저장 │
|
||||
│ attendanceService.js → sp_vacation_balances 자동 반영 │
|
||||
└─────────────────┬────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ monthly-comparison.html │
|
||||
│ │
|
||||
│ [작업자 뷰] [관리자 뷰] │
|
||||
│ ├ 작업보고서 vs 근태 일별 비교 ├ 전체 확인 현황 │
|
||||
│ ├ 요약 카드 (근무일/시간/연장/휴가) ├ 개별 작업자 상세 │
|
||||
│ └ 확인(승인) / 반려(사유 입력) └ 엑셀 다운로드 │
|
||||
│ ↓ (반려 시) │
|
||||
│ notifications → 지원팀에 알림 │
|
||||
│ ↓ (전원 확인 완료 시) │
|
||||
│ 출근부 엑셀 다운로드 │
|
||||
│ (출근부_YYYY.MM_부서명.xlsx) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 출근부 엑셀 양식 (신규)
|
||||
|
||||
기존 2-시트 구조(월간 근무 현황 + 일별 상세)를 **출근부 단일 시트**로 변경.
|
||||
|
||||
```
|
||||
시트명: {YY}.{M}월 출근부
|
||||
|
||||
Row 2: 부서명 | [부서명 (병합)]
|
||||
Row 3: 근로기간 | [YYYY년 M월 (병합)]
|
||||
Row 4: [이름] [담당] 1 2 3 ... 31 [총시간] [M월(병합)] [비고(병합)]
|
||||
Row 5: 일 월 화 ... [ ] 신규 사용 잔여
|
||||
Row 6+: 김두수 1 0 0 0 ... 휴무 =SUM() 15 2 =AF-AG
|
||||
|
||||
셀 값 규칙:
|
||||
정상출근 → 근무시간(숫자, 0.00)
|
||||
주말(근무없음) → '휴무'
|
||||
연차(ANNUAL) → '연차'
|
||||
반차(HALF_ANNUAL) → '반차'
|
||||
반반차(ANNUAL_QUARTER) → '반반차'
|
||||
조퇴(EARLY_LEAVE) → '조퇴'
|
||||
병가(SICK) → '병가'
|
||||
경조사(SPECIAL) → '경조사'
|
||||
|
||||
스타일:
|
||||
폰트: 맑은 고딕 12pt, 가운데 정렬
|
||||
주말 헤더: 빨간 폰트 (FFFF0000)
|
||||
테두리: thin 전체
|
||||
헤더 행 높이: 40, 데이터 행 높이: 60
|
||||
수식: 총시간=SUM(날짜열), 잔여=신규-사용
|
||||
```
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| Method | Path | 접근권한 | 설명 |
|
||||
|--------|------|---------|------|
|
||||
| GET | /api/monthly-comparison/my-records | 인증된 사용자 | 내 일별 비교 데이터 |
|
||||
| GET | /api/monthly-comparison/records | support_team+ 또는 본인 | 특정 작업자 비교 조회 |
|
||||
| POST | /api/monthly-comparison/confirm | 인증된 사용자 (본인) | 승인/반려 |
|
||||
| GET | /api/monthly-comparison/all-status | support_team+ | 전체 확인 현황 |
|
||||
| GET | /api/monthly-comparison/export | support_team+ | 출근부 엑셀 다운로드 |
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
| 데이터 | 테이블 | 비고 |
|
||||
|--------|--------|------|
|
||||
| 작업보고서 | daily_work_reports | report_date, work_hours, project_id |
|
||||
| 근태 기록 | daily_attendance_records | record_date, total_work_hours, vacation_type_id |
|
||||
| 확인 상태 | monthly_work_confirmations | 신규 테이블 (Sprint 004) |
|
||||
| 연차 잔액 | sp_vacation_balances | year별 SUM(total_days, used_days) |
|
||||
| 휴가 유형 | vacation_types | type_code → 출근부 텍스트 매핑 |
|
||||
| 작업자 정보 | workers + departments | worker_id 순 정렬 |
|
||||
|
||||
## 작업 분할
|
||||
|
||||
| 섹션 | 핵심 작업 | 규모 |
|
||||
|------|----------|------|
|
||||
| **A (Backend)** | 모델·컨트롤러 검증 + 출근부 엑셀 엣지케이스 + 배포 | 파일 4개, 검증 위주 |
|
||||
| **B (Frontend)** | UI 최종 검증 + 모바일 반응형 + 캐시 버스팅 + 배포 | 파일 4개, 검증 위주 |
|
||||
|
||||
> 기존 코드가 모두 구현되어 있으므로 **검증·배포 중심**으로 진행.
|
||||
|
||||
## 섹션 간 의존성
|
||||
|
||||
```
|
||||
A ──(API 제공)──→ B
|
||||
|
||||
GET /api/monthly-comparison/my-records
|
||||
POST /api/monthly-comparison/confirm
|
||||
GET /api/monthly-comparison/all-status
|
||||
GET /api/monthly-comparison/records
|
||||
GET /api/monthly-comparison/export
|
||||
```
|
||||
|
||||
Section A 배포 완료 후 Section B 프론트엔드가 정상 동작 가능.
|
||||
|
||||
## 완료 조건
|
||||
- [x] 마이그레이션 파일 생성 (20260330_create_monthly_work_confirmations.sql)
|
||||
- [x] 모든 Backend 파일 생성 (model, controller, routes)
|
||||
- [x] routes.js에 라우트 등록
|
||||
- [x] 프론트엔드 페이지 생성 (HTML, JS, CSS)
|
||||
- [x] 대시보드 아이콘 매핑 추가
|
||||
- [x] 출근부 양식 엑셀 재작성 (getExportData + exportExcel)
|
||||
- [ ] 실 서버 마이그레이션 실행
|
||||
- [ ] Docker 빌드·배포
|
||||
- [ ] 통합 테스트 (작업자 확인 → 지원팀 엑셀 다운로드 전체 플로우)
|
||||
- [ ] 모바일 반응형 확인
|
||||
- [ ] 엣지케이스 확인 (데이터 없는 월, 중도 입사자 등)
|
||||
|
||||
## 주요 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|----------|
|
||||
| 2026-03-30 | 초안 작성 + Section A/B 코드 구현 |
|
||||
| 2026-03-31 | 출근부 양식 반영: getExcelData→getExportData, exportExcel 전면 재작성 |
|
||||
| 2026-03-31 | 버그 수정: export 조건에 rejected 추가, admin detail 모드 버튼 숨김 |
|
||||
| 2026-04-01 | 워크플로우 확장: pending→review_sent→confirmed/change_request/rejected 상태 추가 |
|
||||
| 2026-04-01 | 정책 변경: 확인요청 발송 전 전원 admin_checked 필수 (선택적→필수) |
|
||||
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.github
|
||||
.claude
|
||||
**/.env
|
||||
**/node_modules
|
||||
**/logs
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
**/venv
|
||||
**/web/
|
||||
*.md
|
||||
!shared/**
|
||||
FEATURES.pdf
|
||||
12
.env.example
12
.env.example
@@ -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
5
.githooks/pre-commit
Executable 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
168
.githooks/pre-receive-server.sh
Executable 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
24
.securityignore
Normal file
@@ -0,0 +1,24 @@
|
||||
# =============================================================================
|
||||
# .securityignore — 보안 스캔 제외 목록
|
||||
# =============================================================================
|
||||
# 규칙:
|
||||
# - 모든 항목에 사유 주석 필수 (없으면 경고)
|
||||
# - 월 1회 정기 검토 → 불필요 항목 제거
|
||||
# - 날짜 표기 권장
|
||||
# =============================================================================
|
||||
|
||||
# 스캔 스크립트 자체 (규칙 패턴 포함)
|
||||
scripts/security-scan.sh # 규칙 정의 자체 (2026-04-10)
|
||||
.githooks/pre-receive-server.sh # 규칙 정의 자체 (2026-04-10)
|
||||
|
||||
# 환경변수 템플릿 (placeholder만 포함)
|
||||
.env.example # placeholder 값만 (2026-04-10)
|
||||
|
||||
# 보안 감사 보고서 (발견된 패턴 인용)
|
||||
SECURITY-AUDIT-20260402.md # 감사 보고서 인용 (2026-04-10)
|
||||
SECURITY-FINDINGS-SUMMARY.txt # 감사 요약 인용 (2026-04-10)
|
||||
SECURITY-CODE-SNIPPETS.md # 코드 스니펫 인용 (2026-04-10)
|
||||
|
||||
# 보안 가이드/체크리스트 (규칙 예시 포함)
|
||||
SECURITY-CHECKLIST.md # 규칙 참조 예시 (2026-04-10)
|
||||
docs/SECURITY-GUIDE.md # 가이드 예시 코드 (2026-04-10)
|
||||
108
ARCHITECTURE.md
Normal file
108
ARCHITECTURE.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# TK Factory Services — 시스템 아키텍처
|
||||
|
||||
## 전체 구조
|
||||
|
||||
21개 컨테이너, Docker Compose 기반, Cloudflare Tunnel로 외부 노출.
|
||||
|
||||
```
|
||||
[Cloudflare Tunnel] → tk-cloudflared
|
||||
├── tkfb.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
|
||||
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
|
||||
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
|
||||
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
|
||||
├── tkuser.technicalkorea.net → tk-tkuser-web:80 (통합관리)
|
||||
├── tkpurchase.technicalkorea.net → tk-tkpurchase-web:80 (구매관리)
|
||||
├── tksafety.technicalkorea.net → tk-tksafety-web:80 (안전관리)
|
||||
└── tksupport.technicalkorea.net → tk-tksupport-web:80 (행정지원)
|
||||
```
|
||||
|
||||
## 서비스 목록
|
||||
|
||||
| 서비스 | 컨테이너명 | 로컬 포트 | 내부 포트 | 역할 |
|
||||
|---|---|---|---|---|
|
||||
| **Gateway** | tk-gateway | 30000 | 80 | 로그인/대시보드/공유JS + SSO·API 프록시 |
|
||||
| **System1 API** | tk-system1-api | 30005 | 3005 | 공장관리 백엔드 (Node.js) |
|
||||
| **System1 Web** | tk-system1-web | 30080 | 80 | 공장관리 프론트엔드 (nginx) |
|
||||
| **System1 FastAPI** | tk-system1-fastapi | 30008 | 8000 | FastAPI Bridge |
|
||||
| **System2 API** | tk-system2-api | 30105 | 3005 | 신고 백엔드 (Node.js) |
|
||||
| **System2 Web** | tk-system2-web | 30180 | 80 | 신고 프론트엔드 (nginx) |
|
||||
| **System3 API** | tk-system3-api | 30200 | 8000 | 부적합관리 백엔드 (FastAPI) |
|
||||
| **System3 Web** | tk-system3-web | 30280 | 80 | 부적합관리 프론트엔드 (nginx) |
|
||||
| **User API** | tk-tkuser-api | 30300 | 3000 | 사용자관리 백엔드 (Node.js) |
|
||||
| **User Web** | tk-tkuser-web | 30380 | 80 | 사용자관리 프론트엔드 (nginx) |
|
||||
| **Purchase API** | tk-tkpurchase-api | 30400 | 3000 | 구매관리 백엔드 (Node.js) |
|
||||
| **Purchase Web** | tk-tkpurchase-web | 30480 | 80 | 구매관리 프론트엔드 (nginx) |
|
||||
| **Safety API** | tk-tksafety-api | 30500 | 3000 | 안전관리 백엔드 (Node.js) |
|
||||
| **Safety Web** | tk-tksafety-web | 30580 | 80 | 안전관리 프론트엔드 (nginx) |
|
||||
| **Support API** | tk-tksupport-api | 30600 | 3000 | 행정지원 백엔드 (Node.js) |
|
||||
| **Support Web** | tk-tksupport-web | 30680 | 80 | 행정지원 프론트엔드 (nginx) |
|
||||
| **SSO Auth** | tk-sso-auth | 30050 | 3000 | SSO 인증 서비스 (Node.js) |
|
||||
| **MariaDB** | tk-mariadb | 30306 | 3306 | 메인 데이터베이스 |
|
||||
| **Redis** | tk-redis | — | 6379 | 세션/캐시 |
|
||||
| **phpMyAdmin** | tk-phpmyadmin | 30880 | 80 | DB 관리 도구 |
|
||||
| **Cloudflared** | tk-cloudflared | — | — | Cloudflare Tunnel 에이전트 |
|
||||
|
||||
## 요청 흐름
|
||||
|
||||
```
|
||||
브라우저 → Cloudflare DNS → Cloudflare Tunnel → cloudflared 컨테이너
|
||||
→ 서브도메인별 라우팅 → 해당 web 컨테이너 (nginx)
|
||||
→ /api/ → 해당 API 컨테이너
|
||||
→ /auth/ → sso-auth 컨테이너
|
||||
→ /uploads/ → API 컨테이너 (파일 서빙)
|
||||
→ 나머지 → 정적 파일 (SPA fallback)
|
||||
```
|
||||
|
||||
## SSO 인증 흐름
|
||||
|
||||
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
|
||||
2. 토큰 없음/만료 → `tkfb.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
|
||||
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
|
||||
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
|
||||
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
|
||||
6. 각 서비스의 API는 `Authorization: Bearer <token>` 헤더로 인증 검증
|
||||
|
||||
## CORS 관리
|
||||
|
||||
- **sso-auth**: `sso-auth-service/config/` — 모든 `*.technicalkorea.net` 서브도메인 허용
|
||||
- **system1-factory API**: `system1-factory/api/config/cors.js` — 허용 origin 명시적 관리
|
||||
- 로컬 네트워크(192.168.x.x)는 자동 허용
|
||||
|
||||
## 공유 JS
|
||||
|
||||
Gateway(`/shared/`)에서 서빙:
|
||||
- `notification-bell.js` — 알림 벨 UI, 모든 서비스에서 로딩
|
||||
- `nav-header.js` — 공통 네비게이션 헤더
|
||||
|
||||
각 서비스의 core.js에서 동적 로딩:
|
||||
```
|
||||
프로덕션: https://tkfb.technicalkorea.net/shared/notification-bell.js
|
||||
로컬: http://localhost:30000/shared/notification-bell.js
|
||||
```
|
||||
|
||||
## 신규 서비스 추가 체크리스트
|
||||
|
||||
1. `<service>/api/` + `<service>/web/` 디렉토리 생성 (Dockerfile 포함)
|
||||
2. `docker-compose.yml`에 api + web 서비스 추가 (포트 할당)
|
||||
3. `cloudflared.depends_on`에 web 서비스 추가
|
||||
4. Cloudflare Tunnel 대시보드에서 서브도메인 → 컨테이너 라우팅 추가
|
||||
5. `sso-auth-service/config/`에 새 origin 추가
|
||||
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
|
||||
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
|
||||
8. 로그인 리다이렉트: `tkfb.technicalkorea.net/dashboard?redirect=` 패턴 사용
|
||||
|
||||
## 배포 절차
|
||||
|
||||
```bash
|
||||
# 로컬에서 push
|
||||
git push
|
||||
|
||||
# NAS에서 pull + rebuild
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && \
|
||||
git pull && \
|
||||
export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && \
|
||||
docker compose up -d --build <서비스명>"
|
||||
```
|
||||
|
||||
전체 재시작: `docker compose up -d --build`
|
||||
특정 서비스만: `docker compose up -d --build gateway system1-web`
|
||||
82
CLAUDE.md
Normal file
82
CLAUDE.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# CLAUDE.md — TK Factory Services
|
||||
|
||||
## 프로젝트 개요
|
||||
공장관리 플랫폼. 8개 마이크로서비스, 21개+ Docker 컨테이너. Synology NAS 배포, Cloudflare Tunnel.
|
||||
|
||||
## 서비스 맵
|
||||
| 서비스 | 디렉토리 | 스택 | API/Web 포트 | DB |
|
||||
|--------|----------|------|-------------|-----|
|
||||
| Gateway | gateway/ | nginx | 30000 | - |
|
||||
| SSO Auth | sso-auth-service/ | Node.js | 30050 | MariaDB |
|
||||
| System1 공장관리 | system1-factory/ | Node.js + nginx | 30005/30080 | MariaDB |
|
||||
| System2 신고 | system2-report/ | Node.js + nginx | 30105/30180 | MariaDB |
|
||||
| System3 부적합 | system3-nonconformance/ | FastAPI + nginx | 30200/30280 | MariaDB |
|
||||
| tkuser 사용자 | user-management/ | Node.js + nginx | 30300/30380 | MariaDB |
|
||||
| tkpurchase 소모품 | tkpurchase/ | Node.js + nginx | 30400/30480 | MariaDB |
|
||||
| tksafety 안전 | tksafety/ | Node.js + nginx | 30500/30580 | MariaDB |
|
||||
| tksupport 행정 | tksupport/ | Node.js + nginx | 30600/30680 | MariaDB |
|
||||
| tkeg BOM | tkeg/ | FastAPI + React(Vite) | 30700/30780 | PostgreSQL |
|
||||
|
||||
System1에는 FastAPI bridge도 있음 (30008, AI 연동용).
|
||||
|
||||
## 기술 스택
|
||||
- **프론트엔드**: Vanilla JS + Tailwind CDN + Font Awesome (빌드 없음). tkeg만 React+Vite+MUI
|
||||
- **백엔드(Node)**: Express + mysql2/Knex. 패턴: Routes → Controllers → Services → Models → DB
|
||||
- **백엔드(Python)**: FastAPI + SQLAlchemy (System3, tkeg)
|
||||
- **DB**: MariaDB 공유(hyungi) + PostgreSQL(tkeg: tk_bom) + Redis(세션)
|
||||
- **인증**: JWT SSO — sso_token 쿠키(domain=.technicalkorea.net) + localStorage 폴백
|
||||
- **접근레벨**: worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
||||
|
||||
## 코드 규칙
|
||||
1. **캐시 버스팅 필수**: JS/CSS 수정 시 HTML `<script>`/`<link>` 태그의 `?v=YYYYMMDDNN` 반드시 갱신
|
||||
2. **API 응답 포맷**: `{ success: true, data: {...}, message: "..." }` — res.success(), res.paginated() 등
|
||||
3. **커밋 메시지**: `type(scope): 한국어 설명` (예: `fix(tkfb): 모바일 레이아웃 수정`)
|
||||
4. **CSS 전역**: system1-factory → tkfb.css (모든 페이지 로드). 모바일 = @media (max-width: 768px)
|
||||
|
||||
## 배포
|
||||
전제: Tailscale SSH 키 인증 설정 완료 (hyungi@100.71.132.52)
|
||||
```bash
|
||||
git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && git pull && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose up -d --build <서비스명>"
|
||||
```
|
||||
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
|
||||
|
||||
## 개발 주의사항 — 실수 기록
|
||||
|
||||
### admin 계정으로만 테스트하지 말 것
|
||||
admin(role=admin/system)은 대부분의 권한 체크를 건너뜀. 반드시 **일반 사용자(role=user) 계정으로 테스트**할 것.
|
||||
- `pagePermission.js`: admin은 L18에서 `return next()`로 미들웨어 전체 스킵
|
||||
- `tkfb-core.js initAuth()`: admin은 `accessibleKeys` 조회 자체를 안 함
|
||||
- **실제 사례 (2026-04-01)**: `getPool()` async 함수에 `await` 누락 → admin만 통과, 일반 사용자 전원 500 에러. admin 계정으로만 테스트하여 배포 전 미발견
|
||||
|
||||
### workers 테이블과 sso_users 테이블 불일치
|
||||
모든 사용자가 `workers` 테이블에 있지 않음 (생산지원팀 등 사무직). department_id 조회 시 반드시 **sso_users fallback** 사용:
|
||||
```sql
|
||||
COALESCE(w.department_id, su.department_id) AS department_id
|
||||
```
|
||||
- `dashboardModel.js getUserInfo()` — 수정 완료
|
||||
- `pageAccessRoutes.js` 부서 조회 — 수정 완료
|
||||
- **신규 코드 작성 시**: workers JOIN 후 department_id 사용하면 동일 버그 재발
|
||||
|
||||
### 전역 스코프 const vs function 충돌
|
||||
`const`로 선언한 변수명과 다른 스크립트 파일의 `function` 선언이 같은 이름이면 `SyntaxError: Identifier already declared` 발생. 해당 스크립트 **전체**가 실행 안 됨.
|
||||
- **실제 사례 (2026-04-01)**: `tkfb-core.js`에 `const escHtml = escapeHtml;` 추가 → 4개 JS 파일에서 `function escHtml()` 재선언 → 모바일 전체 미작동
|
||||
- **규칙**: 전역 alias 추가 시 `grep -r "function 함수명" --include="*.js"` 로 충돌 확인 필수
|
||||
|
||||
### 캐시 버스팅 누락
|
||||
JS/CSS 수정 후 HTML의 `?v=YYYYMMDDNN` 갱신을 빠뜨리면 브라우저 캐시로 구버전 실행됨.
|
||||
- 특히 `tkfb-core.js`는 **35개 HTML**에서 참조 — 일괄 갱신 필수
|
||||
- `shared-bottom-nav.js`는 5개 HTML에서 참조
|
||||
- **규칙**: JS/CSS 수정 시 해당 파일을 참조하는 모든 HTML의 버전 갱신. `grep -r "파일명" --include="*.html"` 로 대상 확인
|
||||
|
||||
### nginx 프록시 경로 우선순위
|
||||
nginx location 블록은 **더 구체적인 경로가 먼저** 매칭됨. `/api/auth/`를 `/api/` 뒤에 넣으면 `/api/`가 먼저 잡힘.
|
||||
- **실제 사례 (2026-04-01)**: `/api/auth/change-password` 요청이 system1-api로 라우팅되어 404. `/api/auth/` location을 `/api/` 앞에 추가하여 해결
|
||||
|
||||
## 멀티 에이전트 워크플로우
|
||||
이 프로젝트는 Cowork(설계/검토) + Claude Code(코딩) 멀티 에이전트 방식을 지원한다.
|
||||
- **워크플로우 가이드**: `.cowork/WORKFLOW-GUIDE.md`
|
||||
- **스프린트 계획/스펙**: `.cowork/sprints/sprint-NNN/`
|
||||
- **에러 기록**: `.cowork/errors/ERROR-LOG.md`
|
||||
- **템플릿**: `.cowork/templates/`
|
||||
|
||||
Claude Code Worker는 자신의 섹션 스펙(section-*.md)만 읽고 작업할 것. 다른 섹션 파일 수정 금지.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
39
SECURITY-CHECKLIST.md
Normal 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`)
|
||||
- 환경변수 값 강도
|
||||
@@ -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"]
|
||||
|
||||
@@ -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으로 응답하세요."""
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||
ports:
|
||||
- "30306:3306"
|
||||
- "127.0.0.1:30306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
timeout: 20s
|
||||
@@ -66,6 +66,12 @@ services:
|
||||
- SSO_JWT_REFRESH_EXPIRES_IN=${SSO_JWT_REFRESH_EXPIRES_IN:-30d}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
@@ -80,8 +86,8 @@ services:
|
||||
|
||||
system1-api:
|
||||
build:
|
||||
context: ./system1-factory/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: system1-factory/api/Dockerfile
|
||||
container_name: tk-system1-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -103,6 +109,13 @@ services:
|
||||
- REDIS_PORT=6379
|
||||
- WEATHER_API_URL=${WEATHER_API_URL:-}
|
||||
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system1_uploads:/usr/src/app/uploads
|
||||
- system1_logs:/usr/src/app/logs
|
||||
@@ -123,9 +136,12 @@ services:
|
||||
ports:
|
||||
- "30080:80"
|
||||
volumes:
|
||||
- ./system1-factory/web:/usr/share/nginx/html:ro
|
||||
- ./system1-factory/web/public:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- system1-api
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
sso-auth:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -139,8 +155,15 @@ services:
|
||||
- "30008:8000"
|
||||
environment:
|
||||
- API_BASE_URL=http://system1-api:3005
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
- system1-api
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -150,8 +173,8 @@ services:
|
||||
|
||||
system2-api:
|
||||
build:
|
||||
context: ./system2-report/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: system2-report/api/Dockerfile
|
||||
container_name: tk-system2-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -172,6 +195,13 @@ services:
|
||||
- M_PROJECT_USERNAME=${M_PROJECT_USERNAME:-api_service}
|
||||
- M_PROJECT_PASSWORD=${M_PROJECT_PASSWORD:-}
|
||||
- M_PROJECT_DEFAULT_PROJECT_ID=${M_PROJECT_DEFAULT_PROJECT_ID:-1}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system2_uploads:/usr/src/app/uploads
|
||||
- system2_logs:/usr/src/app/logs
|
||||
@@ -192,7 +222,8 @@ services:
|
||||
ports:
|
||||
- "30180:80"
|
||||
depends_on:
|
||||
- system2-api
|
||||
system2-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -220,6 +251,12 @@ services:
|
||||
- ADMIN_USERNAME=${SYSTEM3_ADMIN_USERNAME:-hyungi}
|
||||
- TZ=Asia/Seoul
|
||||
- TKUSER_API_URL=http://tkuser-api:3000
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system3_uploads:/app/uploads
|
||||
depends_on:
|
||||
@@ -239,7 +276,8 @@ services:
|
||||
volumes:
|
||||
- system3_uploads:/usr/share/nginx/html/uploads
|
||||
depends_on:
|
||||
- system3-api
|
||||
system3-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -249,8 +287,8 @@ services:
|
||||
|
||||
tkuser-api:
|
||||
build:
|
||||
context: ./user-management/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: user-management/api/Dockerfile
|
||||
container_name: tk-tkuser-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -264,6 +302,21 @@ services:
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
|
||||
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
|
||||
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
|
||||
- NTFY_SUB_PASSWORD=${NTFY_SUB_PASSWORD}
|
||||
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system1_uploads:/usr/src/app/uploads
|
||||
depends_on:
|
||||
@@ -281,7 +334,8 @@ services:
|
||||
ports:
|
||||
- "30380:80"
|
||||
depends_on:
|
||||
- tkuser-api
|
||||
tkuser-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -291,8 +345,8 @@ services:
|
||||
|
||||
tkpurchase-api:
|
||||
build:
|
||||
context: ./tkpurchase/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: tkpurchase/api/Dockerfile
|
||||
container_name: tk-tkpurchase-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -306,6 +360,13 @@ services:
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
@@ -321,7 +382,8 @@ services:
|
||||
ports:
|
||||
- "30480:80"
|
||||
depends_on:
|
||||
- tkpurchase-api
|
||||
tkpurchase-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -331,8 +393,8 @@ services:
|
||||
|
||||
tksafety-api:
|
||||
build:
|
||||
context: ./tksafety/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: tksafety/api/Dockerfile
|
||||
container_name: tk-tksafety-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -346,6 +408,15 @@ services:
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- tksafety_uploads:/usr/src/app/uploads
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
@@ -360,8 +431,148 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30580:80"
|
||||
volumes:
|
||||
- tksafety_uploads:/usr/share/nginx/html/uploads
|
||||
depends_on:
|
||||
- tksafety-api
|
||||
tksafety-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# Support (tksupport) - 전사 행정지원
|
||||
# =================================================================
|
||||
|
||||
tksupport-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tksupport/api/Dockerfile
|
||||
container_name: tk-tksupport-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30600:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tksupport-web:
|
||||
build:
|
||||
context: ./tksupport/web
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tksupport-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30680:80"
|
||||
depends_on:
|
||||
tksupport-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# TK-EG - BOM 자재관리 (tkeg)
|
||||
# =================================================================
|
||||
|
||||
tkeg-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: tk-tkeg-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: tk_bom
|
||||
POSTGRES_USER: tkbom_user
|
||||
POSTGRES_PASSWORD: ${TKEG_POSTGRES_PASSWORD}
|
||||
TZ: Asia/Seoul
|
||||
volumes:
|
||||
- tkeg_postgres_data:/var/lib/postgresql/data
|
||||
- ./tkeg/api/database/init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tkbom_user -d tk_bom"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkeg-api:
|
||||
build:
|
||||
context: ./tkeg/api
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tkeg-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30700:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://tkbom_user:${TKEG_POSTGRES_PASSWORD}@tkeg-postgres:5432/tk_bom
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SECRET_KEY=${SSO_JWT_SECRET}
|
||||
- TKUSER_API_URL=http://tkuser-api:3000
|
||||
- ENVIRONMENT=production
|
||||
- TZ=Asia/Seoul
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- tkeg_uploads:/app/uploads
|
||||
depends_on:
|
||||
tkeg-postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkeg-web:
|
||||
build:
|
||||
context: ./tkeg/web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
container_name: tk-tkeg-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30780:80"
|
||||
depends_on:
|
||||
tkeg-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# ntfy — 푸시 알림 서버
|
||||
# =================================================================
|
||||
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: tk-ntfy
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
ports:
|
||||
- "30750:80"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
- ./ntfy/etc:/etc/ntfy
|
||||
- ntfy_cache:/var/cache/ntfy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -370,7 +581,7 @@ services:
|
||||
# =================================================================
|
||||
|
||||
# =================================================================
|
||||
# Gateway
|
||||
# Gateway (로그인 + 대시보드 + 공유JS)
|
||||
# =================================================================
|
||||
|
||||
gateway:
|
||||
@@ -382,10 +593,10 @@ services:
|
||||
ports:
|
||||
- "30000:80"
|
||||
depends_on:
|
||||
- sso-auth
|
||||
- system1-web
|
||||
- system2-web
|
||||
- system3-web
|
||||
sso-auth:
|
||||
condition: service_healthy
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -398,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}
|
||||
@@ -423,10 +634,14 @@ services:
|
||||
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
depends_on:
|
||||
- gateway
|
||||
- system1-web
|
||||
- system2-web
|
||||
- system3-web
|
||||
- tkpurchase-web
|
||||
- tksafety-web
|
||||
- tksupport-web
|
||||
- tkeg-web
|
||||
- ntfy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -440,9 +655,13 @@ volumes:
|
||||
system1_logs:
|
||||
system2_uploads:
|
||||
system2_logs:
|
||||
tksafety_uploads:
|
||||
system3_uploads:
|
||||
external: true
|
||||
name: tkqc-package_uploads
|
||||
tkeg_postgres_data:
|
||||
tkeg_uploads:
|
||||
ntfy_cache:
|
||||
networks:
|
||||
tk-network:
|
||||
driver: bridge
|
||||
|
||||
157
docs/SECURITY-GUIDE.md
Normal file
157
docs/SECURITY-GUIDE.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 보안 시스템 운영 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
TK Factory Services에는 2계층 보안 검사 시스템이 적용되어 있습니다.
|
||||
|
||||
| 계층 | 위치 | 역할 | 우회 가능 |
|
||||
|------|------|------|----------|
|
||||
| pre-commit | 로컬 (개발자 PC) | 빠른 피드백 | `--no-verify` |
|
||||
| pre-receive | Gitea 서버 | 최종 차단 | `[SECURITY-BYPASS: 사유]`만 |
|
||||
|
||||
## 개발 워크플로우
|
||||
|
||||
```
|
||||
코드 작성 → git add → git commit
|
||||
↓
|
||||
pre-commit hook
|
||||
(security-scan.sh --staged)
|
||||
↓
|
||||
위반 있으면 → 커밋 차단 + 상세 출력
|
||||
위반 없으면 → 커밋 성공
|
||||
↓
|
||||
git push
|
||||
↓
|
||||
pre-receive hook (서버)
|
||||
(diff 기반 검사)
|
||||
↓
|
||||
위반 있으면 → push 차단
|
||||
위반 없으면 → push 성공
|
||||
```
|
||||
|
||||
## 위반 발생 시 대처
|
||||
|
||||
### 에러 메시지 읽기
|
||||
|
||||
```
|
||||
[SECURITY] 2 issue(s) found:
|
||||
|
||||
✗ [CRITICAL] #1 SECRET_HARDCODE — 비밀정보 하드코딩
|
||||
→ src/controllers/auth.js:64
|
||||
password: 'my-secret-123'
|
||||
```
|
||||
|
||||
- `[CRITICAL]` / `[HIGH]` → 차단됨, 반드시 수정
|
||||
- `[MEDIUM]` → 경고, 5개 초과 시 차단
|
||||
- `→ 파일:라인번호` → 수정할 위치
|
||||
- 아래 줄 → 문제가 된 코드
|
||||
|
||||
### 수정 방법 (규칙별)
|
||||
|
||||
| 규칙 | 수정 방법 |
|
||||
|------|----------|
|
||||
| SECRET_HARDCODE | `process.env.변수명`으로 이동, `.env`에 추가 |
|
||||
| LOCALSTORAGE_AUTH | HttpOnly 쿠키 또는 Authorization 헤더 사용 |
|
||||
| INNERHTML_XSS | `textContent` 사용 또는 DOMPurify 적용 |
|
||||
| CORS_WILDCARD | 허용 도메인을 명시적으로 나열 |
|
||||
| SQL_INTERPOLATION | 파라미터화 쿼리(`?` placeholder) 사용 |
|
||||
| LOG_SECRET | 로그에서 비밀정보 제거 |
|
||||
|
||||
## bypass 사용법 (긴급 시)
|
||||
|
||||
### 형식
|
||||
```
|
||||
git commit -m "fix: 긴급 장애 대응 [SECURITY-BYPASS: prod 서비스 다운 긴급 핫픽스]"
|
||||
```
|
||||
|
||||
### 규칙
|
||||
- **사유 필수**: `[SECURITY-BYPASS]`만으로는 거부됨
|
||||
- **허용 사용자만**: 운영담당자(ahn@hyungi.net)만 bypass 가능
|
||||
- **24시간 내 수정**: bypass 후 반드시 보안 이슈 수정 PR 제출
|
||||
- **로그 기록**: 모든 bypass는 서버에 자동 기록됨
|
||||
|
||||
### bypass 후 조치
|
||||
1. bypass한 코드의 보안 이슈 파악
|
||||
2. 24시간 내 수정 커밋
|
||||
3. `security-scan.sh --all`로 전체 검증
|
||||
|
||||
## 규칙 추가/수정 방법
|
||||
|
||||
### 새 규칙 추가
|
||||
`scripts/security-scan.sh`의 RULES 배열에 추가:
|
||||
```bash
|
||||
'RULE_NAME|SEVERITY|설명|REGEX_PATTERN'
|
||||
```
|
||||
|
||||
예시:
|
||||
```bash
|
||||
'EVAL_USAGE|HIGH|eval 사용 위험|eval\s*\('
|
||||
```
|
||||
|
||||
### 같은 규칙을 서버에도 반영
|
||||
`.githooks/pre-receive-server.sh`의 RULES 배열에도 동일하게 추가.
|
||||
Gitea 서버의 hook 파일도 업데이트 필요.
|
||||
|
||||
## false positive 등록
|
||||
|
||||
### 파일 단위 제외
|
||||
`.securityignore`에 추가 (주석 필수):
|
||||
```
|
||||
path/to/file.js # 사유 설명 (날짜)
|
||||
```
|
||||
|
||||
### 라인 단위 제외
|
||||
소스 코드에 인라인 주석:
|
||||
```javascript
|
||||
const pattern = /password/; // security-ignore: SECRET_HARDCODE — regex 패턴 정의
|
||||
```
|
||||
|
||||
### 주의
|
||||
- 주석 없는 항목은 스캔 시 경고
|
||||
- 월 1회 `.securityignore` 검토하여 불필요 항목 제거
|
||||
|
||||
## 수동 검사
|
||||
|
||||
### 전체 프로젝트 스캔
|
||||
```bash
|
||||
./scripts/security-scan.sh --all
|
||||
```
|
||||
|
||||
### 엄격 모드 (MEDIUM도 차단)
|
||||
```bash
|
||||
./scripts/security-scan.sh --all --strict
|
||||
```
|
||||
|
||||
### 두 커밋 간 비교
|
||||
```bash
|
||||
./scripts/security-scan.sh --diff HEAD~5 HEAD
|
||||
```
|
||||
|
||||
## 초기 설정 (새 머신)
|
||||
|
||||
```bash
|
||||
# 1. git hooks 경로 설정
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# 2. 전체 스캔 확인
|
||||
./scripts/security-scan.sh --all
|
||||
|
||||
# 3. 테스트 (선택)
|
||||
echo "password: 'test'" >> /tmp/test.js
|
||||
git add /tmp/test.js
|
||||
git commit -m "test" # → 차단되어야 함
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: pre-commit이 너무 느리다**
|
||||
A: staged 파일만 검사하므로 보통 1초 이내. 파일이 많으면 `--no-verify`로 우회 후 push 시 서버에서 검사.
|
||||
|
||||
**Q: false positive가 계속 뜬다**
|
||||
A: `.securityignore`에 등록하거나 라인에 `// security-ignore: RULE_NAME` 추가.
|
||||
|
||||
**Q: 규칙을 비활성화하고 싶다**
|
||||
A: RULES 배열에서 해당 규칙을 주석 처리. 단, CRITICAL 규칙 비활성화는 비권장.
|
||||
|
||||
**Q: 새 서비스 추가 시**
|
||||
A: 추가 설정 불필요. `.securityignore`에 제외할 파일이 있으면 등록.
|
||||
870
gateway/html/dashboard.html
Normal file
870
gateway/html/dashboard.html
Normal file
@@ -0,0 +1,870 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TK 대시보드</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Login Form ===== */
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: #1a56db;
|
||||
font-size: 22px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-box .sub {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #1a56db;
|
||||
box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-submit:hover { background: #1e40af; }
|
||||
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Dashboard ===== */
|
||||
.header {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 { font-size: 18px; font-weight: 600; }
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.user-info span { opacity: 0.9; }
|
||||
.btn-logout {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-logout:hover { background: rgba(255,255,255,0.3); }
|
||||
|
||||
.container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
.section { margin-bottom: 28px; }
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Card grid */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.card-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.card-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card:active { transform: translateY(0); }
|
||||
.card-icon { font-size: 22px; flex-shrink: 0; }
|
||||
.card-name { font-size: 13px; font-weight: 500; color: #1f2937; line-height: 1.3; }
|
||||
|
||||
/* System cards */
|
||||
.system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.system-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.system-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 18px 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.system-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.system-card .card-icon { font-size: 26px; }
|
||||
.system-card .card-name { font-size: 14px; font-weight: 600; }
|
||||
|
||||
/* Banner */
|
||||
.banner-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.banner-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.banner-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.banner-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.banner-text { font-size: 14px; font-weight: 500; color: #1f2937; flex: 1; }
|
||||
.banner-arrow { font-size: 18px; color: #9ca3af; }
|
||||
|
||||
/* Coming soon */
|
||||
.badge-soon {
|
||||
display: inline-block;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.card.coming-soon {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.card.coming-soon:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ===== Welcome Section ===== */
|
||||
.welcome-section {
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.welcome-greeting {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e3a5f;
|
||||
}
|
||||
.welcome-meta {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===== Stats Cards ===== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.stat-icon { font-size: 22px; margin-bottom: 4px; }
|
||||
.stat-label { font-size: 12px; color: #6b7280; font-weight: 500; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
|
||||
|
||||
/* ===== Card desc ===== */
|
||||
.card-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.card-desc { font-size: 11px; color: #9ca3af; font-weight: 400; line-height: 1.3; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Form -->
|
||||
<div id="loginView" class="login-wrapper" style="display:none">
|
||||
<div class="login-box">
|
||||
<h1>TK 공장관리 시스템</h1>
|
||||
<p class="sub">통합 로그인</p>
|
||||
<form id="loginForm" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label for="username">사용자명</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">비밀번호</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-submit" id="submitBtn">로그인</button>
|
||||
<p class="error-msg" id="errorMsg"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboardView" style="display:none">
|
||||
<div class="header">
|
||||
<h1>TK 대시보드</h1>
|
||||
<div class="user-info">
|
||||
<span id="userName"></span>
|
||||
<button class="btn-logout" onclick="logout()">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="welcome-section" id="welcomeSection" style="display:none">
|
||||
<div class="welcome-greeting" id="welcomeGreeting"></div>
|
||||
<div class="welcome-meta">
|
||||
<span id="welcomeDate"></span>
|
||||
<span id="welcomeWeather"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="statsSection" style="display:none">
|
||||
<div class="stats-grid" id="statsGrid"></div>
|
||||
</div>
|
||||
<div class="section" id="bannerSection" style="display:none">
|
||||
<div class="banner-list" id="bannerList"></div>
|
||||
</div>
|
||||
<div class="section" id="systemSection" style="display:none">
|
||||
<div class="section-title"><span>🏢</span> 시스템</div>
|
||||
<div class="system-grid" id="systemGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">TK Factory Services v1.0</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== SSO Cookie Utility =====
|
||||
var ssoCookie = {
|
||||
set: function(name, value, days) {
|
||||
var cookie = name + '=' + encodeURIComponent(value) + '; path=/';
|
||||
if (days) cookie += '; max-age=' + (days * 86400);
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
},
|
||||
get: function(name) {
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
remove: function(name) {
|
||||
var cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
};
|
||||
|
||||
function getToken() {
|
||||
return ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||
}
|
||||
function getUser() {
|
||||
var raw = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
try { return JSON.parse(raw); } catch(e) { return null; }
|
||||
}
|
||||
function isTokenValid(token) {
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp > Math.floor(Date.now() / 1000);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function isSafeRedirect(url) {
|
||||
if (!url) return false;
|
||||
if (/^\/[a-zA-Z0-9]/.test(url) && !url.includes('://') && !url.includes('//')) return true;
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return parsed.hostname.endsWith('.technicalkorea.net') || parsed.hostname === 'technicalkorea.net';
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// ===== Subdomain URLs =====
|
||||
function getSubdomainUrl(name) {
|
||||
var hostname = window.location.hostname;
|
||||
var protocol = window.location.protocol;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return protocol + '//' + name + '.technicalkorea.net';
|
||||
}
|
||||
var ports = { tkfb: 30080, tkreport: 30180, tkqc: 30280, tkuser: 30380, tkpurchase: 30480, tksafety: 30580, tksupport: 30680 };
|
||||
return protocol + '//' + hostname + ':' + (ports[name] || 30000);
|
||||
}
|
||||
|
||||
// ===== Banner Definitions =====
|
||||
var BANNERS = [
|
||||
{
|
||||
id: 'notifications',
|
||||
icon: '\uD83D\uDD14',
|
||||
api: '/api/notifications/unread/count',
|
||||
parse: function(data) {
|
||||
var count = data.data && data.data.count;
|
||||
return count > 0 ? { text: '\uBBF8\uD655\uC778 \uC54C\uB9BC ' + count + '\uAC74' } : null;
|
||||
},
|
||||
subdomain: 'tkfb',
|
||||
path: '/pages/profile/notifications.html',
|
||||
color: '#1a56db'
|
||||
},
|
||||
{
|
||||
id: 'tbm',
|
||||
icon: '\uD83D\uDCCB',
|
||||
api: '/api/tbm/sessions/incomplete-reports',
|
||||
parse: function(data) {
|
||||
var items = data.data || data;
|
||||
var count = Array.isArray(items) ? items.length : 0;
|
||||
return count > 0 ? { text: '\uBBF8\uC81C\uCD9C TBM \uBCF4\uACE0 ' + count + '\uAC74' } : null;
|
||||
},
|
||||
subdomain: 'tkfb',
|
||||
path: '/pages/work/tbm.html',
|
||||
color: '#d97706',
|
||||
requirePageKey: 'work.tbm'
|
||||
},
|
||||
{
|
||||
id: 'vacation',
|
||||
icon: '\uD83D\uDCC5',
|
||||
api: '/api/vacation-requests/pending',
|
||||
parse: function(data) {
|
||||
var items = data.data || data;
|
||||
var count = Array.isArray(items) ? items.length : 0;
|
||||
return count > 0 ? { text: '\uD734\uAC00 \uC2B9\uC778 \uB300\uAE30 ' + count + '\uAC74' } : null;
|
||||
},
|
||||
subdomain: 'tkfb',
|
||||
path: '/pages/attendance/vacation-management.html',
|
||||
color: '#7c3aed',
|
||||
requirePageKey: 'attendance.vacation_management'
|
||||
}
|
||||
];
|
||||
|
||||
// ===== Banner Loading =====
|
||||
async function loadBanners(token, allowed) {
|
||||
var container = document.getElementById('bannerList');
|
||||
container.innerHTML = '';
|
||||
|
||||
var visible = BANNERS.filter(function(b) {
|
||||
return !b.requirePageKey || allowed.has(b.requirePageKey);
|
||||
});
|
||||
|
||||
var results = await Promise.allSettled(
|
||||
visible.map(function(b) {
|
||||
return fetch(b.api, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(data) { return data ? { banner: b, result: b.parse(data) } : null; });
|
||||
})
|
||||
);
|
||||
|
||||
var anyVisible = false;
|
||||
results.forEach(function(r) {
|
||||
if (r.status !== 'fulfilled' || !r.value || !r.value.result) return;
|
||||
var b = r.value.banner;
|
||||
var result = r.value.result;
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.className = 'banner-item';
|
||||
a.style.borderLeftColor = b.color;
|
||||
a.href = getSubdomainUrl(b.subdomain) + (b.path || '');
|
||||
a.innerHTML = '<span class="banner-icon">' + b.icon + '</span>'
|
||||
+ '<span class="banner-text">' + result.text + '</span>'
|
||||
+ '<span class="banner-arrow">\u203A</span>';
|
||||
container.appendChild(a);
|
||||
anyVisible = true;
|
||||
});
|
||||
|
||||
if (anyVisible) {
|
||||
document.getElementById('bannerSection').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Card Definitions =====
|
||||
var SYSTEM_CARDS = [
|
||||
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard-new.html', pageKey: 'dashboard', color: '#1a56db' },
|
||||
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
||||
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
|
||||
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
|
||||
{ id: 'safety', name: '안전관리', desc: '안전 점검, 방문 관리', icon: '\uD83E\uDDBA', subdomain: 'tksafety', color: '#7c3aed' },
|
||||
{ id: 'support', name: '행정지원', desc: '전사 행정 업무 지원', icon: '\uD83C\uDFE2', subdomain: 'tksupport', color: '#0284c7' },
|
||||
{ id: 'admin', name: '통합관리', desc: '사용자·권한 관리', icon: '\u2699\uFE0F', subdomain: 'tkuser', minRole: 'admin', color: '#0891b2' }
|
||||
];
|
||||
|
||||
// ===== Rendering =====
|
||||
function resolveHref(card) {
|
||||
if (card.href) return card.href;
|
||||
if (card.subdomain) {
|
||||
var base = getSubdomainUrl(card.subdomain);
|
||||
return card.path ? base + card.path : base;
|
||||
}
|
||||
return '#';
|
||||
}
|
||||
|
||||
function createCardElement(card, isSystem) {
|
||||
var a = document.createElement('a');
|
||||
a.className = isSystem ? 'system-card' : 'card';
|
||||
|
||||
if (isSystem && card.color) {
|
||||
a.style.borderLeftColor = card.color;
|
||||
}
|
||||
|
||||
if (card.comingSoon) {
|
||||
a.className += ' coming-soon';
|
||||
a.href = 'javascript:void(0)';
|
||||
a.onclick = function(e) { e.preventDefault(); };
|
||||
} else {
|
||||
a.href = resolveHref(card);
|
||||
}
|
||||
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'card-icon';
|
||||
iconSpan.textContent = card.icon;
|
||||
a.appendChild(iconSpan);
|
||||
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'card-name';
|
||||
nameSpan.textContent = card.name;
|
||||
|
||||
if (card.comingSoon) {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge-soon';
|
||||
badge.textContent = '\uC900\uBE44\uC911';
|
||||
nameSpan.appendChild(document.createTextNode(' '));
|
||||
nameSpan.appendChild(badge);
|
||||
}
|
||||
|
||||
if (card.desc && isSystem) {
|
||||
var textDiv = document.createElement('div');
|
||||
textDiv.className = 'card-text';
|
||||
textDiv.appendChild(nameSpan);
|
||||
var descSpan = document.createElement('span');
|
||||
descSpan.className = 'card-desc';
|
||||
descSpan.textContent = card.desc;
|
||||
textDiv.appendChild(descSpan);
|
||||
a.appendChild(textDiv);
|
||||
} else {
|
||||
a.appendChild(nameSpan);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function isCardVisible(card, allowed, systemAccess, userRole) {
|
||||
if (card.comingSoon) return true;
|
||||
if (card.minRole) {
|
||||
var roleOrder = ['user','leader','support_team','admin','system'];
|
||||
var userIdx = roleOrder.indexOf(userRole);
|
||||
var minIdx = roleOrder.indexOf(card.minRole);
|
||||
if (userIdx < minIdx) return false;
|
||||
}
|
||||
if (card.pageKey && !allowed.has(card.pageKey)) return false;
|
||||
if (card.accessKey && systemAccess[card.accessKey] === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderSection(sectionId, gridId, cards, allowed, systemAccess, isSystem, userRole) {
|
||||
var visible = cards.filter(function(c) { return isCardVisible(c, allowed, systemAccess, userRole); });
|
||||
if (visible.length === 0) return;
|
||||
|
||||
var grid = document.getElementById(gridId);
|
||||
grid.innerHTML = '';
|
||||
visible.forEach(function(card) {
|
||||
grid.appendChild(createCardElement(card, isSystem));
|
||||
});
|
||||
|
||||
document.getElementById(sectionId).style.display = '';
|
||||
}
|
||||
|
||||
// ===== Dashboard =====
|
||||
async function showDashboard(user, token) {
|
||||
document.getElementById('loginView').style.display = 'none';
|
||||
document.getElementById('dashboardView').style.display = '';
|
||||
document.getElementById('userName').textContent = (user.name || user.username);
|
||||
|
||||
var systemAccess = user.system_access || {};
|
||||
var allowed = new Set();
|
||||
|
||||
// Fetch page access
|
||||
try {
|
||||
var userId = user.user_id || user.id;
|
||||
var res = await fetch('/api/users/' + userId + '/page-access', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success && data.data && data.data.pageAccess) {
|
||||
data.data.pageAccess.forEach(function(p) {
|
||||
if (p.can_access) allowed.add(p.page_key);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback: show cards based on system_access
|
||||
console.warn('page-access API error:', e);
|
||||
if (systemAccess.system1 !== false) {
|
||||
['work.tbm', 'work.report_create', 'inspection.checkin',
|
||||
'attendance.my_vacation_info', 'attendance.vacation_request',
|
||||
'dashboard'].forEach(function(k) { allowed.add(k); });
|
||||
}
|
||||
}
|
||||
|
||||
// A: Welcome section
|
||||
showWelcome(user, token);
|
||||
|
||||
// B: Today stats
|
||||
loadTodayStats(token, allowed);
|
||||
|
||||
// Render banners + system cards
|
||||
var userRole = user.role || 'user';
|
||||
loadBanners(token, allowed);
|
||||
renderSection('systemSection', 'systemGrid', SYSTEM_CARDS, allowed, systemAccess, true, userRole);
|
||||
}
|
||||
|
||||
// ===== A: Welcome =====
|
||||
function getGreeting() {
|
||||
var h = new Date().getHours();
|
||||
if (h >= 6 && h < 12) return '좋은 아침입니다';
|
||||
if (h >= 12 && h < 18) return '좋은 오후입니다';
|
||||
return '좋은 저녁입니다';
|
||||
}
|
||||
|
||||
function getWeatherIcon(sky, precip) {
|
||||
var p = Number(precip);
|
||||
if (p === 1 || p === 2 || p === 4 || p === 5 || p === 6) return '\uD83C\uDF27\uFE0F';
|
||||
if (p === 3 || p === 7) return '\u2744\uFE0F';
|
||||
if (sky === 'overcast') return '\u2601\uFE0F';
|
||||
if (sky === 'cloudy') return '\u26C5';
|
||||
return '\u2600\uFE0F';
|
||||
}
|
||||
|
||||
function getSkyLabel(sky) {
|
||||
if (sky === 'clear') return '맑음';
|
||||
if (sky === 'cloudy') return '구름많음';
|
||||
if (sky === 'overcast') return '흐림';
|
||||
return '';
|
||||
}
|
||||
|
||||
function showWelcome(user, token) {
|
||||
var section = document.getElementById('welcomeSection');
|
||||
var name = user.name || user.username;
|
||||
document.getElementById('welcomeGreeting').textContent = getGreeting() + ', ' + name + '님';
|
||||
|
||||
var now = new Date();
|
||||
var days = ['일','월','화','수','목','금','토'];
|
||||
var dateStr = now.getFullYear() + '년 ' + (now.getMonth()+1) + '월 ' + now.getDate() + '일 (' + days[now.getDay()] + ')';
|
||||
document.getElementById('welcomeDate').textContent = dateStr;
|
||||
|
||||
section.style.display = '';
|
||||
|
||||
// Weather (async, hide on failure)
|
||||
var weatherEl = document.getElementById('welcomeWeather');
|
||||
weatherEl.textContent = '';
|
||||
fetch('/api/tbm/weather/current', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function(json) {
|
||||
var d = json.data;
|
||||
if (!d) return;
|
||||
var icon = getWeatherIcon(d.skyCondition, d.precipitationType);
|
||||
var label = getSkyLabel(d.skyCondition);
|
||||
var temp = d.temperature != null ? Math.round(d.temperature) + '\u00B0C' : '';
|
||||
weatherEl.textContent = '| ' + icon + ' ' + label + ' ' + temp;
|
||||
})
|
||||
.catch(function() { weatherEl.textContent = ''; });
|
||||
}
|
||||
|
||||
// ===== B: Today Stats =====
|
||||
function loadTodayStats(token, allowed) {
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
var cards = [];
|
||||
var fetches = [];
|
||||
|
||||
// Attendance
|
||||
if (allowed.has('attendance.daily') || allowed.has('attendance.vacation_management') || allowed.has('dashboard')) {
|
||||
var idx = cards.length;
|
||||
cards.push({ icon: '\uD83D\uDC77', label: '출근', value: '\u2013', color: '#1a56db', visible: true });
|
||||
fetches.push(
|
||||
fetch('/api/attendance/daily-status?date=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function(json) {
|
||||
var data = json.data;
|
||||
if (Array.isArray(data)) {
|
||||
cards[idx].value = data.filter(function(d) { return d.status !== 'vacation'; }).length + '명';
|
||||
}
|
||||
})
|
||||
.catch(function() { cards[idx].visible = false; })
|
||||
);
|
||||
}
|
||||
|
||||
// Work reports
|
||||
if (allowed.has('dashboard')) {
|
||||
var idx2 = cards.length;
|
||||
cards.push({ icon: '\uD83D\uDD27', label: '작업', value: '\u2013', color: '#059669', visible: true });
|
||||
fetches.push(
|
||||
fetch('/api/work-analysis/dashboard?start=' + today + '&end=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function(json) {
|
||||
var stats = json.data && json.data.stats;
|
||||
if (stats) {
|
||||
cards[idx2].value = (stats.totalReports || 0) + '건';
|
||||
}
|
||||
})
|
||||
.catch(function() { cards[idx2].visible = false; })
|
||||
);
|
||||
}
|
||||
|
||||
// Issues
|
||||
var idx3 = cards.length;
|
||||
cards.push({ icon: '\u26A0\uFE0F', label: '이슈', value: '\u2013', color: '#dc2626', visible: true });
|
||||
fetches.push(
|
||||
fetch('/api/work-issues/stats/summary', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function(json) {
|
||||
var data = json.data;
|
||||
if (data && data.openCount != null) {
|
||||
cards[idx3].value = data.openCount + '건';
|
||||
} else if (data && data.total != null) {
|
||||
cards[idx3].value = data.total + '건';
|
||||
}
|
||||
})
|
||||
.catch(function() { cards[idx3].visible = false; })
|
||||
);
|
||||
|
||||
if (fetches.length === 0) return;
|
||||
|
||||
Promise.allSettled(fetches).then(function() {
|
||||
var visible = cards.filter(function(c) { return c.visible; });
|
||||
if (visible.length === 0) return;
|
||||
|
||||
var grid = document.getElementById('statsGrid');
|
||||
grid.innerHTML = '';
|
||||
visible.forEach(function(c) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'stat-card';
|
||||
div.innerHTML = '<div class="stat-icon">' + c.icon + '</div>'
|
||||
+ '<div class="stat-label">' + c.label + '</div>'
|
||||
+ '<div class="stat-value" style="color:' + c.color + '">' + c.value + '</div>';
|
||||
grid.appendChild(div);
|
||||
});
|
||||
document.getElementById('statsSection').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Login =====
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('submitBtn');
|
||||
var errEl = document.getElementById('errorMsg');
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '\uB85C\uADF8\uC778 \uC911...';
|
||||
|
||||
try {
|
||||
var res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '\uB85C\uADF8\uC778\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4');
|
||||
|
||||
ssoCookie.set('sso_token', data.access_token, 7);
|
||||
ssoCookie.set('sso_user', JSON.stringify(data.user), 7);
|
||||
if (data.refresh_token) ssoCookie.set('sso_refresh_token', data.refresh_token, 30);
|
||||
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
if (redirect && isSafeRedirect(redirect)) {
|
||||
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(data.access_token);
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '\uB85C\uADF8\uC778';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Logout =====
|
||||
function logout() {
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
fetch('/auth/logout', { method: 'POST' }).catch(function(){});
|
||||
window.location.href = '/dashboard?logout=1';
|
||||
}
|
||||
|
||||
// ===== Auth cleanup helpers =====
|
||||
function clearAllAuth() {
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Entry Point =====
|
||||
(function init() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var isLogout = params.get('logout') === '1';
|
||||
|
||||
if (isLogout) clearAllAuth();
|
||||
|
||||
var token = isLogout ? null : getToken();
|
||||
|
||||
if (token && token !== 'undefined' && token !== 'null') {
|
||||
if (isTokenValid(token)) {
|
||||
var user = getUser();
|
||||
if (user) {
|
||||
// Partner redirect
|
||||
if (user.partner_company_id) {
|
||||
window.location.href = getSubdomainUrl('tkfb') + '/pages/partner/partner-portal.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Already logged in + redirect param
|
||||
var redirect = params.get('redirect');
|
||||
if (redirect && isSafeRedirect(redirect)) {
|
||||
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync cookies
|
||||
var existingUser = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
var existingRefresh = ssoCookie.get('sso_refresh_token') || localStorage.getItem('sso_refresh_token');
|
||||
ssoCookie.set('sso_token', token, 7);
|
||||
if (existingUser) ssoCookie.set('sso_user', existingUser, 7);
|
||||
if (existingRefresh) ssoCookie.set('sso_refresh_token', existingRefresh, 30);
|
||||
|
||||
showDashboard(user, token);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Token invalid or no user data
|
||||
clearAllAuth();
|
||||
}
|
||||
|
||||
// Show login
|
||||
document.getElementById('loginView').style.display = 'flex';
|
||||
document.getElementById('dashboardView').style.display = 'none';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,240 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - TK 공장관리</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: #1a56db;
|
||||
font-size: 22px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-box .sub {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #1a56db;
|
||||
box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-submit:hover { background: #1e40af; }
|
||||
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>TK 공장관리 시스템</h1>
|
||||
<p class="sub">통합 로그인</p>
|
||||
|
||||
<form id="loginForm" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label for="username">사용자명</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">비밀번호</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-submit" id="submitBtn">로그인</button>
|
||||
<p class="error-msg" id="errorMsg"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// SSO 쿠키 유틸리티
|
||||
var ssoCookie = {
|
||||
set: function(name, value, days) {
|
||||
var cookie = name + '=' + encodeURIComponent(value) + '; path=/';
|
||||
if (days) cookie += '; max-age=' + (days * 86400);
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
},
|
||||
get: function(name) {
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
remove: function(name) {
|
||||
var cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
};
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('submitBtn');
|
||||
var errEl = document.getElementById('errorMsg');
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '로그인 중...';
|
||||
|
||||
try {
|
||||
var res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
});
|
||||
|
||||
var data = await res.json();
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || '로그인에 실패했습니다');
|
||||
}
|
||||
|
||||
// 쿠키에 토큰 저장 (서브도메인 공유)
|
||||
ssoCookie.set('sso_token', data.access_token, 7);
|
||||
ssoCookie.set('sso_user', JSON.stringify(data.user), 7);
|
||||
if (data.refresh_token) {
|
||||
ssoCookie.set('sso_refresh_token', data.refresh_token, 30);
|
||||
}
|
||||
|
||||
// localStorage에도 저장 (같은 도메인 내 호환성)
|
||||
localStorage.setItem('sso_token', data.access_token);
|
||||
localStorage.setItem('sso_user', JSON.stringify(data.user));
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('sso_refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// redirect 파라미터가 있으면 해당 URL로, 없으면 포털로
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
if (redirect && isSafeRedirect(redirect)) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = '';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '로그인';
|
||||
}
|
||||
}
|
||||
|
||||
// 안전한 리다이렉트인지 확인 (같은 도메인 상대 경로 또는 *.technicalkorea.net)
|
||||
function isSafeRedirect(url) {
|
||||
if (!url) return false;
|
||||
// 상대 경로
|
||||
if (/^\/[a-zA-Z0-9]/.test(url) && !url.includes('://') && !url.includes('//')) {
|
||||
return true;
|
||||
}
|
||||
// technicalkorea.net 서브도메인 절대 URL
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return parsed.hostname.endsWith('.technicalkorea.net') || parsed.hostname === 'technicalkorea.net';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
function isTokenValid(token) {
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp > Math.floor(Date.now() / 1000);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// logout=1 파라미터가 있으면 모든 인증 데이터 정리
|
||||
var isLogout = new URLSearchParams(location.search).get('logout') === '1';
|
||||
if (isLogout) {
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
}
|
||||
|
||||
// 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크 + 만료 확인)
|
||||
var existingToken = isLogout ? null : (ssoCookie.get('sso_token') || localStorage.getItem('sso_token'));
|
||||
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
|
||||
if (isTokenValid(existingToken)) {
|
||||
// 쿠키 재설정 (localStorage에만 있고 쿠키가 없는 경우 대비)
|
||||
var existingUser = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
var existingRefresh = ssoCookie.get('sso_refresh_token') || localStorage.getItem('sso_refresh_token');
|
||||
ssoCookie.set('sso_token', existingToken, 7);
|
||||
if (existingUser) ssoCookie.set('sso_user', existingUser, 7);
|
||||
if (existingRefresh) ssoCookie.set('sso_refresh_token', existingRefresh, 30);
|
||||
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
window.location.href = (redirect && isSafeRedirect(redirect)) ? redirect : '/';
|
||||
} else {
|
||||
// 만료된 토큰 정리
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TK 공장관리 시스템</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.user-info span { opacity: 0.9; }
|
||||
.btn-logout {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-logout:hover { background: rgba(255,255,255,0.3); }
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
flex: 1;
|
||||
}
|
||||
.welcome {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.welcome h2 { font-size: 26px; color: #1f2937; margin-bottom: 8px; }
|
||||
.welcome p { color: #6b7280; font-size: 15px; }
|
||||
.systems {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.system-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.system-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.12);
|
||||
}
|
||||
.system-card.s1 { border-top: 4px solid #1a56db; }
|
||||
.system-card.s2 { border-top: 4px solid #dc2626; }
|
||||
.system-card.s3 { border-top: 4px solid #059669; }
|
||||
.system-icon { font-size: 36px; margin-bottom: 14px; }
|
||||
.system-card h3 { font-size: 18px; color: #1f2937; margin-bottom: 6px; }
|
||||
.system-card p { color: #6b7280; font-size: 13px; line-height: 1.5; }
|
||||
.system-card .badge {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.s1 .badge { background: #dbeafe; color: #1d4ed8; }
|
||||
.s2 .badge { background: #fee2e2; color: #dc2626; }
|
||||
.s3 .badge { background: #d1fae5; color: #059669; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.login-prompt {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.login-prompt h2 { margin-bottom: 16px; color: #1f2937; }
|
||||
.btn-login {
|
||||
display: inline-block;
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-login:hover { background: #1e40af; }
|
||||
.no-access {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>TK 공장관리 시스템</h1>
|
||||
<div class="user-info" id="userInfo" style="display:none">
|
||||
<span id="userName"></span>
|
||||
<span id="userRole"></span>
|
||||
<button class="btn-logout" onclick="logout()">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 로그인 전 -->
|
||||
<div class="login-prompt" id="loginPrompt">
|
||||
<h2>시스템에 접속하려면 로그인하세요</h2>
|
||||
<a href="/login" class="btn-login">로그인</a>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 후 -->
|
||||
<div id="dashboard" style="display:none">
|
||||
<div class="welcome">
|
||||
<h2 id="welcomeText">환영합니다</h2>
|
||||
<p>사용할 시스템을 선택하세요</p>
|
||||
</div>
|
||||
<div class="systems">
|
||||
<a href="/pages/dashboard.html" class="system-card s1" id="card-s1">
|
||||
<div class="system-icon">🏭</div>
|
||||
<h3>공장관리</h3>
|
||||
<p>작업보고, 근태관리, TBM, 순회점검, 장비관리 등 현장 운영 전반</p>
|
||||
<span class="badge">System 1</span>
|
||||
</a>
|
||||
<a id="card-s2-link" class="system-card s2" id="card-s2">
|
||||
<div class="system-icon">🚨</div>
|
||||
<h3>신고 시스템</h3>
|
||||
<p>안전/부적합 이슈 신고, 처리현황 추적, 부적합 자동 연동</p>
|
||||
<span class="badge">System 2</span>
|
||||
</a>
|
||||
<a id="card-s3-link" class="system-card s3" id="card-s3">
|
||||
<div class="system-icon">📋</div>
|
||||
<h3>부적합 관리</h3>
|
||||
<p>부적합 이슈 접수, 처리, 리포트 생성, 프로젝트별 현황 관리</p>
|
||||
<span class="badge">System 3</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">TK Factory Services v1.0</div>
|
||||
|
||||
<script src="/shared/nav-header.js"></script>
|
||||
<script>
|
||||
var ssoCookie = {
|
||||
get: function(name) {
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
remove: function(name) {
|
||||
var cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
};
|
||||
|
||||
function getToken() {
|
||||
return ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||
}
|
||||
function getUser() {
|
||||
var raw = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
try { return JSON.parse(raw); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function init() {
|
||||
var token = getToken();
|
||||
var user = getUser();
|
||||
|
||||
if (token && user) {
|
||||
showDashboard(user);
|
||||
} else {
|
||||
document.getElementById('loginPrompt').style.display = '';
|
||||
document.getElementById('dashboard').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showDashboard(user) {
|
||||
document.getElementById('loginPrompt').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = '';
|
||||
document.getElementById('userInfo').style.display = 'flex';
|
||||
document.getElementById('userName').textContent = user.name || user.username;
|
||||
document.getElementById('userRole').textContent = '(' + (user.role || '') + ')';
|
||||
document.getElementById('welcomeText').textContent =
|
||||
(user.name || user.username) + '님, 환영합니다';
|
||||
|
||||
// 접근 권한에 따라 카드 비활성화
|
||||
const access = user.system_access || {};
|
||||
if (access.system1 === false) document.getElementById('card-s1').classList.add('no-access');
|
||||
if (access.system2 === false) document.getElementById('card-s2').classList.add('no-access');
|
||||
if (access.system3 === false) document.getElementById('card-s3').classList.add('no-access');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
fetch('/auth/logout', { method: 'POST' }).catch(function(){});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// 서브도메인 링크 설정
|
||||
function setupSystemLinks() {
|
||||
var hostname = window.location.hostname;
|
||||
var protocol = window.location.protocol;
|
||||
var s2Link, s3Link;
|
||||
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
s2Link = protocol + '//tkreport.technicalkorea.net';
|
||||
s3Link = protocol + '//tkqc.technicalkorea.net';
|
||||
} else {
|
||||
// 개발 환경: 포트 기반
|
||||
s2Link = protocol + '//' + hostname + ':30180';
|
||||
s3Link = protocol + '//' + hostname + ':30280';
|
||||
}
|
||||
|
||||
document.getElementById('card-s2-link').href = s2Link;
|
||||
document.getElementById('card-s3-link').href = s3Link;
|
||||
}
|
||||
setupSystemLinks();
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -31,11 +31,11 @@
|
||||
*/
|
||||
window.SSOAuth = {
|
||||
getToken: function() {
|
||||
return cookieGet('sso_token') || localStorage.getItem('sso_token');
|
||||
return cookieGet('sso_token');
|
||||
},
|
||||
|
||||
getUser: function() {
|
||||
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
|
||||
var raw = cookieGet('sso_user');
|
||||
try { return JSON.parse(raw); } catch(e) { return null; }
|
||||
},
|
||||
|
||||
@@ -62,10 +62,10 @@
|
||||
var loginUrl;
|
||||
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/login';
|
||||
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
|
||||
} else {
|
||||
// 개발 환경: Gateway 포트 (30000)
|
||||
loginUrl = window.location.protocol + '//' + hostname + ':30000/login';
|
||||
// 개발 환경: tkds 포트 (30780)
|
||||
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
|
||||
559
gateway/html/shared/notification-bell.js
Normal file
559
gateway/html/shared/notification-bell.js
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 공유 알림 벨 — 모든 서비스 헤더에 자동 삽입
|
||||
*
|
||||
* 사용법: initAuth() 성공 후 동적 <script> 로드
|
||||
* const s = document.createElement('script');
|
||||
* s.src = '/shared/notification-bell.js?v=1';
|
||||
* document.head.appendChild(s);
|
||||
*
|
||||
* 요구사항: SSOAuth (nav-header.js) 또는 getToken() 함수 존재
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ========== Config ========== */
|
||||
var POLL_INTERVAL = 60000; // 60초
|
||||
var DROPDOWN_LIMIT = 5;
|
||||
var API_ORIGIN = (function () {
|
||||
var h = window.location.hostname;
|
||||
if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net';
|
||||
return window.location.protocol + '//' + h + ':30300';
|
||||
})();
|
||||
var API_BASE = API_ORIGIN + '/api/notifications';
|
||||
var PUSH_API_BASE = API_ORIGIN + '/api/push';
|
||||
|
||||
/* ========== Token helper ========== */
|
||||
function _getToken() {
|
||||
if (window.SSOAuth && window.SSOAuth.getToken) return window.SSOAuth.getToken();
|
||||
if (typeof getToken === 'function') return getToken();
|
||||
// cookie fallback
|
||||
var m = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function _authFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
var token = _getToken();
|
||||
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
|
||||
return fetch(url, opts);
|
||||
}
|
||||
|
||||
/* ========== State ========== */
|
||||
var pollTimer = null;
|
||||
var unreadCount = 0;
|
||||
var dropdownOpen = false;
|
||||
var pushSubscribed = false;
|
||||
var ntfySubscribed = false;
|
||||
|
||||
/* ========== UI: Bell injection ========== */
|
||||
function injectBell() {
|
||||
var header = document.querySelector('header');
|
||||
if (!header) return;
|
||||
|
||||
// 벨 컨테이너
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.id = 'notif-bell-wrapper';
|
||||
wrapper.style.cssText = 'position:relative;display:inline-flex;align-items:center;cursor:pointer;margin-left:12px;';
|
||||
|
||||
wrapper.innerHTML =
|
||||
'<div id="notif-bell-btn" style="position:relative;padding:6px;" title="알림">' +
|
||||
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#4B5563;">' +
|
||||
'<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>' +
|
||||
'<path d="M13.73 21a2 2 0 0 1-3.46 0"/>' +
|
||||
'</svg>' +
|
||||
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
|
||||
'</div>' +
|
||||
'<div id="notif-dropdown" style="display:none;position:fixed;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;">' +
|
||||
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
|
||||
'<div style="display:flex;gap:8px;align-items:center;">' +
|
||||
'<button id="notif-ntfy-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="ntfy 앱 알림">ntfy</button>' +
|
||||
'<button id="notif-push-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="Push 알림 설정">Push</button>' +
|
||||
'<button id="notif-read-all" style="font-size:12px;color:#3B82F6;background:none;border:none;cursor:pointer;">모두 읽음</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="notif-list" style="max-height:300px;overflow-y:auto;"></div>' +
|
||||
'<a id="notif-view-all" href="' + _getAllNotificationsUrl() + '" style="display:block;text-align:center;padding:10px;font-size:13px;color:#3B82F6;text-decoration:none;border-top:1px solid #F3F4F6;">전체보기</a>' +
|
||||
'</div>';
|
||||
|
||||
// 삽입 위치: header 내부, 우측 영역 찾기
|
||||
var rightArea = header.querySelector('.flex.items-center.gap-3') ||
|
||||
header.querySelector('.flex.items-center.gap-4') ||
|
||||
header.querySelector('.flex.items-center.space-x-4') ||
|
||||
header.querySelector('[class*="items-center"]');
|
||||
|
||||
if (rightArea) {
|
||||
// 로그아웃 버튼 앞에 삽입
|
||||
var logoutBtn = rightArea.querySelector('button[onclick*="Logout"], button[onclick*="logout"]');
|
||||
if (logoutBtn) {
|
||||
rightArea.insertBefore(wrapper, logoutBtn);
|
||||
} else {
|
||||
rightArea.insertBefore(wrapper, rightArea.firstChild);
|
||||
}
|
||||
} else {
|
||||
header.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('notif-bell-btn').addEventListener('click', toggleDropdown);
|
||||
document.getElementById('notif-read-all').addEventListener('click', markAllRead);
|
||||
document.getElementById('notif-push-toggle').addEventListener('click', handlePushToggle);
|
||||
document.getElementById('notif-ntfy-toggle').addEventListener('click', handleNtfyToggle);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
document.addEventListener('click', function (e) {
|
||||
if (dropdownOpen && !wrapper.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _getAllNotificationsUrl() {
|
||||
var h = window.location.hostname;
|
||||
if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net/?tab=notificationRecipients';
|
||||
return window.location.protocol + '//' + h + ':30380/?tab=notificationRecipients';
|
||||
}
|
||||
|
||||
/* ========== UI: Badge ========== */
|
||||
function updateBadge(count) {
|
||||
unreadCount = count;
|
||||
var badge = document.getElementById('notif-badge');
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.style.display = 'block';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== UI: Dropdown ========== */
|
||||
function toggleDropdown(e) {
|
||||
e && e.stopPropagation();
|
||||
if (dropdownOpen) { closeDropdown(); return; }
|
||||
openDropdown();
|
||||
}
|
||||
|
||||
function onScrollWhileOpen() {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function openDropdown() {
|
||||
dropdownOpen = true;
|
||||
var dd = document.getElementById('notif-dropdown');
|
||||
var btn = document.getElementById('notif-bell-btn');
|
||||
var rect = btn.getBoundingClientRect();
|
||||
|
||||
// 드롭다운 너비: 뷰포트 좁으면 양쪽 8px 여백
|
||||
var ddWidth = Math.min(340, window.innerWidth - 16);
|
||||
dd.style.width = ddWidth + 'px';
|
||||
dd.style.top = (rect.bottom + 4) + 'px';
|
||||
|
||||
// 우측 정렬 기본, 왼쪽 넘치면 보정
|
||||
var rightOffset = window.innerWidth - rect.right;
|
||||
if (rightOffset + ddWidth > window.innerWidth - 8) {
|
||||
dd.style.right = 'auto';
|
||||
dd.style.left = '8px';
|
||||
} else {
|
||||
dd.style.left = 'auto';
|
||||
dd.style.right = Math.max(8, rightOffset) + 'px';
|
||||
}
|
||||
|
||||
dd.style.display = 'block';
|
||||
window.addEventListener('scroll', onScrollWhileOpen, { once: true });
|
||||
loadNotifications();
|
||||
updatePushToggleUI();
|
||||
updateNtfyToggleUI();
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen = false;
|
||||
document.getElementById('notif-dropdown').style.display = 'none';
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
var list = document.getElementById('notif-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">로딩 중...</div>';
|
||||
|
||||
_authFetch(API_BASE + '/unread')
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data.success || !data.data || data.data.length === 0) {
|
||||
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
var items = data.data.slice(0, DROPDOWN_LIMIT);
|
||||
list.innerHTML = items.map(function (n) {
|
||||
var timeAgo = _timeAgo(n.created_at);
|
||||
var typeLabel = _typeLabel(n.type);
|
||||
return '<div class="notif-item" data-id="' + n.notification_id + '" data-url="' + _escAttr(n.link_url || '') + '" style="padding:10px 16px;border-bottom:1px solid #F9FAFB;cursor:pointer;transition:background .15s;" onmouseover="this.style.background=\'#F9FAFB\'" onmouseout="this.style.background=\'transparent\'">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
||||
'<span style="font-size:10px;background:#EFF6FF;color:#3B82F6;padding:1px 6px;border-radius:3px;font-weight:500;">' + _escHtml(typeLabel) + '</span>' +
|
||||
'<span style="font-size:11px;color:#9CA3AF;">' + _escHtml(timeAgo) + '</span>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:13px;font-weight:500;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.title) + '</div>' +
|
||||
(n.message ? '<div style="font-size:12px;color:#6B7280;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.message) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div style="width:8px;height:8px;border-radius:50%;background:#3B82F6;flex-shrink:0;margin-top:6px;margin-left:8px;"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// 클릭 이벤트
|
||||
list.querySelectorAll('.notif-item').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var id = el.getAttribute('data-id');
|
||||
var url = el.getAttribute('data-url');
|
||||
_authFetch(API_BASE + '/' + id + '/read', { method: 'POST' })
|
||||
.then(function () { fetchUnreadCount(); })
|
||||
.catch(function () {});
|
||||
if (url) {
|
||||
// 같은 서비스 내 URL이면 직접 이동, 아니면 새 탭
|
||||
if (url.startsWith('/')) window.location.href = url;
|
||||
else window.open(url, '_blank');
|
||||
}
|
||||
closeDropdown();
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
list.innerHTML = '<div style="padding:20px;text-align:center;color:#EF4444;font-size:13px;">잠시 후 다시 시도해주세요</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function markAllRead(e) {
|
||||
e && e.stopPropagation();
|
||||
_authFetch(API_BASE + '/read-all', { method: 'POST' })
|
||||
.then(function () {
|
||||
updateBadge(0);
|
||||
var list = document.getElementById('notif-list');
|
||||
if (list) list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
/* ========== Polling ========== */
|
||||
function fetchUnreadCount() {
|
||||
_authFetch(API_BASE + '/unread/count')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success) updateBadge(data.data.count);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
fetchUnreadCount();
|
||||
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
|
||||
/* ========== Web Push ========== */
|
||||
function handlePushToggle(e) {
|
||||
e && e.stopPropagation();
|
||||
if (pushSubscribed) {
|
||||
unsubscribePush();
|
||||
} else {
|
||||
subscribePush();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribePush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
alert('이 브라우저는 Push 알림을 지원하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// VAPID 공개키 가져오기
|
||||
fetch(PUSH_API_BASE + '/vapid-public-key')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success || !data.data.vapidPublicKey) {
|
||||
alert('Push 설정을 불러올 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
var vapidKey = data.data.vapidPublicKey;
|
||||
|
||||
return navigator.serviceWorker.getRegistration('/').then(function (reg) {
|
||||
if (!reg) {
|
||||
// push-sw.js 등록
|
||||
return navigator.serviceWorker.register('/push-sw.js', { scope: '/' });
|
||||
}
|
||||
return reg;
|
||||
}).then(function (reg) {
|
||||
return reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: _urlBase64ToUint8Array(vapidKey)
|
||||
});
|
||||
}).then(function (subscription) {
|
||||
// 서버에 구독 저장
|
||||
return _authFetch(PUSH_API_BASE + '/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() })
|
||||
});
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
if (result.success) {
|
||||
pushSubscribed = true;
|
||||
stopPolling(); // Push 구독 성공 → 폴링 중단
|
||||
updatePushToggleUI();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[notification-bell] Push subscribe error:', err);
|
||||
if (err.name === 'NotAllowedError') {
|
||||
alert('알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribePush() {
|
||||
navigator.serviceWorker.getRegistration('/').then(function (reg) {
|
||||
if (!reg) return;
|
||||
return reg.pushManager.getSubscription();
|
||||
}).then(function (sub) {
|
||||
if (!sub) return;
|
||||
var endpoint = sub.endpoint;
|
||||
return sub.unsubscribe().then(function () {
|
||||
return _authFetch(PUSH_API_BASE + '/unsubscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: endpoint })
|
||||
});
|
||||
});
|
||||
}).then(function () {
|
||||
pushSubscribed = false;
|
||||
startPolling(); // Push 해제 → 폴링 복구
|
||||
updatePushToggleUI();
|
||||
}).catch(function (err) {
|
||||
console.error('[notification-bell] Push unsubscribe error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function checkPushStatus() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
navigator.serviceWorker.getRegistration('/').then(function (reg) {
|
||||
if (!reg) return;
|
||||
return reg.pushManager.getSubscription();
|
||||
}).then(function (sub) {
|
||||
if (sub) {
|
||||
pushSubscribed = true;
|
||||
stopPolling(); // 이미 구독 상태면 폴링 불필요
|
||||
// Push로 뱃지만 갱신 (초기 1회)
|
||||
fetchUnreadCount();
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function updatePushToggleUI() {
|
||||
var btn = document.getElementById('notif-push-toggle');
|
||||
if (!btn) return;
|
||||
if (ntfySubscribed) {
|
||||
// ntfy 활성화 시 Web Push 비활성화
|
||||
btn.textContent = 'Push';
|
||||
btn.style.borderColor = '#E5E7EB';
|
||||
btn.style.color = '#D1D5DB';
|
||||
btn.disabled = true;
|
||||
btn.title = 'ntfy 사용 중에는 Push를 사용할 수 없습니다';
|
||||
} else if (pushSubscribed) {
|
||||
btn.textContent = 'Push 해제';
|
||||
btn.style.borderColor = '#EF4444';
|
||||
btn.style.color = '#EF4444';
|
||||
btn.disabled = false;
|
||||
btn.title = 'Push 알림 해제';
|
||||
} else {
|
||||
btn.textContent = 'Push';
|
||||
btn.style.borderColor = '#D1D5DB';
|
||||
btn.style.color = '#6B7280';
|
||||
btn.disabled = false;
|
||||
btn.title = 'Push 알림 설정';
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== ntfy ========== */
|
||||
function handleNtfyToggle(e) {
|
||||
e && e.stopPropagation();
|
||||
if (ntfySubscribed) {
|
||||
unsubscribeNtfy();
|
||||
} else {
|
||||
subscribeNtfy();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeNtfy() {
|
||||
_authFetch(PUSH_API_BASE + '/ntfy/subscribe', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success) {
|
||||
alert('ntfy 구독 등록 실패');
|
||||
return;
|
||||
}
|
||||
ntfySubscribed = true;
|
||||
updateNtfyToggleUI();
|
||||
updatePushToggleUI();
|
||||
showNtfySetupModal(data.data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[notification-bell] ntfy subscribe error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribeNtfy() {
|
||||
_authFetch(PUSH_API_BASE + '/ntfy/unsubscribe', { method: 'DELETE' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
ntfySubscribed = false;
|
||||
updateNtfyToggleUI();
|
||||
updatePushToggleUI();
|
||||
checkPushStatus();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[notification-bell] ntfy unsubscribe error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function checkNtfyStatus() {
|
||||
_authFetch(PUSH_API_BASE + '/ntfy/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success && data.data.subscribed) {
|
||||
ntfySubscribed = true;
|
||||
updateNtfyToggleUI();
|
||||
updatePushToggleUI();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function updateNtfyToggleUI() {
|
||||
var btn = document.getElementById('notif-ntfy-toggle');
|
||||
if (!btn) return;
|
||||
if (ntfySubscribed) {
|
||||
btn.textContent = 'ntfy 해제';
|
||||
btn.style.borderColor = '#EF4444';
|
||||
btn.style.color = '#EF4444';
|
||||
} else {
|
||||
btn.textContent = 'ntfy';
|
||||
btn.style.borderColor = '#D1D5DB';
|
||||
btn.style.color = '#6B7280';
|
||||
}
|
||||
}
|
||||
|
||||
function showNtfySetupModal(info) {
|
||||
// 기존 모달 제거
|
||||
var existing = document.getElementById('ntfy-setup-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'ntfy-setup-modal';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
||||
|
||||
var modal = document.createElement('div');
|
||||
modal.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:380px;width:90%;box-shadow:0 20px 40px rgba(0,0,0,.2);';
|
||||
modal.innerHTML =
|
||||
'<h3 style="margin:0 0 16px;font-size:16px;color:#111827;">ntfy 앱 설정 안내</h3>' +
|
||||
'<div style="font-size:13px;color:#374151;line-height:1.6;">' +
|
||||
'<p style="margin:0 0 12px;"><strong>1.</strong> ntfy 앱 설치<br>' +
|
||||
'<span style="color:#6B7280;">Android: Play Store / iOS: App Store</span></p>' +
|
||||
'<p style="margin:0 0 12px;"><strong>2.</strong> 앱에서 서버 추가<br>' +
|
||||
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.serverUrl) + '</code></p>' +
|
||||
'<p style="margin:0 0 12px;"><strong>3.</strong> 계정 로그인<br>' +
|
||||
'아이디: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.username) + '</code><br>' +
|
||||
'비밀번호: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.password) + '</code></p>' +
|
||||
'<p style="margin:0;"><strong>4.</strong> 토픽 구독<br>' +
|
||||
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.topic) + '</code></p>' +
|
||||
'</div>' +
|
||||
'<button id="ntfy-modal-close" style="margin-top:16px;width:100%;padding:10px;background:#3B82F6;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;">확인</button>';
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('ntfy-modal-close').addEventListener('click', function () {
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== Push SW message handler ========== */
|
||||
function listenForPushMessages() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.addEventListener('message', function (e) {
|
||||
if (e.data && e.data.type === 'NOTIFICATION_RECEIVED') {
|
||||
// Push 수신 시 뱃지 즉시 갱신
|
||||
fetchUnreadCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== Helpers ========== */
|
||||
function _timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var now = Date.now();
|
||||
var then = new Date(dateStr).getTime();
|
||||
var diff = Math.floor((now - then) / 1000);
|
||||
if (diff < 60) return '방금 전';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
|
||||
if (diff < 604800) return Math.floor(diff / 86400) + '일 전';
|
||||
return dateStr.substring(0, 10);
|
||||
}
|
||||
|
||||
function _typeLabel(type) {
|
||||
var map = { system: '시스템', repair: '설비수리', safety: '안전', nonconformity: '부적합', partner_work: '협력업체', day_labor: '일용공' };
|
||||
return map[type] || type || '알림';
|
||||
}
|
||||
|
||||
function _escHtml(s) {
|
||||
if (!s) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _escAttr(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function _urlBase64ToUint8Array(base64String) {
|
||||
var padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
var raw = atob(base64);
|
||||
var arr = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* ========== Init ========== */
|
||||
function init() {
|
||||
// 토큰 없으면 동작하지 않음
|
||||
if (!_getToken()) return;
|
||||
|
||||
injectBell();
|
||||
startPolling();
|
||||
checkNtfyStatus();
|
||||
checkPushStatus();
|
||||
listenForPushMessages();
|
||||
}
|
||||
|
||||
// DOM ready 또는 즉시 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -1,36 +1,52 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# ===== Gateway 자체 페이지 (포털, 로그인) =====
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 로그인 페이지 (캐시 금지 — SSO 쿠키 재설정 로직 항상 최신 반영)
|
||||
location = /login {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 대시보드 (로그인 포함)
|
||||
location = /dashboard {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
try_files /login.html =404;
|
||||
try_files /dashboard.html =404;
|
||||
}
|
||||
|
||||
# 공유 JS/CSS (nav-header 등)
|
||||
location = / {
|
||||
return 302 /dashboard$is_args$args;
|
||||
}
|
||||
|
||||
# 레거시 /login → /dashboard 리다이렉트
|
||||
location = /login {
|
||||
return 302 /dashboard$is_args$args;
|
||||
}
|
||||
|
||||
# 공유 JS (notification-bell.js, nav-header.js)
|
||||
location /shared/ {
|
||||
alias /usr/share/nginx/html/shared/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# ===== SSO Auth API =====
|
||||
# SSO Auth 프록시
|
||||
location /auth/ {
|
||||
proxy_pass http://sso-auth:3000/api/auth/;
|
||||
set $upstream http://sso-auth:3000;
|
||||
rewrite ^/auth/(.*)$ /api/auth/$1 break;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== System 1 API 프록시 =====
|
||||
# System1 API 프록시 (대시보드 배너용: 알림/TBM/휴가 카운트)
|
||||
location /api/ {
|
||||
proxy_pass http://system1-api:3005/api/;
|
||||
set $upstream http://system1-api:3005;
|
||||
proxy_pass $upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -38,50 +54,9 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== System 1 업로드 파일 =====
|
||||
location /uploads/ {
|
||||
proxy_pass http://system1-api:3005/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ===== System 1 FastAPI Bridge =====
|
||||
location /fastapi/ {
|
||||
proxy_pass http://system1-fastapi:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== AI Service API (맥미니 home-service-proxy 경유) =====
|
||||
location /ai-api/ {
|
||||
resolver 8.8.8.8 valid=300s ipv6=off;
|
||||
set $ai_upstream https://ai.hyungi.net;
|
||||
rewrite ^/ai-api/(.*) /api/ai/$1 break;
|
||||
proxy_pass $ai_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host ai.hyungi.net;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_send_timeout 180s;
|
||||
}
|
||||
|
||||
# ===== System 1 Web (나머지 모든 경로) =====
|
||||
location / {
|
||||
proxy_pass http://system1-web:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== Health Check =====
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok","service":"gateway"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
|
||||
167
ntfy/README.md
Normal file
167
ntfy/README.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# ntfy 푸시 알림 서버 — 운영 매뉴얼
|
||||
|
||||
## 개요
|
||||
|
||||
ntfy는 Web Push(VAPID)의 iOS 제한, 전송 보장 부재 등을 보완하는 푸시 알림 채널이다.
|
||||
모바일 ntfy 앱으로 알림을 수신하고, 탭하면 딥링크로 해당 페이지로 이동한다.
|
||||
|
||||
- **Docker 서비스명**: `ntfy`
|
||||
- **내부 URL**: `http://ntfy:80` (Docker 네트워크)
|
||||
- **외부 URL**: `https://ntfy.technicalkorea.net` (Cloudflare Tunnel)
|
||||
- **호스트 포트**: `30750`
|
||||
- **설정 파일**: `ntfy/etc/server.yml`
|
||||
- **데이터**: `ntfy_cache` Docker 볼륨 (`/var/cache/ntfy`)
|
||||
|
||||
## 초기 설정 (최초 1회)
|
||||
|
||||
### 1. Cloudflare Tunnel 설정
|
||||
|
||||
Zero Trust 대시보드 → Tunnels → Public Hostname 추가:
|
||||
|
||||
| Subdomain | Domain | Service |
|
||||
|-----------|--------|---------|
|
||||
| ntfy | technicalkorea.net | http://ntfy:80 |
|
||||
|
||||
### 2. 컨테이너 기동
|
||||
|
||||
```bash
|
||||
docker compose up -d ntfy
|
||||
```
|
||||
|
||||
### 3. 관리자 계정 + 토큰 발급
|
||||
|
||||
```bash
|
||||
# 관리자 계정 생성 (비밀번호 입력 프롬프트)
|
||||
docker exec -it tk-ntfy ntfy user add --role=admin admin
|
||||
|
||||
# API 토큰 발급
|
||||
docker exec -it tk-ntfy ntfy token add admin
|
||||
# 출력 예: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
```
|
||||
|
||||
### 4. .env에 토큰 설정
|
||||
|
||||
```env
|
||||
NTFY_PUBLISH_TOKEN=<위에서 발급받은 토큰>
|
||||
```
|
||||
|
||||
## 메시지 발행 (서버 → 사용자)
|
||||
|
||||
### curl로 테스트
|
||||
|
||||
```bash
|
||||
# 기본 메시지
|
||||
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
|
||||
-d "테스트 메시지입니다." \
|
||||
http://localhost:30750/tkfactory-test
|
||||
|
||||
# 제목 + 딥링크 포함
|
||||
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
|
||||
-H "Title: 설비수리 요청" \
|
||||
-H "Tags: wrench" \
|
||||
-H "Click: https://tkfb.technicalkorea.net/pages/admin/repair-management.html" \
|
||||
-d "A동 CNC 설비 수리 요청이 접수되었습니다." \
|
||||
http://localhost:30750/tkfactory-user-1
|
||||
|
||||
# 우선순위 높은 알림 (5=max, 3=default, 1=min)
|
||||
curl -H "Authorization: Bearer $NTFY_PUBLISH_TOKEN" \
|
||||
-H "Title: 긴급 안전 알림" \
|
||||
-H "Priority: 5" \
|
||||
-H "Tags: warning" \
|
||||
-d "즉시 확인이 필요합니다." \
|
||||
http://localhost:30750/tkfactory-user-1
|
||||
```
|
||||
|
||||
### 토픽 네이밍 규칙
|
||||
|
||||
| 토픽 | 용도 |
|
||||
|------|------|
|
||||
| `tkfactory-user-{userId}` | 사용자별 개인 알림 (Phase 2에서 사용) |
|
||||
| `tkfactory-test` | 테스트용 |
|
||||
|
||||
### 주요 헤더
|
||||
|
||||
| 헤더 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `Title` | 알림 제목 | `설비수리 요청` |
|
||||
| `Click` | 탭 시 열릴 URL (딥링크) | `https://tkfb.technicalkorea.net/pages/work/tbm.html` |
|
||||
| `Tags` | 이모지 태그 ([목록](https://docs.ntfy.sh/emojis/)) | `wrench`, `warning`, `white_check_mark` |
|
||||
| `Priority` | 1(min) ~ 5(max) | `5` |
|
||||
| `Authorization` | 인증 토큰 | `Bearer tk_...` |
|
||||
|
||||
## 사용자 관리
|
||||
|
||||
```bash
|
||||
# 사용자 목록
|
||||
docker exec tk-ntfy ntfy user list
|
||||
|
||||
# 일반 사용자 추가
|
||||
docker exec -it tk-ntfy ntfy user add username
|
||||
|
||||
# 사용자 삭제
|
||||
docker exec tk-ntfy ntfy user del username
|
||||
|
||||
# 특정 토픽 접근 권한 부여 (read-write / read-only / write-only)
|
||||
docker exec tk-ntfy ntfy access username 'tkfactory-user-*' read-only
|
||||
|
||||
# 토큰 발급
|
||||
docker exec tk-ntfy ntfy token add username
|
||||
|
||||
# 토큰 목록
|
||||
docker exec tk-ntfy ntfy token list
|
||||
|
||||
# 토큰 삭제
|
||||
docker exec tk-ntfy ntfy token remove username tk_...
|
||||
```
|
||||
|
||||
## 모바일 앱 설정 (수신자용)
|
||||
|
||||
### Android
|
||||
1. Play Store에서 **ntfy** 설치
|
||||
2. 설정(⚙️) → **Default server** → `https://ntfy.technicalkorea.net` 입력
|
||||
3. 우측 상단 사용자 아이콘 → 로그인 (발급받은 계정/비밀번호 또는 토큰)
|
||||
4. **+** → 토픽 `tkfactory-user-{본인userId}` 구독
|
||||
|
||||
### iOS
|
||||
1. App Store에서 **ntfy** 설치
|
||||
2. Settings → **Default server** → `https://ntfy.technicalkorea.net` 입력
|
||||
3. 로그인 후 토픽 구독 (Android와 동일)
|
||||
|
||||
## 서버 설정 (server.yml)
|
||||
|
||||
| 항목 | 현재 값 | 설명 |
|
||||
|------|---------|------|
|
||||
| `auth-default-access` | `deny-all` | 인증 없이 접근 불가 |
|
||||
| `cache-duration` | `72h` | 메시지 보관 기간 (주말 포함 3일) |
|
||||
| `visitor-request-limit-burst` | `60` | 버스트 요청 한도 |
|
||||
| `visitor-request-limit-replenish` | `5s` | 요청 한도 보충 주기 |
|
||||
|
||||
설정 변경 후 컨테이너 재시작:
|
||||
```bash
|
||||
docker compose restart ntfy
|
||||
```
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 401 Unauthorized
|
||||
- 토큰이 맞는지 확인: `docker exec tk-ntfy ntfy token list`
|
||||
- `auth-default-access: deny-all` 상태에서 토큰 없이 요청하면 발생
|
||||
|
||||
### 모바일 앱에서 알림이 안 옴
|
||||
- Cloudflare Tunnel Public Hostname에 `ntfy.technicalkorea.net` 등록되었는지 확인
|
||||
- 앱의 Default server URL이 `https://ntfy.technicalkorea.net`인지 확인
|
||||
- 앱에서 로그인했는지, 토픽을 구독했는지 확인
|
||||
- 폰 설정에서 ntfy 앱 알림 권한이 켜져 있는지 확인
|
||||
|
||||
### 컨테이너 로그 확인
|
||||
```bash
|
||||
docker logs tk-ntfy --tail 50
|
||||
docker logs tk-ntfy -f # 실시간
|
||||
```
|
||||
|
||||
## Phase 2 예정 사항 (참고)
|
||||
|
||||
- `system1-factory/api/models/notificationModel.js`의 `sendPushToUsers()`에서 ntfy 발송 연동
|
||||
- `push_subscriptions` 테이블에 `channel` 컬럼 추가 (`web_push` | `ntfy`)
|
||||
- ntfy 구독 사용자에게는 Web Push 미발송 (중복 방지)
|
||||
- notification-bell.js에서 ntfy 구독 토글 UI 추가
|
||||
23
ntfy/etc/server.yml
Normal file
23
ntfy/etc/server.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# ntfy server configuration
|
||||
# Docs: https://docs.ntfy.sh/config/
|
||||
|
||||
# Base URL (Cloudflare Tunnel 경유)
|
||||
base-url: "https://ntfy.technicalkorea.net"
|
||||
|
||||
# Listen on port 80 inside the container
|
||||
listen-http: ":80"
|
||||
|
||||
# Cache — 72시간 (금요일 알림 → 월요일 확인 가능)
|
||||
cache-duration: "72h"
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
|
||||
# Auth — 기본 접근 거부, 토큰 인증 필수
|
||||
auth-default-access: "deny-all"
|
||||
auth-file: "/var/cache/ntfy/user.db"
|
||||
|
||||
# Attachment (비활성화 — Phase 1에서는 텍스트 알림만)
|
||||
# attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
|
||||
# Rate limiting
|
||||
visitor-request-limit-burst: 60
|
||||
visitor-request-limit-replenish: "5s"
|
||||
94
scripts/check-webroot-security.sh
Executable file
94
scripts/check-webroot-security.sh
Executable 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
|
||||
@@ -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
355
scripts/security-scan.sh
Executable file
@@ -0,0 +1,355 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# security-scan.sh — TK Factory Services 보안 스캔 엔진
|
||||
# =============================================================================
|
||||
# 용도: pre-commit hook, pre-receive hook, 수동 전체 검사
|
||||
# 모드:
|
||||
# --staged staged 파일만 검사 (pre-commit 기본)
|
||||
# --all 프로젝트 전체 파일 검사
|
||||
# --diff OLD NEW 두 커밋 간 변경 검사 (pre-receive용)
|
||||
# --strict MEDIUM도 차단
|
||||
#
|
||||
# 커버리지 한계 (PR 리뷰에서 수동):
|
||||
# - RBAC 설계 오류 / 인증 흐름
|
||||
# - 비즈니스 로직 / race condition
|
||||
# - third-party dependency (npm audit 영역)
|
||||
# - 환경변수 값 강도
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- 색상 ---
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# --- 설정 ---
|
||||
MEDIUM_THRESHOLD=5
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
IGNORE_FILE="$PROJECT_ROOT/.securityignore"
|
||||
|
||||
# --- 검출 규칙: NAME|SEVERITY|설명|PATTERN ---
|
||||
RULES=(
|
||||
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
|
||||
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
|
||||
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
|
||||
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
|
||||
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
|
||||
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
|
||||
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
|
||||
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
|
||||
)
|
||||
|
||||
# --- 제외 패턴 ---
|
||||
EXCLUDE_DIRS="node_modules|\.git|__pycache__|\.next|dist|build|coverage"
|
||||
EXCLUDE_FILES="package-lock\.json|yarn\.lock|\.min\.js|\.min\.css|\.map"
|
||||
|
||||
# --- 파싱 함수 ---
|
||||
parse_rule() {
|
||||
local rule="$1"
|
||||
RULE_NAME=$(echo "$rule" | cut -d'|' -f1)
|
||||
RULE_SEVERITY=$(echo "$rule" | cut -d'|' -f2)
|
||||
RULE_DESC=$(echo "$rule" | cut -d'|' -f3)
|
||||
RULE_PATTERN=$(echo "$rule" | cut -d'|' -f4-)
|
||||
}
|
||||
|
||||
# --- .securityignore 로드 ---
|
||||
load_ignore_list() {
|
||||
IGNORED_FILES=()
|
||||
if [[ -f "$IGNORE_FILE" ]]; then
|
||||
while IFS= read -r line; do
|
||||
# 빈 줄, 순수 주석 스킵
|
||||
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
# 파일명 추출 (주석 앞부분)
|
||||
local filepath
|
||||
filepath=$(echo "$line" | sed 's/#.*$//' | xargs)
|
||||
[[ -z "$filepath" ]] && continue
|
||||
# 주석 없는 항목 경고
|
||||
if ! echo "$line" | grep -q '#'; then
|
||||
echo -e "${YELLOW}[WARN] .securityignore: '$filepath' 에 사유 주석이 없습니다${NC}" >&2
|
||||
fi
|
||||
IGNORED_FILES+=("$filepath")
|
||||
done < "$IGNORE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
is_ignored_file() {
|
||||
local file="$1"
|
||||
for ignored in "${IGNORED_FILES[@]}"; do
|
||||
[[ "$file" == "$ignored" || "$file" == *"/$ignored" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
is_line_ignored() {
|
||||
local line_content="$1"
|
||||
local rule_name="$2"
|
||||
echo "$line_content" | grep -qP "security-ignore:\s*$rule_name" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- diff 파싱 + 검사 ---
|
||||
scan_diff() {
|
||||
local diff_input="$1"
|
||||
local violations=0
|
||||
local medium_count=0
|
||||
local current_file=""
|
||||
local current_line=0
|
||||
local results=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
# 파일명 추출
|
||||
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
|
||||
current_file="${BASH_REMATCH[2]}"
|
||||
continue
|
||||
fi
|
||||
# +++ b/filename
|
||||
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
|
||||
current_file="${BASH_REMATCH[1]}"
|
||||
continue
|
||||
fi
|
||||
# hunk header → 라인 번호
|
||||
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
|
||||
current_line="${BASH_REMATCH[1]}"
|
||||
continue
|
||||
fi
|
||||
# 추가된 라인만 검사
|
||||
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
|
||||
local content="${line:1}" # + 제거
|
||||
current_line=$((current_line))
|
||||
|
||||
# 제외 디렉토리/파일 체크
|
||||
if echo "$current_file" | grep -qEi "($EXCLUDE_DIRS)" 2>/dev/null; then
|
||||
current_line=$((current_line + 1))
|
||||
continue
|
||||
fi
|
||||
if echo "$current_file" | grep -qEi "($EXCLUDE_FILES)" 2>/dev/null; then
|
||||
current_line=$((current_line + 1))
|
||||
continue
|
||||
fi
|
||||
# .securityignore 체크
|
||||
if is_ignored_file "$current_file"; then
|
||||
current_line=$((current_line + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# 각 규칙 검사
|
||||
for i in "${!RULES[@]}"; do
|
||||
parse_rule "${RULES[$i]}"
|
||||
if echo "$content" | grep -qP "$RULE_PATTERN" 2>/dev/null; then
|
||||
# 라인 단위 ignore 체크
|
||||
if is_line_ignored "$content" "$RULE_NAME"; then
|
||||
continue
|
||||
fi
|
||||
local rule_num=$((i + 1))
|
||||
local trimmed
|
||||
trimmed=$(echo "$content" | sed 's/^[[:space:]]*//' | head -c 100)
|
||||
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
|
||||
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
|
||||
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||
"$current_file" "$current_line" "$trimmed")"
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
|
||||
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||
"$current_file" "$current_line" "$trimmed")"
|
||||
medium_count=$((medium_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
current_line=$((current_line + 1))
|
||||
fi
|
||||
done <<< "$diff_input"
|
||||
|
||||
# 결과 출력
|
||||
local total=$((violations + medium_count))
|
||||
if [[ $total -gt 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}[SECURITY] ${total} issue(s) found:${NC}"
|
||||
echo -e "$results"
|
||||
echo ""
|
||||
|
||||
# MEDIUM 임계값 체크
|
||||
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
|
||||
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD) — blocking${NC}"
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
# strict 모드
|
||||
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
|
||||
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also block${NC}"
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if [[ $violations -gt 0 ]]; then
|
||||
echo -e "${RED}Push/commit rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message.${NC}"
|
||||
echo ""
|
||||
return 1
|
||||
else
|
||||
echo -e "${YELLOW}Warnings only — commit/push allowed.${NC}"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 전체 파일 검사 (--all 모드) ---
|
||||
scan_all() {
|
||||
local violations=0
|
||||
local medium_count=0
|
||||
local results=""
|
||||
|
||||
load_ignore_list
|
||||
|
||||
local files
|
||||
files=$(find "$PROJECT_ROOT" -type f \
|
||||
\( -name "*.js" -o -name "*.py" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \
|
||||
-o -name "*.md" -o -name "*.sql" -o -name "*.yml" -o -name "*.yaml" \
|
||||
-o -name "*.json" -o -name "*.sh" -o -name "*.html" \) \
|
||||
! -path "*/node_modules/*" \
|
||||
! -path "*/.git/*" \
|
||||
! -path "*/__pycache__/*" \
|
||||
! -path "*/dist/*" \
|
||||
! -path "*/build/*" \
|
||||
! -path "*/coverage/*" \
|
||||
! -path "*/.claude/worktrees/*" \
|
||||
! -name "package-lock.json" \
|
||||
! -name "*.min.js" \
|
||||
! -name "*.min.css" \
|
||||
2>/dev/null || true)
|
||||
|
||||
while IFS= read -r filepath; do
|
||||
[[ -z "$filepath" ]] && continue
|
||||
local relpath="${filepath#$PROJECT_ROOT/}"
|
||||
|
||||
# .securityignore 체크
|
||||
if is_ignored_file "$relpath"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for i in "${!RULES[@]}"; do
|
||||
parse_rule "${RULES[$i]}"
|
||||
|
||||
local matches
|
||||
matches=$(grep -nP "$RULE_PATTERN" "$filepath" 2>/dev/null || true)
|
||||
[[ -z "$matches" ]] && continue
|
||||
|
||||
while IFS= read -r match; do
|
||||
local linenum content
|
||||
linenum=$(echo "$match" | cut -d: -f1)
|
||||
content=$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 100)
|
||||
|
||||
# 라인 단위 ignore
|
||||
local full_line
|
||||
full_line=$(sed -n "${linenum}p" "$filepath" 2>/dev/null || true)
|
||||
if is_line_ignored "$full_line" "$RULE_NAME"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local rule_num=$((i + 1))
|
||||
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
|
||||
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
|
||||
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||
"$relpath" "$linenum" "$content")"
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
|
||||
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
|
||||
"$relpath" "$linenum" "$content")"
|
||||
medium_count=$((medium_count + 1))
|
||||
fi
|
||||
done <<< "$matches"
|
||||
done
|
||||
done <<< "$files"
|
||||
|
||||
# 결과 출력
|
||||
local total=$((violations + medium_count))
|
||||
if [[ $total -gt 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}[SECURITY] Full scan: ${total} issue(s) found:${NC}"
|
||||
echo -e "$results"
|
||||
echo ""
|
||||
|
||||
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
|
||||
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD)${NC}"
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
|
||||
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also count${NC}"
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if [[ $violations -gt 0 ]]; then
|
||||
echo -e "${RED}${violations} blocking violation(s) found.${NC}"
|
||||
return 1
|
||||
else
|
||||
echo -e "${YELLOW}Warnings only (${medium_count} MEDIUM).${NC}"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}[SECURITY] Full scan: 0 violations found.${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# --- 메인 ---
|
||||
main() {
|
||||
local mode="staged"
|
||||
local old_rev="" new_rev=""
|
||||
STRICT_MODE="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--staged) mode="staged"; shift ;;
|
||||
--all) mode="all"; shift ;;
|
||||
--diff) mode="diff"; old_rev="$2"; new_rev="$3"; shift 3 ;;
|
||||
--strict) STRICT_MODE="true"; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: security-scan.sh [--staged|--all|--diff OLD NEW] [--strict]"
|
||||
echo " --staged Check staged files (default, for pre-commit)"
|
||||
echo " --all Scan entire project"
|
||||
echo " --diff Check changes between two commits (for pre-receive)"
|
||||
echo " --strict Block MEDIUM violations too"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
load_ignore_list
|
||||
|
||||
case "$mode" in
|
||||
staged)
|
||||
local diff_output
|
||||
diff_output=$(git diff --cached -U0 --diff-filter=ACMRT 2>/dev/null || true)
|
||||
if [[ -z "$diff_output" ]]; then
|
||||
echo -e "${GREEN}[SECURITY] No staged changes to scan.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
scan_diff "$diff_output"
|
||||
;;
|
||||
all)
|
||||
scan_all
|
||||
;;
|
||||
diff)
|
||||
if [[ -z "$old_rev" || -z "$new_rev" ]]; then
|
||||
echo "Error: --diff requires OLD and NEW revisions"
|
||||
exit 1
|
||||
fi
|
||||
local diff_output
|
||||
diff_output=$(git diff -U0 --diff-filter=ACMRT "$old_rev" "$new_rev" 2>/dev/null || true)
|
||||
if [[ -z "$diff_output" ]]; then
|
||||
echo -e "${GREEN}[SECURITY] No changes to scan.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
scan_diff "$diff_output"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
28
shared/config/database.js
Normal file
28
shared/config/database.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 공유 데이터베이스 커넥션 풀
|
||||
*
|
||||
* Group B 서비스(tkuser, tkpurchase, tksafety, tksupport)용 동기 풀
|
||||
* mysql2/promise 기반, 싱글톤 lazy initialization
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
let pool;
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'mariadb',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'hyungi_user',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'hyungi',
|
||||
waitForConnections: true,
|
||||
connectionLimit: parseInt(process.env.DB_CONN_LIMIT) || 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
module.exports = { getPool };
|
||||
39
shared/frontend/sso-relay.js
Normal file
39
shared/frontend/sso-relay.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
|
||||
*
|
||||
* Canonical source: shared/frontend/sso-relay.js
|
||||
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
|
||||
* system1-factory/web/js/sso-relay.js
|
||||
* system2-report/web/js/sso-relay.js
|
||||
* system3-nonconformance/web/static/js/sso-relay.js
|
||||
* user-management/web/static/js/sso-relay.js
|
||||
* tkpurchase/web/static/js/sso-relay.js
|
||||
* tksafety/web/static/js/sso-relay.js
|
||||
* tksupport/web/static/js/sso-relay.js
|
||||
*
|
||||
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
|
||||
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
|
||||
*/
|
||||
(function() {
|
||||
var hash = location.hash;
|
||||
if (!hash || hash.indexOf('_sso=') === -1) return;
|
||||
|
||||
var match = hash.match(/[#&]_sso=([^&]*)/);
|
||||
if (!match) return;
|
||||
|
||||
var token = decodeURIComponent(match[1]);
|
||||
if (!token) return;
|
||||
|
||||
// 로컬(1st-party) 쿠키 설정
|
||||
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
|
||||
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
|
||||
// localStorage 폴백
|
||||
try { localStorage.setItem('sso_token', token); } catch (e) {}
|
||||
|
||||
// URL에서 hash 제거
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
})();
|
||||
360
shared/middleware/auth.js
Normal file
360
shared/middleware/auth.js
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 통합 인증/인가 미들웨어
|
||||
*
|
||||
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// JWT_SECRET: system1/system2는 JWT_SECRET, 나머지는 SSO_JWT_SECRET 사용
|
||||
const JWT_SECRET = process.env.JWT_SECRET || process.env.SSO_JWT_SECRET;
|
||||
|
||||
/**
|
||||
* 권한 레벨 계층 구조
|
||||
* 숫자가 높을수록 상위 권한
|
||||
*/
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 미들웨어
|
||||
*
|
||||
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
||||
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
||||
*
|
||||
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
||||
*
|
||||
* @example
|
||||
* router.get('/profile', requireAuth, getProfile);
|
||||
*/
|
||||
const requireAuth = (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (!authHeader) {
|
||||
logger.warn('인증 실패: Authorization 헤더 없음', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
logger.warn('인증 실패: Bearer 토큰 누락', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
||||
}
|
||||
|
||||
// JWT 검증 (SSO 공유 시크릿)
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
|
||||
logger.debug('인증 성공', {
|
||||
user_id: decoded.user_id || decoded.id,
|
||||
username: decoded.username,
|
||||
role: decoded.role,
|
||||
access_level: decoded.access_level
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
||||
} else if (err.name === 'TokenExpiredError') {
|
||||
logger.warn('인증 실패: 만료된 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('토큰이 만료되었습니다');
|
||||
} else if (err instanceof AuthenticationError) {
|
||||
// 이미 AuthenticationError인 경우 그대로 throw
|
||||
throw err;
|
||||
} else {
|
||||
logger.error('인증 처리 중 예상치 못한 오류', {
|
||||
error: err.message,
|
||||
stack: err.stack
|
||||
});
|
||||
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 역할(들) 권한 체크 미들웨어
|
||||
*
|
||||
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {...string} roles - 허용할 역할 목록
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 없는 경우
|
||||
*
|
||||
* @example
|
||||
* // 단일 역할
|
||||
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
||||
*
|
||||
* // 여러 역할
|
||||
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
||||
*/
|
||||
const requireRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
||||
const rolesLower = roles.map(r => r.toLowerCase());
|
||||
|
||||
if (!rolesLower.includes(userRoleLower)) {
|
||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_role: userRole,
|
||||
required_roles: roles,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('역할 권한 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
required_roles: roles
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
||||
*
|
||||
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
||||
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 부족한 경우
|
||||
*
|
||||
* @example
|
||||
* // admin 이상 필요 (admin, system만 허용)
|
||||
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
||||
*
|
||||
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
||||
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
||||
*/
|
||||
const requireMinLevel = (minLevel) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
||||
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_level: req.user.access_level,
|
||||
current_level_value: userLevel,
|
||||
required_level: minLevel,
|
||||
required_level_value: requiredLevel,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('권한 레벨 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
access_level: req.user.access_level,
|
||||
required_level: minLevel
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
||||
*
|
||||
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {Object} options - 옵션 객체
|
||||
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
|
||||
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
||||
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
||||
*
|
||||
* @example
|
||||
* // URL 파라미터의 user_id로 체크
|
||||
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'params.user_id'
|
||||
* }), updateUser);
|
||||
*
|
||||
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
|
||||
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'body.user_id',
|
||||
* adminRoles: ['admin', 'system', 'support_team']
|
||||
* }), deleteReport);
|
||||
*/
|
||||
const requireOwnerOrAdmin = (options = {}) => {
|
||||
const {
|
||||
resourceField = 'params.id',
|
||||
userField = 'user_id',
|
||||
adminRoles = ['admin', 'system']
|
||||
} = options;
|
||||
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
// 관리자 권한 체크
|
||||
const userRole = req.user.role;
|
||||
const isAdmin = adminRoles.includes(userRole);
|
||||
|
||||
if (isAdmin) {
|
||||
logger.debug('관리자 권한으로 접근 허용', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
path: req.path
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// 리소스 ID 추출
|
||||
const fieldParts = resourceField.split('.');
|
||||
let resourceId = req;
|
||||
for (const part of fieldParts) {
|
||||
resourceId = resourceId[part];
|
||||
if (resourceId === undefined) break;
|
||||
}
|
||||
|
||||
// 사용자 ID (user_id 또는 id)
|
||||
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
||||
|
||||
// 소유자 체크
|
||||
const isOwner = resourceId && String(resourceId) === String(userId);
|
||||
|
||||
if (!isOwner) {
|
||||
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
resource_id: resourceId,
|
||||
resource_field: resourceField,
|
||||
is_admin: isAdmin,
|
||||
is_owner: isOwner,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
||||
}
|
||||
|
||||
logger.debug('리소스 소유자로 접근 허용', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
resource_id: resourceId,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireAuth를 사용하세요
|
||||
*/
|
||||
const verifyToken = requireAuth;
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('admin', 'system')을 사용하세요
|
||||
*/
|
||||
const requireAdmin = requireRole('admin', 'system');
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('system')을 사용하세요
|
||||
*/
|
||||
const requireSystem = requireRole('system');
|
||||
|
||||
module.exports = {
|
||||
// 주요 미들웨어
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
|
||||
// 레거시 호환성
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
|
||||
// 상수
|
||||
ACCESS_LEVELS
|
||||
};
|
||||
61
shared/middleware/pagePermission.js
Normal file
61
shared/middleware/pagePermission.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 페이지 권한 미들웨어 (shared)
|
||||
* admin/system 역할은 자동 통과, 일반 사용자는 개인/부서 권한 체크
|
||||
*
|
||||
* 사용법:
|
||||
* const { createRequirePage } = require('../../shared/middleware/pagePermission');
|
||||
* const requirePage = createRequirePage(() => getPool());
|
||||
* router.get('/some', requirePage('page_name'), controller.handler);
|
||||
*/
|
||||
|
||||
function createRequirePage(getPool) {
|
||||
return function requirePage(pageName) {
|
||||
return async (req, res, next) => {
|
||||
const userId = req.user.user_id || req.user.id;
|
||||
const role = (req.user.role || '').toLowerCase();
|
||||
|
||||
// admin/system 자동 통과
|
||||
if (role === 'admin' || role === 'system') return next();
|
||||
|
||||
try {
|
||||
const db = typeof getPool === 'function' ? await getPool() : getPool;
|
||||
|
||||
// 1. 개인 권한 체크
|
||||
const [rows] = await db.query(
|
||||
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
|
||||
[userId, pageName]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
return rows[0].can_access
|
||||
? next()
|
||||
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||
}
|
||||
|
||||
// 2. 부서 권한 체크
|
||||
const [userRows] = await db.query(
|
||||
'SELECT department_id FROM sso_users WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
if (userRows.length > 0 && userRows[0].department_id) {
|
||||
const [deptRows] = await db.query(
|
||||
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
|
||||
[userRows[0].department_id, pageName]
|
||||
);
|
||||
if (deptRows.length > 0) {
|
||||
return deptRows[0].can_access
|
||||
? next()
|
||||
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 권한 레코드 없음 → 거부
|
||||
return res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||
} catch (err) {
|
||||
console.error('Permission check error:', err);
|
||||
return res.status(500).json({ success: false, error: '권한 확인 실패' });
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createRequirePage };
|
||||
186
shared/utils/errors.js
Normal file
186
shared/utils/errors.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 커스텀 에러 클래스
|
||||
*
|
||||
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
/**
|
||||
* 기본 애플리케이션 에러 클래스
|
||||
* 모든 커스텀 에러의 부모 클래스
|
||||
*/
|
||||
class AppError extends Error {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} statusCode - HTTP 상태 코드
|
||||
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
||||
* @param {object} details - 추가 세부 정보
|
||||
*/
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.isOperational = true; // 운영 에러 (예상된 에러)
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 형태로 에러 정보 반환
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
statusCode: this.statusCode,
|
||||
code: this.code,
|
||||
details: this.details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 에러 (400 Bad Request)
|
||||
* 입력값 검증 실패 시 사용
|
||||
*/
|
||||
class ValidationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 검증 실패 세부 정보
|
||||
*/
|
||||
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 에러 (401 Unauthorized)
|
||||
* 인증이 필요하거나 인증 실패 시 사용
|
||||
*/
|
||||
class AuthenticationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '인증이 필요합니다') {
|
||||
super(message, 401, 'AUTHENTICATION_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 에러 (403 Forbidden)
|
||||
* 권한이 부족할 때 사용
|
||||
*/
|
||||
class ForbiddenError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '권한이 없습니다') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 없음 에러 (404 Not Found)
|
||||
* 요청한 리소스를 찾을 수 없을 때 사용
|
||||
*/
|
||||
class NotFoundError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} resource - 찾을 수 없는 리소스명
|
||||
*/
|
||||
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
||||
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 충돌 에러 (409 Conflict)
|
||||
* 중복된 리소스 등 충돌 발생 시 사용
|
||||
*/
|
||||
class ConflictError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 충돌 세부 정보
|
||||
*/
|
||||
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
||||
super(message, 409, 'CONFLICT', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 에러 (500 Internal Server Error)
|
||||
* 예상하지 못한 서버 오류 시 사용
|
||||
*/
|
||||
class InternalServerError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '서버 오류가 발생했습니다') {
|
||||
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 에러 (500 Internal Server Error)
|
||||
* DB 관련 오류 시 사용
|
||||
*/
|
||||
class DatabaseError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {Error} originalError - 원본 DB 에러
|
||||
*/
|
||||
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
||||
super(
|
||||
message,
|
||||
500,
|
||||
'DATABASE_ERROR',
|
||||
originalError ? { originalMessage: originalError.message } : null
|
||||
);
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 에러 (502 Bad Gateway)
|
||||
* 외부 서비스 호출 실패 시 사용
|
||||
*/
|
||||
class ExternalApiError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} service - 외부 서비스명
|
||||
*/
|
||||
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
||||
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임아웃 에러 (504 Gateway Timeout)
|
||||
* 요청 처리 시간 초과 시 사용
|
||||
*/
|
||||
class TimeoutError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} timeout - 타임아웃 시간 (ms)
|
||||
*/
|
||||
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
||||
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
DatabaseError,
|
||||
ExternalApiError,
|
||||
TimeoutError
|
||||
};
|
||||
199
shared/utils/logger.js
Normal file
199
shared/utils/logger.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 로깅 유틸리티
|
||||
*
|
||||
* 애플리케이션 전체에서 사용하는 통합 로거
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 로그 레벨 정의
|
||||
*/
|
||||
const LogLevel = {
|
||||
ERROR: 'ERROR',
|
||||
WARN: 'WARN',
|
||||
INFO: 'INFO',
|
||||
DEBUG: 'DEBUG'
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그 레벨별 이모지
|
||||
*/
|
||||
const LogEmoji = {
|
||||
ERROR: '❌',
|
||||
WARN: '⚠️',
|
||||
INFO: 'ℹ️',
|
||||
DEBUG: '🔍'
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그 레벨별 색상 (콘솔)
|
||||
*/
|
||||
const LogColor = {
|
||||
ERROR: '\x1b[31m', // Red
|
||||
WARN: '\x1b[33m', // Yellow
|
||||
INFO: '\x1b[36m', // Cyan
|
||||
DEBUG: '\x1b[90m', // Gray
|
||||
RESET: '\x1b[0m'
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
// process.cwd() = /usr/src/app (컨테이너 WORKDIR)
|
||||
// __dirname 대신 사용하여 shared/ 위치와 무관하게 서비스의 logs/ 디렉토리에 기록
|
||||
this.logDir = path.join(process.cwd(), 'logs');
|
||||
this.logFile = path.join(this.logDir, 'app.log');
|
||||
this.errorFile = path.join(this.logDir, 'error.log');
|
||||
this.ensureLogDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 디렉토리 생성
|
||||
*/
|
||||
ensureLogDirectory() {
|
||||
if (!fs.existsSync(this.logDir)) {
|
||||
fs.mkdirSync(this.logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프 생성
|
||||
*/
|
||||
getTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 포맷팅
|
||||
*/
|
||||
formatLog(level, message, context = {}) {
|
||||
const timestamp = this.getTimestamp();
|
||||
const emoji = LogEmoji[level] || '';
|
||||
const contextStr = Object.keys(context).length > 0
|
||||
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
||||
: '';
|
||||
|
||||
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘솔에 컬러 로그 출력
|
||||
*/
|
||||
logToConsole(level, message, context = {}) {
|
||||
const color = LogColor[level] || LogColor.RESET;
|
||||
const formattedLog = this.formatLog(level, message, context);
|
||||
|
||||
if (level === LogLevel.ERROR) {
|
||||
console.error(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
} else if (level === LogLevel.WARN) {
|
||||
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
} else {
|
||||
console.log(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일에 로그 기록
|
||||
*/
|
||||
logToFile(level, message, context = {}) {
|
||||
const formattedLog = this.formatLog(level, message, context);
|
||||
const logEntry = `${formattedLog}\n`;
|
||||
|
||||
try {
|
||||
// 모든 로그를 app.log에 기록
|
||||
fs.appendFileSync(this.logFile, logEntry, 'utf8');
|
||||
|
||||
// 에러는 error.log에도 기록
|
||||
if (level === LogLevel.ERROR) {
|
||||
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그 파일 기록 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 기록 메인 함수
|
||||
*/
|
||||
log(level, message, context = {}) {
|
||||
// 개발 환경에서는 콘솔에 출력
|
||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
|
||||
this.logToConsole(level, message, context);
|
||||
}
|
||||
|
||||
// 프로덕션에서는 파일에만 기록
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
this.logToFile(level, message, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 로그
|
||||
*/
|
||||
error(message, context = {}) {
|
||||
this.log(LogLevel.ERROR, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경고 로그
|
||||
*/
|
||||
warn(message, context = {}) {
|
||||
this.log(LogLevel.WARN, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정보 로그
|
||||
*/
|
||||
info(message, context = {}) {
|
||||
this.log(LogLevel.INFO, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디버그 로그
|
||||
*/
|
||||
debug(message, context = {}) {
|
||||
// DEBUG 로그는 개발 환경에서만 출력
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.log(LogLevel.DEBUG, message, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 로그
|
||||
*/
|
||||
http(method, url, statusCode, duration, user = 'anonymous') {
|
||||
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
|
||||
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
|
||||
const context = {
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration,
|
||||
user
|
||||
};
|
||||
|
||||
this.log(level, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 쿼리 로그
|
||||
*/
|
||||
query(sql, params = [], duration = 0) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.debug('DB Query', {
|
||||
sql,
|
||||
params,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 내보내기
|
||||
const logger = new Logger();
|
||||
|
||||
module.exports = logger;
|
||||
64
shared/utils/notifyHelper.js
Normal file
64
shared/utils/notifyHelper.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// shared/utils/notifyHelper.js — 공용 알림 헬퍼
|
||||
// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송
|
||||
const http = require('http');
|
||||
|
||||
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
|
||||
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
|
||||
|
||||
const notifyHelper = {
|
||||
/**
|
||||
* 알림 전송
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system, purchase)
|
||||
* @param {string} opts.title - 알림 제목
|
||||
* @param {string} [opts.message] - 알림 내용
|
||||
* @param {string} [opts.link_url] - 클릭 시 이동 URL
|
||||
* @param {string} [opts.reference_type] - 연관 테이블명
|
||||
* @param {number} [opts.reference_id] - 연관 레코드 ID
|
||||
* @param {number} [opts.created_by] - 생성자 user_id
|
||||
* @param {number[]} [opts.target_user_ids] - 특정 사용자 직접 알림 (생략 시 type 기반 브로드캐스트)
|
||||
*/
|
||||
async send(opts) {
|
||||
try {
|
||||
const body = JSON.stringify(opts);
|
||||
const url = new URL(NOTIFY_URL);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Internal-Service-Key': SERVICE_KEY,
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
},
|
||||
timeout: 5000
|
||||
}, (res) => {
|
||||
res.resume(); // drain
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('[notifyHelper] 알림 전송 실패:', err.message);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
console.error('[notifyHelper] 알림 전송 타임아웃');
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[notifyHelper] 알림 전송 오류:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = notifyHelper;
|
||||
@@ -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으로 설정
|
||||
@@ -127,8 +127,17 @@ async function loginForm(req, res, next) {
|
||||
return res.status(400).json({ detail: 'Missing username or password' });
|
||||
}
|
||||
|
||||
// Rate limiting (동일 로직: /login과 공유)
|
||||
const attemptKey = `login_attempts:${username}`;
|
||||
const attempts = parseInt(await redis.get(attemptKey)) || 0;
|
||||
if (attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
return res.status(429).json({ detail: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
|
||||
}
|
||||
|
||||
const user = await userModel.findByUsername(username);
|
||||
if (!user) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ detail: 'Incorrect username or password' });
|
||||
}
|
||||
|
||||
@@ -139,13 +148,18 @@ async function loginForm(req, res, next) {
|
||||
|
||||
const valid = await userModel.verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ detail: 'Incorrect username or password' });
|
||||
}
|
||||
|
||||
// 로그인 성공 시 시도 횟수 초기화
|
||||
await redis.del(attemptKey);
|
||||
|
||||
await userModel.updateLastLogin(user.user_id);
|
||||
|
||||
const payload = createTokenPayload(user);
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||
|
||||
res.json({
|
||||
access_token,
|
||||
@@ -173,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: '유효하지 않은 사용자입니다' });
|
||||
@@ -215,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' });
|
||||
@@ -247,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 토큰입니다' });
|
||||
}
|
||||
@@ -258,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({
|
||||
@@ -351,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 토큰 또는 쿠키에서 토큰 추출
|
||||
*/
|
||||
@@ -373,5 +447,7 @@ module.exports = {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
deleteUser,
|
||||
changePassword,
|
||||
checkPasswordStrength
|
||||
};
|
||||
|
||||
@@ -20,14 +20,18 @@ const allowedOrigins = [
|
||||
'https://tkreport.technicalkorea.net',
|
||||
'https://tkqc.technicalkorea.net',
|
||||
'https://tkuser.technicalkorea.net',
|
||||
'https://tkpurchase.technicalkorea.net',
|
||||
'https://tksafety.technicalkorea.net',
|
||||
'https://tksupport.technicalkorea.net',
|
||||
'https://tkfb.technicalkorea.net',
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
||||
}
|
||||
app.use(cors({
|
||||
origin: function(origin, cb) {
|
||||
if (!origin || allowedOrigins.includes(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
||||
cb(new Error('CORS blocked: ' + origin));
|
||||
if (!origin || allowedOrigins.includes(origin) || /^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
||||
cb(null, false);
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
# Node.js 공식 이미지 사용
|
||||
FROM node:18-alpine
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# 패키지 파일 복사 (캐싱 최적화)
|
||||
COPY package*.json ./
|
||||
# shared 모듈 복사
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# 프로덕션 의존성만 설치 (sharp용 빌드 도구 포함)
|
||||
# 패키지 파일 복사 + 프로덕션 의존성 설치 (sharp용 빌드 도구 포함)
|
||||
COPY system1-factory/api/package*.json ./
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
||||
npm install --omit=dev && \
|
||||
npm install sharp && \
|
||||
apk del .build-deps
|
||||
|
||||
# 앱 소스 복사
|
||||
COPY . .
|
||||
COPY system1-factory/api/ ./
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
RUN mkdir -p logs uploads
|
||||
# shared 모듈 심링크 (routes에서 ../../../shared/ 경로 호환)
|
||||
RUN ln -s /usr/src/app/shared /usr/src/shared && ln -s /usr/src/app/shared /usr/shared
|
||||
|
||||
# 실행 권한 설정
|
||||
# 로그/업로드 디렉토리 생성
|
||||
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
||||
RUN chown -R node:node /usr/src/app
|
||||
|
||||
# 보안을 위해 non-root 사용자로 실행
|
||||
USER node
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3005
|
||||
|
||||
# 헬스체크 추가
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||
|
||||
# 앱 시작
|
||||
CMD ["node", "index.js"]
|
||||
@@ -13,9 +13,14 @@ const logger = require('../utils/logger');
|
||||
* 허용된 Origin 목록
|
||||
*/
|
||||
const allowedOrigins = [
|
||||
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
|
||||
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
||||
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
|
||||
'https://tkreport.technicalkorea.net', // System 2
|
||||
'https://tkqc.technicalkorea.net', // System 3
|
||||
'https://tkuser.technicalkorea.net', // User Management
|
||||
'https://tkpurchase.technicalkorea.net', // Purchase Management
|
||||
'https://tksafety.technicalkorea.net', // Safety Management
|
||||
'https://tksupport.technicalkorea.net', // Support Management
|
||||
'http://localhost:20000', // 웹 UI (로컬)
|
||||
'http://localhost:30080', // 웹 UI (Docker)
|
||||
'http://localhost:3005', // API 서버
|
||||
@@ -45,6 +50,12 @@ const corsOptions = {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// *.technicalkorea.net 서브도메인 허용 (인앱 브라우저 대응)
|
||||
if (/^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin)) {
|
||||
logger.debug('CORS: technicalkorea.net 서브도메인 허용', { origin });
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// 개발 환경에서는 모든 localhost 허용
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||
@@ -59,9 +70,9 @@ const corsOptions = {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// 차단
|
||||
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
|
||||
logger.warn('CORS: 차단된 Origin', { origin });
|
||||
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
|
||||
callback(null, false);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -77,7 +88,7 @@ const corsOptions = {
|
||||
/**
|
||||
* 허용된 헤더
|
||||
*/
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Internal-Service-Key'],
|
||||
|
||||
/**
|
||||
* 노출할 헤더
|
||||
|
||||
@@ -50,7 +50,17 @@ function setupRoutes(app) {
|
||||
const workIssueRoutes = require('../routes/workIssueRoutes');
|
||||
const departmentRoutes = require('../routes/departmentRoutes');
|
||||
const patrolRoutes = require('../routes/patrolRoutes');
|
||||
const notificationRoutes = require('../routes/notificationRoutes');
|
||||
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');
|
||||
@@ -105,7 +115,7 @@ function setupRoutes(app) {
|
||||
'/api/setup/migrate-existing-data',
|
||||
'/api/setup/check-data-status',
|
||||
'/api/monthly-status/calendar',
|
||||
'/api/monthly-status/daily-details'
|
||||
'/api/monthly-status/daily-details',
|
||||
];
|
||||
|
||||
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
|
||||
@@ -156,7 +166,17 @@ function setupRoutes(app) {
|
||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||
app.use('/api/notifications', notificationRoutes); // 알림 시스템
|
||||
app.use('/api/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 문서
|
||||
|
||||
@@ -19,7 +19,7 @@ const helmetOptions = {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // 개발 중 unsafe-eval 허용
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
connectSrc: ["'self'", "https://api.technicalkorea.com"],
|
||||
|
||||
@@ -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;
|
||||
92
system1-factory/api/controllers/dashboardController.js
Normal file
92
system1-factory/api/controllers/dashboardController.js
Normal 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;
|
||||
@@ -558,6 +558,19 @@ const EquipmentController = {
|
||||
}
|
||||
},
|
||||
|
||||
getRepairRequests: async (req, res) => {
|
||||
try {
|
||||
const results = await EquipmentModel.getRepairRequests(req.query.status || null);
|
||||
res.json({ success: true, data: results });
|
||||
} catch (err) {
|
||||
logger.error('수리 요청 목록 조회 오류:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '수리 요청 목록 조회 중 오류가 발생했습니다.'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getRepairCategories: async (req, res) => {
|
||||
try {
|
||||
const results = await EquipmentModel.getRepairCategories();
|
||||
|
||||
47
system1-factory/api/controllers/itemAliasController.js
Normal file
47
system1-factory/api/controllers/itemAliasController.js
Normal 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;
|
||||
182
system1-factory/api/controllers/meetingController.js
Normal file
182
system1-factory/api/controllers/meetingController.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const MeetingModel = require('../models/meetingModel');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const MeetingController = {
|
||||
// 회의록 목록
|
||||
getAll: async (req, res) => {
|
||||
try {
|
||||
const { year, month, search } = req.query;
|
||||
const rows = await MeetingModel.getAll({ year, month, search });
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Meeting getAll error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 회의록 상세
|
||||
getById: async (req, res) => {
|
||||
try {
|
||||
const meeting = await MeetingModel.getById(req.params.id);
|
||||
if (!meeting) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
|
||||
res.json({ success: true, data: meeting });
|
||||
} catch (err) {
|
||||
logger.error('Meeting getById error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 회의록 생성
|
||||
create: async (req, res) => {
|
||||
try {
|
||||
const { meeting_date, title } = req.body;
|
||||
if (!meeting_date || !title) {
|
||||
return res.status(400).json({ success: false, message: '날짜와 제목은 필수입니다.' });
|
||||
}
|
||||
const id = await MeetingModel.create({
|
||||
...req.body,
|
||||
created_by: req.user.user_id || req.user.id
|
||||
});
|
||||
res.status(201).json({ success: true, data: { meeting_id: id }, message: '회의록이 생성되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting create error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 회의록 수정
|
||||
update: async (req, res) => {
|
||||
try {
|
||||
const meetingId = req.params.id;
|
||||
const status = await MeetingModel.getStatus(meetingId);
|
||||
if (!status) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
|
||||
|
||||
// published 상태면 admin만 수정 가능
|
||||
const userLevel = req.user.access_level || req.user.role;
|
||||
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
|
||||
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
await MeetingModel.update(meetingId, req.body);
|
||||
res.json({ success: true, message: '회의록이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting update error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 회의록 발행
|
||||
publish: async (req, res) => {
|
||||
try {
|
||||
await MeetingModel.publish(req.params.id);
|
||||
res.json({ success: true, message: '회의록이 발행되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting publish error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 발행 취소 (admin only)
|
||||
unpublish: async (req, res) => {
|
||||
try {
|
||||
await MeetingModel.unpublish(req.params.id);
|
||||
res.json({ success: true, message: '발행이 취소되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting unpublish error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 회의록 삭제
|
||||
delete: async (req, res) => {
|
||||
try {
|
||||
await MeetingModel.delete(req.params.id);
|
||||
res.json({ success: true, message: '회의록이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting delete error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 안건 ===
|
||||
addItem: async (req, res) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ success: false, message: '안건 내용을 입력해주세요.' });
|
||||
|
||||
// published 체크
|
||||
const status = await MeetingModel.getStatus(req.params.id);
|
||||
const userLevel = req.user.access_level || req.user.role;
|
||||
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
|
||||
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
const id = await MeetingModel.addItem(req.params.id, req.body);
|
||||
res.status(201).json({ success: true, data: { item_id: id }, message: '안건이 추가되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting addItem error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
updateItem: async (req, res) => {
|
||||
try {
|
||||
// published 체크
|
||||
const status = await MeetingModel.getStatus(req.params.id);
|
||||
const userLevel = req.user.access_level || req.user.role;
|
||||
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
|
||||
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
await MeetingModel.updateItem(req.params.itemId, req.body);
|
||||
res.json({ success: true, message: '안건이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting updateItem error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteItem: async (req, res) => {
|
||||
try {
|
||||
// published 체크
|
||||
const status = await MeetingModel.getStatus(req.params.id);
|
||||
const userLevel = req.user.access_level || req.user.role;
|
||||
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
|
||||
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
await MeetingModel.deleteItem(req.params.itemId);
|
||||
res.json({ success: true, message: '안건이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting deleteItem error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 조치상태 업데이트 (group_leader+)
|
||||
updateItemStatus: async (req, res) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
if (!status) return res.status(400).json({ success: false, message: '상태를 선택해주세요.' });
|
||||
await MeetingModel.updateItemStatus(req.params.itemId, status);
|
||||
res.json({ success: true, message: '조치상태가 업데이트되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Meeting updateItemStatus error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 미완료 조치사항
|
||||
getActionItems: async (req, res) => {
|
||||
try {
|
||||
const { status, responsible_user_id } = req.query;
|
||||
const rows = await MeetingModel.getActionItems({ status, responsible_user_id });
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Meeting getActionItems error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = MeetingController;
|
||||
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal 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;
|
||||
210
system1-factory/api/controllers/proxyInputController.js
Normal file
210
system1-factory/api/controllers/proxyInputController.js
Normal 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;
|
||||
175
system1-factory/api/controllers/purchaseBatchController.js
Normal file
175
system1-factory/api/controllers/purchaseBatchController.js
Normal 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;
|
||||
126
system1-factory/api/controllers/purchaseController.js
Normal file
126
system1-factory/api/controllers/purchaseController.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const PurchaseModel = require('../models/purchaseModel');
|
||||
const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const PurchaseController = {
|
||||
// 구매 처리 (신청 → 구매)
|
||||
create: async (req, res) => {
|
||||
try {
|
||||
const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, register_to_master, notes } = req.body;
|
||||
|
||||
// item_id가 없으면 custom item → register_to_master로 자동 등록 가능
|
||||
let effectiveItemId = item_id;
|
||||
|
||||
if (!effectiveItemId && request_id) {
|
||||
// 미등록 품목의 구매 처리 — 마스터 등록 처리
|
||||
const requestData = await PurchaseRequestModel.getById(request_id);
|
||||
if (requestData && requestData.custom_item_name) {
|
||||
if (register_to_master !== false) {
|
||||
// 마스터에 등록
|
||||
const newItemId = await PurchaseModel.registerToMaster(
|
||||
requestData.custom_item_name,
|
||||
requestData.custom_category,
|
||||
null // maker
|
||||
);
|
||||
effectiveItemId = newItemId;
|
||||
// purchase_requests.item_id 업데이트
|
||||
await PurchaseRequestModel.updateItemId(request_id, newItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveItemId) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' });
|
||||
if (!unit_price) return res.status(400).json({ success: false, message: '구매 단가를 입력해주세요.' });
|
||||
if (!purchase_date) return res.status(400).json({ success: false, message: '구매일을 입력해주세요.' });
|
||||
|
||||
// 구매 내역 생성
|
||||
const purchaseId = await PurchaseModel.createFromRequest({
|
||||
request_id: request_id || null,
|
||||
item_id: effectiveItemId,
|
||||
vendor_id: vendor_id || null,
|
||||
quantity: quantity || 1,
|
||||
unit_price,
|
||||
purchase_date,
|
||||
purchaser_id: req.user.id,
|
||||
notes
|
||||
});
|
||||
|
||||
// 기준가 업데이트 요청 시
|
||||
if (update_base_price) {
|
||||
const items = await PurchaseModel.getConsumableItems(false);
|
||||
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
|
||||
if (item) {
|
||||
await PurchaseModel.updateBasePrice(effectiveItemId, unit_price, item.base_price, req.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 설비 자동 등록 (category='equipment')
|
||||
let equipmentResult = null;
|
||||
if (request_id) {
|
||||
const requestData = await PurchaseRequestModel.getById(request_id);
|
||||
const category = requestData?.category || requestData?.custom_category;
|
||||
if (category === 'equipment') {
|
||||
equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({
|
||||
item_name: requestData.item_name || requestData.custom_item_name,
|
||||
maker: requestData.maker,
|
||||
vendor_name: null,
|
||||
unit_price,
|
||||
purchase_date,
|
||||
purchase_id: purchaseId,
|
||||
purchaser_id: req.user.id
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 직접 구매 시에도 category 확인
|
||||
const items = await PurchaseModel.getConsumableItems(false);
|
||||
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
|
||||
if (item && item.category === 'equipment') {
|
||||
const vendors = await PurchaseModel.getVendors();
|
||||
const vendor = vendors.find(v => v.vendor_id === parseInt(vendor_id));
|
||||
equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({
|
||||
item_name: item.item_name,
|
||||
maker: item.maker,
|
||||
vendor_name: vendor ? vendor.vendor_name : null,
|
||||
unit_price,
|
||||
purchase_date,
|
||||
purchase_id: purchaseId,
|
||||
purchaser_id: req.user.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = { purchase_id: purchaseId };
|
||||
if (equipmentResult) result.equipment = equipmentResult;
|
||||
|
||||
res.status(201).json({ success: true, data: result, message: '구매 처리가 완료되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Purchase create error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 구매 내역 목록
|
||||
getAll: async (req, res) => {
|
||||
try {
|
||||
const { vendor_id, category, from_date, to_date, year_month } = req.query;
|
||||
const rows = await PurchaseModel.getAll({ vendor_id, category, from_date, to_date, year_month });
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Purchase getAll error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 가격 변동 이력
|
||||
getPriceHistory: async (req, res) => {
|
||||
try {
|
||||
const rows = await PurchaseModel.getPriceHistory(req.params.itemId);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('PriceHistory get error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PurchaseController;
|
||||
455
system1-factory/api/controllers/purchaseRequestController.js
Normal file
455
system1-factory/api/controllers/purchaseRequestController.js
Normal file
@@ -0,0 +1,455 @@
|
||||
const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
||||
const PurchaseModel = require('../models/purchaseModel');
|
||||
const { saveBase64Image } = require('../services/imageUploadService');
|
||||
const logger = require('../utils/logger');
|
||||
const notifyHelper = require('../../../shared/utils/notifyHelper');
|
||||
const koreanSearch = require('../utils/koreanSearch');
|
||||
|
||||
const PurchaseRequestController = {
|
||||
// 구매신청 목록
|
||||
getAll: async (req, res) => {
|
||||
try {
|
||||
const { status, category, from_date, to_date, batch_id } = req.query;
|
||||
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
||||
const filters = { status, category, from_date, to_date, batch_id };
|
||||
if (!isAdmin) filters.requester_id = req.user.id;
|
||||
const rows = await PurchaseRequestModel.getAll(filters);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest getAll error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 구매신청 상세
|
||||
getById: async (req, res) => {
|
||||
try {
|
||||
const row = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!row) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
res.json({ success: true, data: row });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest getById error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 구매신청 생성
|
||||
create: async (req, res) => {
|
||||
try {
|
||||
const { item_id, custom_item_name, custom_category, quantity, notes, photo } = req.body;
|
||||
|
||||
// item_id 또는 custom_item_name 중 하나 필수
|
||||
if (!item_id && !custom_item_name) {
|
||||
return res.status(400).json({ success: false, message: '소모품을 선택하거나 품목명을 입력해주세요.' });
|
||||
}
|
||||
if (!quantity || quantity < 1) {
|
||||
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||
}
|
||||
if (!item_id && custom_item_name && !custom_category) {
|
||||
return res.status(400).json({ success: false, message: '직접 입력 시 분류를 선택해주세요.' });
|
||||
}
|
||||
|
||||
// 사진 업로드
|
||||
let photo_path = null;
|
||||
if (photo) {
|
||||
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||
}
|
||||
|
||||
const request = await PurchaseRequestModel.create({
|
||||
item_id: item_id || null,
|
||||
custom_item_name: custom_item_name || null,
|
||||
custom_category: custom_category || null,
|
||||
quantity,
|
||||
requester_id: req.user.id,
|
||||
request_date: new Date().toISOString().substring(0, 10),
|
||||
notes,
|
||||
photo_path
|
||||
});
|
||||
res.status(201).json({ success: true, data: request, message: '구매신청이 등록되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest create error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 보류 처리 (admin)
|
||||
hold: async (req, res) => {
|
||||
try {
|
||||
const { hold_reason } = req.body;
|
||||
const request = await PurchaseRequestModel.hold(req.params.id, hold_reason);
|
||||
if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
res.json({ success: true, data: request, message: '보류 처리되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest hold error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// pending으로 되돌리기 (admin)
|
||||
revert: async (req, res) => {
|
||||
try {
|
||||
const request = await PurchaseRequestModel.revertToPending(req.params.id);
|
||||
if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
res.json({ success: true, data: request, message: '대기 상태로 되돌렸습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest revert error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 삭제 (본인 + pending만)
|
||||
delete: async (req, res) => {
|
||||
try {
|
||||
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
||||
if (!isAdmin && existing.requester_id !== req.user.id) {
|
||||
return res.status(403).json({ success: false, message: '본인의 신청만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const deleted = await PurchaseRequestModel.delete(req.params.id);
|
||||
if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 신청만 삭제할 수 있습니다.' });
|
||||
res.json({ success: true, message: '삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest delete error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
|
||||
registerAndRequest: async (req, res) => {
|
||||
const { getDb } = require('../dbPool');
|
||||
let conn;
|
||||
try {
|
||||
const { item_name, spec, maker, category, quantity, notes, photo } = req.body;
|
||||
if (!item_name || !item_name.trim()) {
|
||||
return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' });
|
||||
}
|
||||
if (!quantity || quantity < 1) {
|
||||
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
conn = await db.getConnection();
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 1. 소모품 마스터 등록 (중복 확인)
|
||||
const [existing] = await conn.query(
|
||||
`SELECT item_id FROM consumable_items
|
||||
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||
[item_name.trim(), spec || null, spec || null, maker || null, maker || null]
|
||||
);
|
||||
|
||||
let itemId;
|
||||
if (existing.length > 0) {
|
||||
itemId = existing[0].item_id;
|
||||
} else {
|
||||
const [insertResult] = await conn.query(
|
||||
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
|
||||
[item_name.trim(), spec || null, maker || null, category || 'consumable']
|
||||
);
|
||||
itemId = insertResult.insertId;
|
||||
}
|
||||
|
||||
// 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에)
|
||||
let photo_path = null;
|
||||
if (photo) {
|
||||
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||
}
|
||||
|
||||
// 3. 구매 신청 생성
|
||||
const [reqResult] = await conn.query(
|
||||
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path)
|
||||
VALUES (?, ?, ?, CURDATE(), ?, ?)`,
|
||||
[itemId, quantity, req.user.id, notes || null, photo_path]
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
// 검색 캐시 무효화
|
||||
koreanSearch.clearCache();
|
||||
|
||||
const request = await PurchaseRequestModel.getById(reqResult.insertId);
|
||||
res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' });
|
||||
} catch (err) {
|
||||
if (conn) await conn.rollback().catch(() => {});
|
||||
logger.error('registerAndRequest error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
if (conn) conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
// 일괄 신청 (장바구니, 트랜잭션)
|
||||
bulkCreate: async (req, res) => {
|
||||
const { getDb } = require('../dbPool');
|
||||
let conn;
|
||||
try {
|
||||
const { items, photo } = req.body;
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!item.item_id && !item.item_name) {
|
||||
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
|
||||
}
|
||||
if (!item.quantity || item.quantity < 1) {
|
||||
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
|
||||
let photo_path = null;
|
||||
if (photo) {
|
||||
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
conn = await db.getConnection();
|
||||
await conn.beginTransaction();
|
||||
|
||||
const createdIds = [];
|
||||
let newItemRegistered = false;
|
||||
|
||||
for (const item of items) {
|
||||
let itemId = item.item_id || null;
|
||||
|
||||
// 신규 품목 등록 (is_new)
|
||||
if (item.is_new && item.item_name) {
|
||||
const [existing] = await conn.query(
|
||||
`SELECT item_id FROM consumable_items
|
||||
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
itemId = existing[0].item_id;
|
||||
} else {
|
||||
// 신규 품목 사진 저장 (마스터에)
|
||||
let itemPhotoPath = null;
|
||||
if (item.item_photo) {
|
||||
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
|
||||
}
|
||||
const [ins] = await conn.query(
|
||||
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
|
||||
);
|
||||
itemId = ins.insertId;
|
||||
newItemRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// purchase_request 생성
|
||||
const [result] = await conn.query(
|
||||
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
|
||||
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
|
||||
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
|
||||
item.quantity, req.user.id, item.notes || null, photo_path]
|
||||
);
|
||||
createdIds.push(result.insertId);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
if (newItemRegistered) koreanSearch.clearCache();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { request_ids: createdIds, count: createdIds.length },
|
||||
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
|
||||
});
|
||||
} catch (err) {
|
||||
if (conn) await conn.rollback().catch(() => {});
|
||||
logger.error('bulkCreate error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
if (conn) conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
// 품목 마스터 사진 등록/업데이트
|
||||
updateItemPhoto: async (req, res) => {
|
||||
try {
|
||||
const { photo } = req.body;
|
||||
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
|
||||
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
|
||||
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
|
||||
koreanSearch.clearCache();
|
||||
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('updateItemPhoto error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 스마트 검색 (초성 + 별칭 + substring)
|
||||
search: async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
const results = await koreanSearch.search(q || '');
|
||||
res.json({ success: true, data: results });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest search error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 소모품 목록 (select용)
|
||||
getConsumableItems: async (req, res) => {
|
||||
try {
|
||||
const items = await PurchaseModel.getConsumableItems();
|
||||
res.json({ success: true, data: items });
|
||||
} catch (err) {
|
||||
logger.error('ConsumableItems get error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 업체 목록 (select용)
|
||||
getVendors: async (req, res) => {
|
||||
try {
|
||||
const vendors = await PurchaseModel.getVendors();
|
||||
res.json({ success: true, data: vendors });
|
||||
} catch (err) {
|
||||
logger.error('Vendors get error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 내 신청 목록 (모바일용, 페이지네이션)
|
||||
getMyRequests: async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const { status } = req.query;
|
||||
const result = await PurchaseRequestModel.getMyRequests(req.user.id, { page, limit, status });
|
||||
res.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest getMyRequests error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 개별 입고 처리 (admin)
|
||||
receive: async (req, res) => {
|
||||
try {
|
||||
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
if (existing.status !== 'purchased') {
|
||||
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 입고 처리할 수 있습니다.' });
|
||||
}
|
||||
|
||||
const { received_location, photo } = req.body;
|
||||
let receivedPhotoPath = null;
|
||||
if (photo) {
|
||||
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
|
||||
}
|
||||
|
||||
const updated = await PurchaseRequestModel.receive(req.params.id, {
|
||||
receivedPhotoPath,
|
||||
receivedLocation: received_location || null,
|
||||
receivedBy: req.user.id
|
||||
});
|
||||
|
||||
// batch 내 전체 입고 완료 시 batch.status 자동 전환
|
||||
if (existing.batch_id) {
|
||||
const allReceived = await PurchaseRequestModel.checkBatchAllReceived(existing.batch_id);
|
||||
if (allReceived) {
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ? WHERE batch_id = ?`,
|
||||
[req.user.id, existing.batch_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 신청자에게 입고 알림
|
||||
notifyHelper.send({
|
||||
type: 'purchase',
|
||||
title: '소모품 입고 완료',
|
||||
message: `${existing.item_name || existing.custom_item_name} 입고 완료${received_location ? '. 보관위치: ' + received_location : ''}`,
|
||||
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||
target_user_ids: [existing.requester_id],
|
||||
created_by: req.user.id
|
||||
}).catch(() => {});
|
||||
|
||||
res.json({ success: true, data: updated, message: '입고 처리가 완료되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest receive error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 구매 취소 (purchased → cancelled)
|
||||
cancel: async (req, res) => {
|
||||
try {
|
||||
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
if (existing.status !== 'purchased') {
|
||||
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 취소할 수 있습니다.' });
|
||||
}
|
||||
const { cancel_reason } = req.body;
|
||||
const updated = await PurchaseRequestModel.cancelPurchase(req.params.id, {
|
||||
cancelledBy: req.user.id,
|
||||
cancelReason: cancel_reason
|
||||
});
|
||||
res.json({ success: true, data: updated, message: '구매가 취소되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest cancel error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 반품 (received → returned)
|
||||
returnItem: async (req, res) => {
|
||||
try {
|
||||
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
if (existing.status !== 'received') {
|
||||
return res.status(400).json({ success: false, message: '입고완료 상태의 신청만 반품할 수 있습니다.' });
|
||||
}
|
||||
const { cancel_reason } = req.body;
|
||||
const updated = await PurchaseRequestModel.returnItem(req.params.id, {
|
||||
cancelledBy: req.user.id,
|
||||
cancelReason: cancel_reason
|
||||
});
|
||||
|
||||
// 신청자에게 반품 알림
|
||||
notifyHelper.send({
|
||||
type: 'purchase',
|
||||
title: '소모품 반품 처리',
|
||||
message: `${existing.item_name || existing.custom_item_name} 반품 처리되었습니다.${cancel_reason ? ' 사유: ' + cancel_reason : ''}`,
|
||||
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||
target_user_ids: [existing.requester_id],
|
||||
created_by: req.user.id
|
||||
}).catch(() => {});
|
||||
|
||||
res.json({ success: true, data: updated, message: '반품 처리되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest return error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 취소 → 대기로 되돌리기
|
||||
revertCancel: async (req, res) => {
|
||||
try {
|
||||
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||
if (existing.status !== 'cancelled') {
|
||||
return res.status(400).json({ success: false, message: '취소 상태의 신청만 되돌릴 수 있습니다.' });
|
||||
}
|
||||
const updated = await PurchaseRequestModel.revertCancel(req.params.id);
|
||||
res.json({ success: true, data: updated, message: '대기 상태로 되돌렸습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('PurchaseRequest revertCancel error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PurchaseRequestController;
|
||||
259
system1-factory/api/controllers/scheduleController.js
Normal file
259
system1-factory/api/controllers/scheduleController.js
Normal file
@@ -0,0 +1,259 @@
|
||||
const ScheduleModel = require('../models/scheduleModel');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const ScheduleController = {
|
||||
// === 공정 단계 ===
|
||||
getPhases: async (req, res) => {
|
||||
try {
|
||||
const rows = await ScheduleModel.getPhases();
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getPhases error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
createPhase: async (req, res) => {
|
||||
try {
|
||||
const { phase_name, display_order, color } = req.body;
|
||||
if (!phase_name) return res.status(400).json({ success: false, message: '단계명을 입력해주세요.' });
|
||||
const id = await ScheduleModel.createPhase({ phase_name, display_order, color });
|
||||
res.status(201).json({ success: true, data: { phase_id: id }, message: '공정 단계가 추가되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule createPhase error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
updatePhase: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.updatePhase(req.params.id, req.body);
|
||||
res.json({ success: true, message: '공정 단계가 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule updatePhase error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 작업 템플릿 ===
|
||||
getTemplates: async (req, res) => {
|
||||
try {
|
||||
const rows = await ScheduleModel.getTemplates(req.query.phase_id);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getTemplates error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 공정표 항목 ===
|
||||
getEntries: async (req, res) => {
|
||||
try {
|
||||
const { project_id, year, month } = req.query;
|
||||
const rows = await ScheduleModel.getEntries({ project_id, year, month });
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getEntries error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
getGanttData: async (req, res) => {
|
||||
try {
|
||||
const year = req.query.year || new Date().getFullYear();
|
||||
const data = await ScheduleModel.getGanttData(year);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getGanttData error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
createEntry: async (req, res) => {
|
||||
try {
|
||||
const { project_id, phase_id, task_name, start_date, end_date } = req.body;
|
||||
if (!project_id || !phase_id || !task_name || !start_date || !end_date) {
|
||||
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
|
||||
}
|
||||
const id = await ScheduleModel.createEntry({
|
||||
...req.body,
|
||||
created_by: req.user.user_id || req.user.id
|
||||
});
|
||||
res.status(201).json({ success: true, data: { entry_id: id }, message: '공정표 항목이 추가되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule createEntry error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
createBatchEntries: async (req, res) => {
|
||||
try {
|
||||
const { project_id, phase_id, entries } = req.body;
|
||||
if (!project_id || !phase_id || !entries || entries.length === 0) {
|
||||
return res.status(400).json({ success: false, message: '프로젝트, 단계, 항목 정보를 입력해주세요.' });
|
||||
}
|
||||
const ids = await ScheduleModel.createBatchEntries(
|
||||
project_id, phase_id, entries, req.user.user_id || req.user.id
|
||||
);
|
||||
res.status(201).json({ success: true, data: { entry_ids: ids }, message: `${ids.length}개 항목이 일괄 추가되었습니다.` });
|
||||
} catch (err) {
|
||||
logger.error('Schedule createBatchEntries error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
updateEntry: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.updateEntry(req.params.id, req.body);
|
||||
res.json({ success: true, message: '공정표 항목이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule updateEntry error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
updateProgress: async (req, res) => {
|
||||
try {
|
||||
const { progress } = req.body;
|
||||
if (progress === undefined || progress < 0 || progress > 100) {
|
||||
return res.status(400).json({ success: false, message: '진행률은 0~100 사이의 값이어야 합니다.' });
|
||||
}
|
||||
await ScheduleModel.updateProgress(req.params.id, progress);
|
||||
res.json({ success: true, message: '진행률이 업데이트되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule updateProgress error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteEntry: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.deleteEntry(req.params.id);
|
||||
res.json({ success: true, message: '공정표 항목이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule deleteEntry error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 의존관계 ===
|
||||
addDependency: async (req, res) => {
|
||||
try {
|
||||
const { depends_on_entry_id } = req.body;
|
||||
if (!depends_on_entry_id) {
|
||||
return res.status(400).json({ success: false, message: '선행 작업을 선택해주세요.' });
|
||||
}
|
||||
await ScheduleModel.addDependency(req.params.id, depends_on_entry_id);
|
||||
res.status(201).json({ success: true, message: '의존관계가 추가되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule addDependency error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
removeDependency: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.removeDependency(req.params.id, req.params.depId);
|
||||
res.json({ success: true, message: '의존관계가 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule removeDependency error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 마일스톤 ===
|
||||
getMilestones: async (req, res) => {
|
||||
try {
|
||||
const rows = await ScheduleModel.getMilestones({ project_id: req.query.project_id });
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getMilestones error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
createMilestone: async (req, res) => {
|
||||
try {
|
||||
const { project_id, milestone_name, milestone_date } = req.body;
|
||||
if (!project_id || !milestone_name || !milestone_date) {
|
||||
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
|
||||
}
|
||||
const id = await ScheduleModel.createMilestone({
|
||||
...req.body,
|
||||
created_by: req.user.user_id || req.user.id
|
||||
});
|
||||
res.status(201).json({ success: true, data: { milestone_id: id }, message: '마일스톤이 추가되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule createMilestone error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
updateMilestone: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.updateMilestone(req.params.id, req.body);
|
||||
res.json({ success: true, message: '마일스톤이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule updateMilestone error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteMilestone: async (req, res) => {
|
||||
try {
|
||||
await ScheduleModel.deleteMilestone(req.params.id);
|
||||
res.json({ success: true, message: '마일스톤이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Schedule deleteMilestone error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 제품유형 ===
|
||||
getProductTypes: async (req, res) => {
|
||||
try {
|
||||
const rows = await ScheduleModel.getProductTypes();
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getProductTypes error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 표준공정 자동 생성 ===
|
||||
generateFromTemplate: async (req, res) => {
|
||||
try {
|
||||
const { project_id, product_type_code } = req.body;
|
||||
if (!project_id || !product_type_code) {
|
||||
return res.status(400).json({ success: false, message: '프로젝트와 제품유형을 선택해주세요.' });
|
||||
}
|
||||
const result = await ScheduleModel.generateFromTemplate(
|
||||
project_id, product_type_code, req.user.user_id || req.user.id
|
||||
);
|
||||
if (result.error) {
|
||||
return res.status(409).json({ success: false, message: result.error });
|
||||
}
|
||||
res.status(201).json({ success: true, data: result, message: `${result.created}개 표준공정이 생성되었습니다.` });
|
||||
} catch (err) {
|
||||
logger.error('Schedule generateFromTemplate error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// === 부적합 연동 ===
|
||||
getNonconformance: async (req, res) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
if (!project_id) {
|
||||
return res.status(400).json({ success: false, message: '프로젝트를 선택해주세요.' });
|
||||
}
|
||||
const rows = await ScheduleModel.getNonconformanceByProject(project_id);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Schedule getNonconformance error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ScheduleController;
|
||||
102
system1-factory/api/controllers/settlementController.js
Normal file
102
system1-factory/api/controllers/settlementController.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const SettlementModel = require('../models/settlementModel');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const SettlementController = {
|
||||
// 월간 요약 (분류별 + 업체별)
|
||||
getMonthlySummary: async (req, res) => {
|
||||
try {
|
||||
const { year_month } = req.query;
|
||||
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||
|
||||
const [categorySummary, vendorSummary] = await Promise.all([
|
||||
SettlementModel.getCategorySummary(year_month),
|
||||
SettlementModel.getVendorSummary(year_month)
|
||||
]);
|
||||
|
||||
res.json({ success: true, data: { categorySummary, vendorSummary } });
|
||||
} catch (err) {
|
||||
logger.error('Settlement summary error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 월간 상세 구매 목록
|
||||
getMonthlyPurchases: async (req, res) => {
|
||||
try {
|
||||
const { year_month } = req.query;
|
||||
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||
const rows = await SettlementModel.getMonthlyPurchases(year_month);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Settlement purchases error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 가격 변동 목록
|
||||
getPriceChanges: async (req, res) => {
|
||||
try {
|
||||
const { year_month } = req.query;
|
||||
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||
const rows = await SettlementModel.getPriceChanges(year_month);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Settlement priceChanges error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 입고일 기준 월간 요약
|
||||
getMonthlyReceivedSummary: async (req, res) => {
|
||||
try {
|
||||
const { year_month } = req.query;
|
||||
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||
const categorySummary = await SettlementModel.getCategorySummaryByReceived(year_month);
|
||||
res.json({ success: true, data: { categorySummary } });
|
||||
} catch (err) {
|
||||
logger.error('Settlement received summary error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 입고일 기준 월간 상세 목록
|
||||
getMonthlyReceivedList: async (req, res) => {
|
||||
try {
|
||||
const { year_month } = req.query;
|
||||
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||
const rows = await SettlementModel.getMonthlyReceived(year_month);
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error('Settlement received list error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 정산 완료
|
||||
complete: async (req, res) => {
|
||||
try {
|
||||
const { year_month, vendor_id, notes } = req.body;
|
||||
if (!year_month || !vendor_id) return res.status(400).json({ success: false, message: '년월과 업체를 선택해주세요.' });
|
||||
const result = await SettlementModel.completeSettlement(year_month, vendor_id, req.user.id, notes);
|
||||
res.json({ success: true, data: result, message: '정산 완료 처리되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Settlement complete error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
},
|
||||
|
||||
// 정산 취소
|
||||
cancel: async (req, res) => {
|
||||
try {
|
||||
const { year_month, vendor_id } = req.body;
|
||||
if (!year_month || !vendor_id) return res.status(400).json({ success: false, message: '년월과 업체를 선택해주세요.' });
|
||||
const result = await SettlementModel.cancelSettlement(year_month, vendor_id);
|
||||
res.json({ success: true, data: result, message: '정산이 취소되었습니다.' });
|
||||
} catch (err) {
|
||||
logger.error('Settlement cancel error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = SettlementController;
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Push 구독 테이블 생성
|
||||
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p"$MYSQL_PASSWORD" hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
endpoint VARCHAR(1000) NOT NULL,
|
||||
p256dh VARCHAR(500) NOT NULL,
|
||||
auth VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_endpoint (endpoint(500)),
|
||||
INDEX idx_push_user (user_id)
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
-- 생산소모품 구매 관리 시스템 테이블
|
||||
|
||||
-- 업체 (tkuser에서 CRUD)
|
||||
CREATE TABLE IF NOT EXISTS vendors (
|
||||
vendor_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
vendor_name VARCHAR(100) NOT NULL,
|
||||
business_number VARCHAR(20),
|
||||
representative VARCHAR(50),
|
||||
contact_name VARCHAR(50),
|
||||
contact_phone VARCHAR(20),
|
||||
address VARCHAR(200),
|
||||
bank_name VARCHAR(50),
|
||||
bank_account VARCHAR(50),
|
||||
notes TEXT,
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 소모품 마스터 (tkuser에서 CRUD)
|
||||
CREATE TABLE IF NOT EXISTS consumable_items (
|
||||
item_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
item_name VARCHAR(100) NOT NULL,
|
||||
spec VARCHAR(200) DEFAULT NULL,
|
||||
maker VARCHAR(100),
|
||||
category ENUM('consumable','safety','repair','equipment') NOT NULL
|
||||
COMMENT '소모품, 안전용품, 수선비, 설비',
|
||||
base_price DECIMAL(12,0) DEFAULT 0,
|
||||
unit VARCHAR(20) DEFAULT 'EA',
|
||||
photo_path VARCHAR(255),
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_name_spec_maker (item_name, spec, maker)
|
||||
);
|
||||
|
||||
-- 구매신청 (tkfb에서 CRUD) — item_id NULL 허용 + 직접입력/사진 컬럼 추가
|
||||
CREATE TABLE IF NOT EXISTS purchase_requests (
|
||||
request_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
item_id INT NULL,
|
||||
custom_item_name VARCHAR(100) NULL COMMENT '직접입력 품명',
|
||||
custom_category ENUM('consumable','safety','repair','equipment') NULL COMMENT '직접입력 카테고리',
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
requester_id INT NOT NULL COMMENT 'FK → sso_users.user_id',
|
||||
request_date DATE NOT NULL,
|
||||
status ENUM('pending','purchased','hold') DEFAULT 'pending'
|
||||
COMMENT '대기, 구매완료, 보류',
|
||||
hold_reason TEXT,
|
||||
notes TEXT,
|
||||
photo_path VARCHAR(255) NULL COMMENT '첨부 사진',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id),
|
||||
FOREIGN KEY (requester_id) REFERENCES sso_users(user_id)
|
||||
);
|
||||
|
||||
-- 구매 내역 (tkfb에서 CRUD)
|
||||
CREATE TABLE IF NOT EXISTS purchases (
|
||||
purchase_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
request_id INT,
|
||||
item_id INT NOT NULL,
|
||||
vendor_id INT,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
unit_price DECIMAL(12,0) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
purchaser_id INT NOT NULL COMMENT 'FK → sso_users.user_id',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id),
|
||||
FOREIGN KEY (request_id) REFERENCES purchase_requests(request_id),
|
||||
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
|
||||
FOREIGN KEY (purchaser_id) REFERENCES sso_users(user_id)
|
||||
);
|
||||
|
||||
-- 가격 변동 이력
|
||||
CREATE TABLE IF NOT EXISTS consumable_price_history (
|
||||
history_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
item_id INT NOT NULL,
|
||||
old_price DECIMAL(12,0),
|
||||
new_price DECIMAL(12,0) NOT NULL,
|
||||
changed_by INT COMMENT 'FK → sso_users.user_id',
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id)
|
||||
);
|
||||
|
||||
-- 월간 정산
|
||||
CREATE TABLE IF NOT EXISTS monthly_settlements (
|
||||
settlement_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`year_month` VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
|
||||
vendor_id INT NOT NULL,
|
||||
total_amount DECIMAL(12,0) DEFAULT 0,
|
||||
status ENUM('pending','completed') DEFAULT 'pending',
|
||||
completed_at TIMESTAMP NULL,
|
||||
completed_by INT COMMENT 'FK → sso_users.user_id',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
|
||||
UNIQUE KEY uq_ym_vendor (`year_month`, vendor_id)
|
||||
);
|
||||
|
||||
-- 페이지 키 등록
|
||||
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, is_admin_only, display_order) VALUES
|
||||
('purchase.request', '구매신청', '/pages/purchase/request.html', 'purchase', 0, 40),
|
||||
('purchase.analysis', '구매 분석', '/pages/admin/purchase-analysis.html', 'purchase', 1, 41);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- ntfy 구독 테이블
|
||||
CREATE TABLE IF NOT EXISTS ntfy_subscriptions (
|
||||
user_id INT PRIMARY KEY,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 공정표 + 생산회의록 시스템 마이그레이션
|
||||
*
|
||||
* 테이블 8개: schedule_phases, schedule_task_templates, schedule_entries,
|
||||
* schedule_entry_dependencies, schedule_milestones, meeting_minutes,
|
||||
* meeting_attendees, meeting_agenda_items
|
||||
*/
|
||||
|
||||
exports.up = async (knex) => {
|
||||
// 1. 공정 단계
|
||||
await knex.schema.createTable('schedule_phases', (table) => {
|
||||
table.increments('phase_id').primary();
|
||||
table.string('phase_name', 100).notNullable();
|
||||
table.integer('display_order').defaultTo(0);
|
||||
table.string('color', 20).defaultTo('#3B82F6');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
|
||||
// 2. 작업 템플릿
|
||||
await knex.schema.createTable('schedule_task_templates', (table) => {
|
||||
table.increments('template_id').primary();
|
||||
table.integer('phase_id').unsigned().notNullable()
|
||||
.references('phase_id').inTable('schedule_phases').onDelete('CASCADE');
|
||||
table.string('task_name', 200).notNullable();
|
||||
table.integer('default_duration_days').defaultTo(7);
|
||||
table.integer('display_order').defaultTo(0);
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
|
||||
// 3. 공정표 항목
|
||||
await knex.schema.createTable('schedule_entries', (table) => {
|
||||
table.increments('entry_id').primary();
|
||||
table.integer('project_id').notNullable()
|
||||
.references('project_id').inTable('projects');
|
||||
table.integer('phase_id').unsigned().notNullable()
|
||||
.references('phase_id').inTable('schedule_phases');
|
||||
table.string('task_name', 200).notNullable();
|
||||
table.date('start_date').notNullable();
|
||||
table.date('end_date').notNullable();
|
||||
table.integer('progress').defaultTo(0);
|
||||
table.string('status', 20).defaultTo('planned');
|
||||
table.string('assignee', 100).nullable();
|
||||
table.text('notes').nullable();
|
||||
table.integer('display_order').defaultTo(0);
|
||||
table.integer('created_by').nullable()
|
||||
.references('user_id').inTable('sso_users');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// 4. 작업 의존관계 (다대다)
|
||||
await knex.schema.createTable('schedule_entry_dependencies', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('entry_id').unsigned().notNullable()
|
||||
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
|
||||
table.integer('depends_on_entry_id').unsigned().notNullable()
|
||||
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
|
||||
table.unique(['entry_id', 'depends_on_entry_id']);
|
||||
});
|
||||
|
||||
// 5. 마일스톤
|
||||
await knex.schema.createTable('schedule_milestones', (table) => {
|
||||
table.increments('milestone_id').primary();
|
||||
table.integer('project_id').notNullable()
|
||||
.references('project_id').inTable('projects');
|
||||
table.integer('entry_id').unsigned().nullable()
|
||||
.references('entry_id').inTable('schedule_entries').onDelete('SET NULL');
|
||||
table.string('milestone_name', 200).notNullable();
|
||||
table.date('milestone_date').notNullable();
|
||||
table.string('milestone_type', 30).defaultTo('deadline');
|
||||
table.string('status', 20).defaultTo('upcoming');
|
||||
table.text('notes').nullable();
|
||||
table.integer('created_by').nullable()
|
||||
.references('user_id').inTable('sso_users');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// 6. 생산회의록
|
||||
await knex.schema.createTable('meeting_minutes', (table) => {
|
||||
table.increments('meeting_id').primary();
|
||||
table.date('meeting_date').notNullable();
|
||||
table.string('meeting_time', 10).nullable();
|
||||
table.string('title', 300).notNullable();
|
||||
table.string('location', 200).nullable();
|
||||
table.text('summary').nullable();
|
||||
table.string('status', 20).defaultTo('draft');
|
||||
table.integer('created_by').nullable()
|
||||
.references('user_id').inTable('sso_users');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// 7. 회의 참석자 (정규화)
|
||||
await knex.schema.createTable('meeting_attendees', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('meeting_id').unsigned().notNullable()
|
||||
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
|
||||
table.integer('user_id').notNullable()
|
||||
.references('user_id').inTable('sso_users');
|
||||
table.unique(['meeting_id', 'user_id']);
|
||||
});
|
||||
|
||||
// 8. 회의 안건
|
||||
await knex.schema.createTable('meeting_agenda_items', (table) => {
|
||||
table.increments('item_id').primary();
|
||||
table.integer('meeting_id').unsigned().notNullable()
|
||||
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
|
||||
table.integer('project_id').nullable()
|
||||
.references('project_id').inTable('projects');
|
||||
table.integer('milestone_id').unsigned().nullable()
|
||||
.references('milestone_id').inTable('schedule_milestones').onDelete('SET NULL');
|
||||
table.string('item_type', 30).defaultTo('other');
|
||||
table.text('content').notNullable();
|
||||
table.text('decision').nullable();
|
||||
table.text('action_required').nullable();
|
||||
table.integer('responsible_user_id').nullable()
|
||||
.references('user_id').inTable('sso_users');
|
||||
table.date('due_date').nullable();
|
||||
table.string('status', 20).defaultTo('open');
|
||||
table.integer('display_order').defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Seed: 공정 단계
|
||||
await knex('schedule_phases').insert([
|
||||
{ phase_name: 'Outsourcing', display_order: 1, color: '#3B82F6' },
|
||||
{ phase_name: 'BASE', display_order: 2, color: '#10B981' },
|
||||
{ phase_name: 'SHOP', display_order: 3, color: '#F59E0B' },
|
||||
{ phase_name: 'PV/Heat Exchanger', display_order: 4, color: '#8B5CF6' },
|
||||
]);
|
||||
|
||||
// Seed: 작업 템플릿
|
||||
const phases = await knex('schedule_phases').select('phase_id', 'phase_name');
|
||||
const phaseMap = {};
|
||||
phases.forEach(p => { phaseMap[p.phase_name] = p.phase_id; });
|
||||
|
||||
const templates = [
|
||||
// Outsourcing
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: '용기입고', default_duration_days: 14, display_order: 1 },
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: 'PTK', default_duration_days: 10, display_order: 2 },
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: 'Tray and Instrument Wiring', default_duration_days: 7, display_order: 3 },
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: 'Painting', default_duration_days: 5, display_order: 4 },
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: 'Instrument Wiring', default_duration_days: 7, display_order: 5 },
|
||||
{ phase_id: phaseMap['Outsourcing'], task_name: 'Packing', default_duration_days: 3, display_order: 6 },
|
||||
// BASE
|
||||
{ phase_id: phaseMap['BASE'], task_name: 'Base Fabrication', default_duration_days: 14, display_order: 1 },
|
||||
{ phase_id: phaseMap['BASE'], task_name: 'Base 제작', default_duration_days: 10, display_order: 2 },
|
||||
{ phase_id: phaseMap['BASE'], task_name: '용기설치', default_duration_days: 5, display_order: 3 },
|
||||
// SHOP
|
||||
{ phase_id: phaseMap['SHOP'], task_name: '배관자재입고', default_duration_days: 7, display_order: 1 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: 'Pre-Fabrication for Piping', default_duration_days: 10, display_order: 2 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: '1st Piping Assembly', default_duration_days: 14, display_order: 3 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: 'Hydro. Test', default_duration_days: 3, display_order: 4 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: 'Re-Assembly', default_duration_days: 7, display_order: 5 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: 'Tubing', default_duration_days: 5, display_order: 6 },
|
||||
{ phase_id: phaseMap['SHOP'], task_name: 'FAT', default_duration_days: 2, display_order: 7 },
|
||||
];
|
||||
await knex('schedule_task_templates').insert(templates);
|
||||
|
||||
// 페이지 접근 권한 등록
|
||||
await knex.raw(`
|
||||
INSERT IGNORE INTO pages (page_key, page_name, page_path, description) VALUES
|
||||
('work.schedule', '공정표', '/pages/work/schedule.html', '프로젝트 공정표 Gantt 뷰'),
|
||||
('work.meetings', '생산회의록', '/pages/work/meetings.html', '생산회의록 관리'),
|
||||
('work.meeting_detail', '회의록 상세', '/pages/work/meeting-detail.html', '회의록 상세/작성')
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async (knex) => {
|
||||
await knex.schema.dropTableIfExists('meeting_agenda_items');
|
||||
await knex.schema.dropTableIfExists('meeting_attendees');
|
||||
await knex.schema.dropTableIfExists('meeting_minutes');
|
||||
await knex.schema.dropTableIfExists('schedule_milestones');
|
||||
await knex.schema.dropTableIfExists('schedule_entry_dependencies');
|
||||
await knex.schema.dropTableIfExists('schedule_entries');
|
||||
await knex.schema.dropTableIfExists('schedule_task_templates');
|
||||
await knex.schema.dropTableIfExists('schedule_phases');
|
||||
|
||||
await knex.raw(`DELETE FROM pages WHERE page_key IN ('work.schedule', 'work.meetings', 'work.meeting_detail')`);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 (앱 레벨 참조)';
|
||||
@@ -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='월간 근무 확인 (승인/반려)'
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal file
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal 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 '%안전화%';
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -1,357 +1 @@
|
||||
/**
|
||||
* 통합 인증/인가 미들웨어
|
||||
*
|
||||
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 권한 레벨 계층 구조
|
||||
* 숫자가 높을수록 상위 권한
|
||||
*/
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 미들웨어
|
||||
*
|
||||
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
||||
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
||||
*
|
||||
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
||||
*
|
||||
* @example
|
||||
* router.get('/profile', requireAuth, getProfile);
|
||||
*/
|
||||
const requireAuth = (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (!authHeader) {
|
||||
logger.warn('인증 실패: Authorization 헤더 없음', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
logger.warn('인증 실패: Bearer 토큰 누락', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
||||
}
|
||||
|
||||
// JWT 검증 (SSO 공유 시크릿 - docker-compose에서 JWT_SECRET=SSO_JWT_SECRET로 설정)
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
|
||||
logger.debug('인증 성공', {
|
||||
user_id: decoded.user_id || decoded.id,
|
||||
username: decoded.username,
|
||||
role: decoded.role,
|
||||
access_level: decoded.access_level
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
||||
} else if (err.name === 'TokenExpiredError') {
|
||||
logger.warn('인증 실패: 만료된 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('토큰이 만료되었습니다');
|
||||
} else if (err instanceof AuthenticationError) {
|
||||
// 이미 AuthenticationError인 경우 그대로 throw
|
||||
throw err;
|
||||
} else {
|
||||
logger.error('인증 처리 중 예상치 못한 오류', {
|
||||
error: err.message,
|
||||
stack: err.stack
|
||||
});
|
||||
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 역할(들) 권한 체크 미들웨어
|
||||
*
|
||||
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {...string} roles - 허용할 역할 목록
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 없는 경우
|
||||
*
|
||||
* @example
|
||||
* // 단일 역할
|
||||
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
||||
*
|
||||
* // 여러 역할
|
||||
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
||||
*/
|
||||
const requireRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
||||
const rolesLower = roles.map(r => r.toLowerCase());
|
||||
|
||||
if (!rolesLower.includes(userRoleLower)) {
|
||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_role: userRole,
|
||||
required_roles: roles,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('역할 권한 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
required_roles: roles
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
||||
*
|
||||
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
||||
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 부족한 경우
|
||||
*
|
||||
* @example
|
||||
* // admin 이상 필요 (admin, system만 허용)
|
||||
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
||||
*
|
||||
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
||||
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
||||
*/
|
||||
const requireMinLevel = (minLevel) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
||||
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_level: req.user.access_level,
|
||||
current_level_value: userLevel,
|
||||
required_level: minLevel,
|
||||
required_level_value: requiredLevel,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('권한 레벨 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
access_level: req.user.access_level,
|
||||
required_level: minLevel
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
||||
*
|
||||
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {Object} options - 옵션 객체
|
||||
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
|
||||
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
||||
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
||||
*
|
||||
* @example
|
||||
* // URL 파라미터의 user_id로 체크
|
||||
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'params.user_id'
|
||||
* }), updateUser);
|
||||
*
|
||||
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
|
||||
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'body.user_id',
|
||||
* adminRoles: ['admin', 'system', 'support_team']
|
||||
* }), deleteReport);
|
||||
*/
|
||||
const requireOwnerOrAdmin = (options = {}) => {
|
||||
const {
|
||||
resourceField = 'params.id',
|
||||
userField = 'user_id',
|
||||
adminRoles = ['admin', 'system']
|
||||
} = options;
|
||||
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
// 관리자 권한 체크
|
||||
const userRole = req.user.role;
|
||||
const isAdmin = adminRoles.includes(userRole);
|
||||
|
||||
if (isAdmin) {
|
||||
logger.debug('관리자 권한으로 접근 허용', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
path: req.path
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// 리소스 ID 추출
|
||||
const fieldParts = resourceField.split('.');
|
||||
let resourceId = req;
|
||||
for (const part of fieldParts) {
|
||||
resourceId = resourceId[part];
|
||||
if (resourceId === undefined) break;
|
||||
}
|
||||
|
||||
// 사용자 ID (user_id 또는 id)
|
||||
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
||||
|
||||
// 소유자 체크
|
||||
const isOwner = resourceId && String(resourceId) === String(userId);
|
||||
|
||||
if (!isOwner) {
|
||||
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
resource_id: resourceId,
|
||||
resource_field: resourceField,
|
||||
is_admin: isAdmin,
|
||||
is_owner: isOwner,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
||||
}
|
||||
|
||||
logger.debug('리소스 소유자로 접근 허용', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
resource_id: resourceId,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireAuth를 사용하세요
|
||||
*/
|
||||
const verifyToken = requireAuth;
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('admin', 'system')을 사용하세요
|
||||
*/
|
||||
const requireAdmin = requireRole('admin', 'system');
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('system')을 사용하세요
|
||||
*/
|
||||
const requireSystem = requireRole('system');
|
||||
|
||||
module.exports = {
|
||||
// 주요 미들웨어
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
|
||||
// 레거시 호환성
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
|
||||
// 상수
|
||||
ACCESS_LEVELS
|
||||
};
|
||||
module.exports = require('../shared/middleware/auth');
|
||||
|
||||
@@ -150,9 +150,10 @@ class AttendanceModel {
|
||||
static async initializeDailyRecords(date, createdBy) {
|
||||
const db = await getDb();
|
||||
|
||||
// 1. 활성 작업자 조회
|
||||
// 1. 활성 작업자 조회 (입사일 이전 제외)
|
||||
const [workers] = await db.execute(
|
||||
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL'
|
||||
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL AND (hire_date IS NULL OR hire_date <= ?)',
|
||||
[date]
|
||||
);
|
||||
|
||||
if (workers.length === 0) return { inserted: 0 };
|
||||
@@ -269,8 +270,7 @@ class AttendanceModel {
|
||||
w.job_type,
|
||||
COALESCE(dar.total_work_hours, 0) as total_work_hours,
|
||||
COALESCE(dar.status, 'incomplete') as status,
|
||||
dar.is_vacation_processed,
|
||||
dar.overtime_approved,
|
||||
dar.is_overtime_approved,
|
||||
wat.type_name as attendance_type_name,
|
||||
wat.type_code as attendance_type_code,
|
||||
vt.type_name as vacation_type_name,
|
||||
|
||||
47
system1-factory/api/models/consumableCategoryModel.js
Normal file
47
system1-factory/api/models/consumableCategoryModel.js
Normal 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;
|
||||
148
system1-factory/api/models/dashboardModel.js
Normal file
148
system1-factory/api/models/dashboardModel.js
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
// models/equipmentModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
const notificationModel = require('./notificationModel');
|
||||
const notifyHelper = require('../shared/utils/notifyHelper');
|
||||
|
||||
const EquipmentModel = {
|
||||
// CREATE - 설비 생성
|
||||
@@ -669,17 +669,16 @@ const EquipmentModel = {
|
||||
['repair_needed', requestData.equipment_id]
|
||||
);
|
||||
|
||||
try {
|
||||
await notificationModel.createRepairNotification({
|
||||
equipment_id: requestData.equipment_id,
|
||||
equipment_name: requestData.equipment_name || '설비',
|
||||
repair_type: requestData.repair_type || '일반 수리',
|
||||
request_id: result.insertId,
|
||||
// fire-and-forget: 알림 실패가 수리 신청을 블로킹하면 안 됨
|
||||
notifyHelper.send({
|
||||
type: 'repair',
|
||||
title: `수리 신청: ${requestData.equipment_name || '설비'}`,
|
||||
message: `${requestData.repair_type || '일반 수리'} 수리가 신청되었습니다.`,
|
||||
link_url: '/pages/admin/repair-management.html',
|
||||
reference_type: 'work_issue_reports',
|
||||
reference_id: result.insertId,
|
||||
created_by: requestData.reported_by
|
||||
});
|
||||
} catch (notifError) {
|
||||
// 알림 생성 실패해도 수리 신청은 성공으로 처리
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
report_id: result.insertId,
|
||||
@@ -711,6 +710,33 @@ const EquipmentModel = {
|
||||
return rows;
|
||||
},
|
||||
|
||||
getRepairRequests: async (status) => {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT wir.report_id, wir.status, wir.additional_description, wir.created_at,
|
||||
e.equipment_name, irc.category_name, iri.item_name,
|
||||
u_rep.name AS reported_by_name, w.workplace_name
|
||||
FROM work_issue_reports wir
|
||||
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
|
||||
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
|
||||
LEFT JOIN equipments e ON wir.equipment_id = e.equipment_id
|
||||
LEFT JOIN users u_rep ON wir.reporter_id = u_rep.user_id
|
||||
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
|
||||
WHERE irc.category_name = '설비 수리'
|
||||
`;
|
||||
const values = [];
|
||||
|
||||
if (status) {
|
||||
query += ' AND wir.status = ?';
|
||||
values.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY wir.created_at DESC';
|
||||
|
||||
const [rows] = await db.query(query, values);
|
||||
return rows;
|
||||
},
|
||||
|
||||
getRepairCategories: async () => {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
|
||||
35
system1-factory/api/models/itemAliasModel.js
Normal file
35
system1-factory/api/models/itemAliasModel.js
Normal 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;
|
||||
223
system1-factory/api/models/meetingModel.js
Normal file
223
system1-factory/api/models/meetingModel.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// models/meetingModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const MeetingModel = {
|
||||
// === 회의록 ===
|
||||
async getAll(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT m.*,
|
||||
su.name AS created_by_name,
|
||||
(SELECT COUNT(*) FROM meeting_attendees WHERE meeting_id = m.meeting_id) AS attendee_count,
|
||||
(SELECT COUNT(*) FROM meeting_agenda_items WHERE meeting_id = m.meeting_id) AS agenda_count,
|
||||
(SELECT COUNT(*) FROM meeting_agenda_items
|
||||
WHERE meeting_id = m.meeting_id AND status IN ('open','in_progress')) AS open_action_count
|
||||
FROM meeting_minutes m
|
||||
LEFT JOIN sso_users su ON m.created_by = su.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
if (filters.year && filters.month) {
|
||||
sql += ' AND YEAR(m.meeting_date) = ? AND MONTH(m.meeting_date) = ?';
|
||||
params.push(filters.year, filters.month);
|
||||
} else if (filters.year) {
|
||||
sql += ' AND YEAR(m.meeting_date) = ?';
|
||||
params.push(filters.year);
|
||||
}
|
||||
if (filters.search) {
|
||||
sql += ' AND (m.title LIKE ? OR m.summary LIKE ?)';
|
||||
params.push(`%${filters.search}%`, `%${filters.search}%`);
|
||||
}
|
||||
sql += ' ORDER BY m.meeting_date DESC, m.created_at DESC';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
async getById(meetingId) {
|
||||
const db = await getDb();
|
||||
// 회의 기본정보
|
||||
const [meetings] = await db.query(`
|
||||
SELECT m.*, su.name AS created_by_name
|
||||
FROM meeting_minutes m
|
||||
LEFT JOIN sso_users su ON m.created_by = su.user_id
|
||||
WHERE m.meeting_id = ?
|
||||
`, [meetingId]);
|
||||
if (meetings.length === 0) return null;
|
||||
const meeting = meetings[0];
|
||||
|
||||
// 참석자
|
||||
const [attendees] = await db.query(`
|
||||
SELECT ma.id, ma.user_id, su.name, su.username, su.department
|
||||
FROM meeting_attendees ma
|
||||
JOIN sso_users su ON ma.user_id = su.user_id
|
||||
WHERE ma.meeting_id = ?
|
||||
ORDER BY su.name
|
||||
`, [meetingId]);
|
||||
meeting.attendees = attendees;
|
||||
|
||||
// 안건
|
||||
const [items] = await db.query(`
|
||||
SELECT ai.*, pr.project_name, pr.job_no AS project_code,
|
||||
ms.milestone_name, ms.milestone_date,
|
||||
su.name AS responsible_name
|
||||
FROM meeting_agenda_items ai
|
||||
LEFT JOIN projects pr ON ai.project_id = pr.project_id
|
||||
LEFT JOIN schedule_milestones ms ON ai.milestone_id = ms.milestone_id
|
||||
LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id
|
||||
WHERE ai.meeting_id = ?
|
||||
ORDER BY ai.display_order, ai.item_id
|
||||
`, [meetingId]);
|
||||
meeting.items = items;
|
||||
|
||||
return meeting;
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO meeting_minutes (meeting_date, meeting_time, title, location, summary, status, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.meeting_date, data.meeting_time || null, data.title,
|
||||
data.location || null, data.summary || null, 'draft', data.created_by]
|
||||
);
|
||||
const meetingId = result.insertId;
|
||||
|
||||
// 참석자 추가
|
||||
if (data.attendees && data.attendees.length > 0) {
|
||||
const values = data.attendees.map(userId => [meetingId, userId]);
|
||||
await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]);
|
||||
}
|
||||
|
||||
return meetingId;
|
||||
},
|
||||
|
||||
async update(meetingId, data) {
|
||||
const db = await getDb();
|
||||
const fields = [];
|
||||
const params = [];
|
||||
const allowed = ['meeting_date', 'meeting_time', 'title', 'location', 'summary'];
|
||||
for (const key of allowed) {
|
||||
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
fields.push('updated_at = NOW()');
|
||||
params.push(meetingId);
|
||||
await db.query(`UPDATE meeting_minutes SET ${fields.join(', ')} WHERE meeting_id = ?`, params);
|
||||
}
|
||||
|
||||
// 참석자 재설정
|
||||
if (data.attendees !== undefined) {
|
||||
await db.query('DELETE FROM meeting_attendees WHERE meeting_id = ?', [meetingId]);
|
||||
if (data.attendees.length > 0) {
|
||||
const values = data.attendees.map(userId => [meetingId, userId]);
|
||||
await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async publish(meetingId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
"UPDATE meeting_minutes SET status = 'published', updated_at = NOW() WHERE meeting_id = ?",
|
||||
[meetingId]
|
||||
);
|
||||
},
|
||||
|
||||
async unpublish(meetingId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
"UPDATE meeting_minutes SET status = 'draft', updated_at = NOW() WHERE meeting_id = ?",
|
||||
[meetingId]
|
||||
);
|
||||
},
|
||||
|
||||
async delete(meetingId) {
|
||||
const db = await getDb();
|
||||
await db.query('DELETE FROM meeting_minutes WHERE meeting_id = ?', [meetingId]);
|
||||
},
|
||||
|
||||
// === 안건 ===
|
||||
async addItem(meetingId, data) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO meeting_agenda_items
|
||||
(meeting_id, project_id, milestone_id, item_type, content, decision, action_required,
|
||||
responsible_user_id, due_date, status, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[meetingId, data.project_id || null, data.milestone_id || null,
|
||||
data.item_type || 'other', data.content, data.decision || null,
|
||||
data.action_required || null, data.responsible_user_id || null,
|
||||
data.due_date || null, data.status || 'open', data.display_order || 0]
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async updateItem(itemId, data) {
|
||||
const db = await getDb();
|
||||
const fields = [];
|
||||
const params = [];
|
||||
const allowed = ['project_id', 'milestone_id', 'item_type', 'content', 'decision',
|
||||
'action_required', 'responsible_user_id', 'due_date', 'status', 'display_order'];
|
||||
for (const key of allowed) {
|
||||
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
fields.push('updated_at = NOW()');
|
||||
params.push(itemId);
|
||||
await db.query(`UPDATE meeting_agenda_items SET ${fields.join(', ')} WHERE item_id = ?`, params);
|
||||
},
|
||||
|
||||
async deleteItem(itemId) {
|
||||
const db = await getDb();
|
||||
await db.query('DELETE FROM meeting_agenda_items WHERE item_id = ?', [itemId]);
|
||||
},
|
||||
|
||||
async updateItemStatus(itemId, status) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
'UPDATE meeting_agenda_items SET status = ?, updated_at = NOW() WHERE item_id = ?',
|
||||
[status, itemId]
|
||||
);
|
||||
},
|
||||
|
||||
// === 미완료 조치사항 ===
|
||||
async getActionItems(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT ai.*, m.title AS meeting_title, m.meeting_date,
|
||||
pr.project_name, pr.job_no AS project_code,
|
||||
su.name AS responsible_name
|
||||
FROM meeting_agenda_items ai
|
||||
JOIN meeting_minutes m ON ai.meeting_id = m.meeting_id
|
||||
LEFT JOIN projects pr ON ai.project_id = pr.project_id
|
||||
LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id
|
||||
WHERE ai.item_type IN ('action_item', 'issue', 'decision')
|
||||
`;
|
||||
const params = [];
|
||||
if (filters.status) {
|
||||
sql += ' AND ai.status = ?';
|
||||
params.push(filters.status);
|
||||
} else {
|
||||
sql += " AND ai.status IN ('open', 'in_progress')";
|
||||
}
|
||||
if (filters.responsible_user_id) {
|
||||
sql += ' AND ai.responsible_user_id = ?';
|
||||
params.push(filters.responsible_user_id);
|
||||
}
|
||||
sql += ' ORDER BY ai.due_date ASC, m.meeting_date DESC';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 회의록 상태 조회 (published 체크용)
|
||||
async getStatus(meetingId) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT status FROM meeting_minutes WHERE meeting_id = ?',
|
||||
[meetingId]
|
||||
);
|
||||
return rows.length > 0 ? rows[0].status : null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = MeetingModel;
|
||||
387
system1-factory/api/models/monthlyComparisonModel.js
Normal file
387
system1-factory/api/models/monthlyComparisonModel.js
Normal 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;
|
||||
@@ -1,197 +0,0 @@
|
||||
// models/notificationModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// 순환 참조를 피하기 위해 함수 내에서 require
|
||||
async function getRecipientIds(notificationType) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT user_id FROM notification_recipients
|
||||
WHERE notification_type = ? AND is_active = 1`,
|
||||
[notificationType]
|
||||
);
|
||||
return rows.map(r => r.user_id);
|
||||
}
|
||||
|
||||
const notificationModel = {
|
||||
// 알림 생성
|
||||
async create(notificationData) {
|
||||
const db = await getDb();
|
||||
const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null]
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
// 읽지 않은 알림 조회 (특정 사용자 또는 전체)
|
||||
async getUnread(userId = null) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM notifications
|
||||
WHERE is_read = 0
|
||||
AND (user_id IS NULL OR user_id = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[userId || 0]
|
||||
);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 전체 알림 조회 (페이징)
|
||||
async getAll(userId = null, page = 1, limit = 20) {
|
||||
const db = await getDb();
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM notifications
|
||||
WHERE (user_id IS NULL OR user_id = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[userId || 0, limit, offset]
|
||||
);
|
||||
|
||||
const [[{ total }]] = await db.query(
|
||||
`SELECT COUNT(*) as total FROM notifications
|
||||
WHERE (user_id IS NULL OR user_id = ?)`,
|
||||
[userId || 0]
|
||||
);
|
||||
|
||||
return { notifications: rows, total, page, limit };
|
||||
},
|
||||
|
||||
// 알림 읽음 처리
|
||||
async markAsRead(notificationId) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
|
||||
[notificationId]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
},
|
||||
|
||||
// 모든 알림 읽음 처리
|
||||
async markAllAsRead(userId = null) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`UPDATE notifications SET is_read = 1, read_at = NOW()
|
||||
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
|
||||
[userId || 0]
|
||||
);
|
||||
return result.affectedRows;
|
||||
},
|
||||
|
||||
// 알림 삭제
|
||||
async delete(notificationId) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM notifications WHERE notification_id = ?`,
|
||||
[notificationId]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
},
|
||||
|
||||
// 오래된 알림 삭제 (30일 이상)
|
||||
async deleteOld(days = 30) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
|
||||
[days]
|
||||
);
|
||||
return result.affectedRows;
|
||||
},
|
||||
|
||||
// 읽지 않은 알림 개수
|
||||
async getUnreadCount(userId = null) {
|
||||
const db = await getDb();
|
||||
const [[{ count }]] = await db.query(
|
||||
`SELECT COUNT(*) as count FROM notifications
|
||||
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
|
||||
[userId || 0]
|
||||
);
|
||||
return count;
|
||||
},
|
||||
|
||||
// 수리 신청 알림 생성 헬퍼 (지정된 수신자에게 전송)
|
||||
async createRepairNotification(repairData) {
|
||||
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
|
||||
|
||||
// 수리 알림 수신자 목록 가져오기
|
||||
const recipientIds = await getRecipientIds('repair');
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
|
||||
return await this.create({
|
||||
type: 'repair',
|
||||
title: `수리 신청: ${equipment_name || '설비'}`,
|
||||
message: `${repair_type} 수리가 신청되었습니다.`,
|
||||
link_url: `/pages/admin/repair-management.html`,
|
||||
reference_type: 'work_issue_reports',
|
||||
reference_id: request_id,
|
||||
created_by
|
||||
});
|
||||
}
|
||||
|
||||
// 지정된 수신자 각각에게 알림 생성
|
||||
const results = [];
|
||||
for (const userId of recipientIds) {
|
||||
const notificationId = await this.create({
|
||||
user_id: userId,
|
||||
type: 'repair',
|
||||
title: `수리 신청: ${equipment_name || '설비'}`,
|
||||
message: `${repair_type} 수리가 신청되었습니다.`,
|
||||
link_url: `/pages/admin/repair-management.html`,
|
||||
reference_type: 'work_issue_reports',
|
||||
reference_id: request_id,
|
||||
created_by
|
||||
});
|
||||
results.push(notificationId);
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
// 일반 알림 생성 (유형별 지정된 수신자에게 전송)
|
||||
async createTypedNotification(notificationData) {
|
||||
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
|
||||
|
||||
// 해당 유형의 수신자 목록 가져오기
|
||||
const recipientIds = await getRecipientIds(type);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
// 수신자가 지정되지 않은 경우 전체 알림
|
||||
return await this.create({
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
link_url,
|
||||
reference_type,
|
||||
reference_id,
|
||||
created_by
|
||||
});
|
||||
}
|
||||
|
||||
// 지정된 수신자 각각에게 알림 생성
|
||||
const results = [];
|
||||
for (const userId of recipientIds) {
|
||||
const notificationId = await this.create({
|
||||
user_id: userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
link_url,
|
||||
reference_type,
|
||||
reference_id,
|
||||
created_by
|
||||
});
|
||||
results.push(notificationId);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = notificationModel;
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
242
system1-factory/api/models/proxyInputModel.js
Normal file
242
system1-factory/api/models/proxyInputModel.js
Normal 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;
|
||||
117
system1-factory/api/models/purchaseBatchModel.js
Normal file
117
system1-factory/api/models/purchaseBatchModel.js
Normal 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;
|
||||
164
system1-factory/api/models/purchaseModel.js
Normal file
164
system1-factory/api/models/purchaseModel.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// models/purchaseModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const PurchaseModel = {
|
||||
// 구매 내역 목록
|
||||
async getAll(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT p.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.photo_path,
|
||||
v.vendor_name, su.name AS purchaser_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||
LEFT JOIN sso_users su ON p.purchaser_id = su.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (filters.vendor_id) { sql += ' AND p.vendor_id = ?'; params.push(filters.vendor_id); }
|
||||
if (filters.category) { sql += ' AND ci.category = ?'; params.push(filters.category); }
|
||||
if (filters.from_date) { sql += ' AND p.purchase_date >= ?'; params.push(filters.from_date); }
|
||||
if (filters.to_date) { sql += ' AND p.purchase_date <= ?'; params.push(filters.to_date); }
|
||||
if (filters.year_month) {
|
||||
sql += ' AND DATE_FORMAT(p.purchase_date, "%Y-%m") = ?';
|
||||
params.push(filters.year_month);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY p.purchase_date DESC, p.created_at DESC';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 구매 처리 (구매신청 → 구매 내역 생성 + 상태 변경)
|
||||
async createFromRequest(data) {
|
||||
const db = await getDb();
|
||||
|
||||
// 구매 내역 INSERT
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO purchases (request_id, item_id, vendor_id, quantity, unit_price, purchase_date, purchaser_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.request_id || null, data.item_id, data.vendor_id || null,
|
||||
data.quantity, data.unit_price, data.purchase_date, data.purchaser_id, data.notes || null]
|
||||
);
|
||||
|
||||
// 구매신청 상태 → purchased
|
||||
if (data.request_id) {
|
||||
await db.query(
|
||||
`UPDATE purchase_requests SET status = 'purchased' WHERE request_id = ?`,
|
||||
[data.request_id]
|
||||
);
|
||||
}
|
||||
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
// 기준가 업데이트 + 이력 기록
|
||||
async updateBasePrice(itemId, newPrice, oldPrice, changedBy) {
|
||||
const db = await getDb();
|
||||
|
||||
// 이력 기록
|
||||
await db.query(
|
||||
`INSERT INTO consumable_price_history (item_id, old_price, new_price, changed_by)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[itemId, oldPrice, newPrice, changedBy]
|
||||
);
|
||||
|
||||
// base_price 갱신
|
||||
await db.query(
|
||||
`UPDATE consumable_items SET base_price = ? WHERE item_id = ?`,
|
||||
[newPrice, itemId]
|
||||
);
|
||||
},
|
||||
|
||||
// 설비 자동 등록 시도 (category='equipment')
|
||||
async tryAutoRegisterEquipment(purchaseData) {
|
||||
try {
|
||||
const EquipmentModel = require('./equipmentModel');
|
||||
const equipmentCode = await EquipmentModel.getNextEquipmentCode('TKP');
|
||||
|
||||
await EquipmentModel.create({
|
||||
equipment_code: equipmentCode,
|
||||
equipment_name: purchaseData.item_name,
|
||||
manufacturer: purchaseData.maker || null,
|
||||
supplier: purchaseData.vendor_name || null,
|
||||
purchase_price: purchaseData.unit_price,
|
||||
installation_date: purchaseData.purchase_date,
|
||||
status: 'active',
|
||||
notes: `구매 자동 등록 (purchase_id: ${purchaseData.purchase_id})`
|
||||
});
|
||||
|
||||
return { success: true, equipment_code: equipmentCode };
|
||||
} catch (err) {
|
||||
console.error('[purchase] 설비 자동 등록 실패:', err.message);
|
||||
|
||||
// fire-and-forget: admin 알림 전송
|
||||
const notifyHelper = require('../shared/utils/notifyHelper');
|
||||
notifyHelper.send({
|
||||
type: 'equipment',
|
||||
title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
|
||||
message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`,
|
||||
link_url: '/pages/admin/equipments.html',
|
||||
created_by: purchaseData.purchaser_id
|
||||
}).catch(() => {});
|
||||
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
},
|
||||
|
||||
// 업체 목록 (vendors 테이블 직접 조회)
|
||||
async getVendors() {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT vendor_id, vendor_name FROM vendors WHERE is_active = 1 ORDER BY vendor_name'
|
||||
);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 소모품 목록 (구매신청용)
|
||||
async getConsumableItems(activeOnly = true) {
|
||||
const db = await getDb();
|
||||
let sql = 'SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path FROM consumable_items';
|
||||
if (activeOnly) sql += ' WHERE is_active = 1';
|
||||
sql += ' ORDER BY category, item_name';
|
||||
const [rows] = await db.query(sql);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 미등록 품목 → 소모품 마스터 등록
|
||||
async registerToMaster(customItemName, customCategory, maker) {
|
||||
const db = await getDb();
|
||||
|
||||
// 중복 확인
|
||||
const [existing] = await db.query(
|
||||
`SELECT item_id FROM consumable_items WHERE item_name = ? AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||
[customItemName, maker || null, maker || null]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return existing[0].item_id;
|
||||
}
|
||||
|
||||
// 신규 등록 (photo_path = NULL)
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO consumable_items (item_name, maker, category, is_active) VALUES (?, ?, ?, 1)`,
|
||||
[customItemName, maker || null, customCategory || 'consumable']
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
// 가격 변동 이력
|
||||
async getPriceHistory(itemId) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT cph.*, su.name AS changed_by_name
|
||||
FROM consumable_price_history cph
|
||||
LEFT JOIN sso_users su ON cph.changed_by = su.user_id
|
||||
WHERE cph.item_id = ?
|
||||
ORDER BY cph.changed_at DESC`,
|
||||
[itemId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PurchaseModel;
|
||||
276
system1-factory/api/models/purchaseRequestModel.js
Normal file
276
system1-factory/api/models/purchaseRequestModel.js
Normal file
@@ -0,0 +1,276 @@
|
||||
// models/purchaseRequestModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const PurchaseRequestModel = {
|
||||
// 구매신청 목록 (소모품 정보 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,
|
||||
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 = [];
|
||||
|
||||
if (filters.status) { sql += ' AND pr.status = ?'; params.push(filters.status); }
|
||||
if (filters.requester_id) { sql += ' AND pr.requester_id = ?'; params.push(filters.requester_id); }
|
||||
if (filters.category) {
|
||||
sql += ' AND (ci.category = ? OR pr.custom_category = ?)';
|
||||
params.push(filters.category, filters.category);
|
||||
}
|
||||
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,
|
||||
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;
|
||||
},
|
||||
|
||||
// 구매신청 생성
|
||||
async create(data) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.item_id || null, data.custom_item_name || null, data.custom_category || null,
|
||||
data.quantity || 1, data.requester_id, data.request_date, data.notes || null, data.photo_path || null]
|
||||
);
|
||||
return this.getById(result.insertId);
|
||||
},
|
||||
|
||||
// 상태 변경 (보류)
|
||||
async hold(requestId, holdReason) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE purchase_requests SET status = 'hold', hold_reason = ? WHERE request_id = ?`,
|
||||
[holdReason || null, requestId]
|
||||
);
|
||||
return this.getById(requestId);
|
||||
},
|
||||
|
||||
// 상태 → purchased
|
||||
async markPurchased(requestId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE purchase_requests SET status = 'purchased' WHERE request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
},
|
||||
|
||||
// pending으로 되돌리기
|
||||
async revertToPending(requestId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE purchase_requests SET status = 'pending', hold_reason = NULL WHERE request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
return this.getById(requestId);
|
||||
},
|
||||
|
||||
// item_id 업데이트 (마스터 등록 후)
|
||||
async updateItemId(requestId, itemId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE purchase_requests SET item_id = ? WHERE request_id = ?`,
|
||||
[itemId, requestId]
|
||||
);
|
||||
},
|
||||
|
||||
// 삭제 (admin only, pending 상태만)
|
||||
async delete(requestId) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM purchase_requests WHERE request_id = ? AND status = 'pending'`,
|
||||
[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);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PurchaseRequestModel;
|
||||
362
system1-factory/api/models/scheduleModel.js
Normal file
362
system1-factory/api/models/scheduleModel.js
Normal file
@@ -0,0 +1,362 @@
|
||||
// models/scheduleModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const ScheduleModel = {
|
||||
// === 공정 단계 ===
|
||||
async getPhases() {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM schedule_phases WHERE is_active = 1 ORDER BY display_order'
|
||||
);
|
||||
return rows;
|
||||
},
|
||||
|
||||
async createPhase(data) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO schedule_phases (phase_name, display_order, color) VALUES (?, ?, ?)',
|
||||
[data.phase_name, data.display_order || 0, data.color || '#3B82F6']
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async updatePhase(phaseId, data) {
|
||||
const db = await getDb();
|
||||
const fields = [];
|
||||
const params = [];
|
||||
if (data.phase_name !== undefined) { fields.push('phase_name = ?'); params.push(data.phase_name); }
|
||||
if (data.display_order !== undefined) { fields.push('display_order = ?'); params.push(data.display_order); }
|
||||
if (data.color !== undefined) { fields.push('color = ?'); params.push(data.color); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); params.push(data.is_active); }
|
||||
if (fields.length === 0) return;
|
||||
params.push(phaseId);
|
||||
await db.query(`UPDATE schedule_phases SET ${fields.join(', ')} WHERE phase_id = ?`, params);
|
||||
},
|
||||
|
||||
// === 작업 템플릿 ===
|
||||
async getTemplates(phaseId) {
|
||||
const db = await getDb();
|
||||
let sql = 'SELECT t.*, p.phase_name FROM schedule_task_templates t JOIN schedule_phases p ON t.phase_id = p.phase_id WHERE t.is_active = 1';
|
||||
const params = [];
|
||||
if (phaseId) { sql += ' AND t.phase_id = ?'; params.push(phaseId); }
|
||||
sql += ' ORDER BY p.display_order, t.display_order';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// === 공정표 항목 ===
|
||||
async getEntries(filters = {}) {
|
||||
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, 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 = [];
|
||||
if (filters.project_id) { sql += ' AND e.project_id = ?'; params.push(filters.project_id); }
|
||||
if (filters.year) {
|
||||
sql += ' AND (YEAR(e.start_date) = ? OR YEAR(e.end_date) = ?)';
|
||||
params.push(filters.year, filters.year);
|
||||
}
|
||||
if (filters.month && filters.year) {
|
||||
sql += ' AND ((YEAR(e.start_date) = ? AND MONTH(e.start_date) = ?) OR (YEAR(e.end_date) = ? AND MONTH(e.end_date) = ?))';
|
||||
params.push(filters.year, filters.month, filters.year, filters.month);
|
||||
}
|
||||
sql += ' ORDER BY pr.job_no, p.display_order, e.display_order';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
async getGanttData(year) {
|
||||
const db = await getDb();
|
||||
// 해당 연도에 걸치는 모든 항목
|
||||
const [entries] = await db.query(`
|
||||
SELECT e.*, p.phase_name, p.color AS phase_color, p.display_order AS phase_order,
|
||||
pr.project_name, pr.job_no AS project_code
|
||||
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 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]);
|
||||
|
||||
// 의존관계
|
||||
const entryIds = entries.map(e => e.entry_id);
|
||||
let dependencies = [];
|
||||
if (entryIds.length > 0) {
|
||||
const [deps] = await db.query(
|
||||
`SELECT * FROM schedule_entry_dependencies WHERE entry_id IN (?)`,
|
||||
[entryIds]
|
||||
);
|
||||
dependencies = deps;
|
||||
}
|
||||
|
||||
// 마일스톤
|
||||
const [milestones] = await db.query(`
|
||||
SELECT m.*, pr.project_name, pr.job_no AS project_code
|
||||
FROM schedule_milestones m
|
||||
JOIN projects pr ON m.project_id = pr.project_id
|
||||
WHERE YEAR(m.milestone_date) = ?
|
||||
ORDER BY m.milestone_date
|
||||
`, [year]);
|
||||
|
||||
return { entries, dependencies, milestones };
|
||||
},
|
||||
|
||||
async createEntry(data) {
|
||||
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, 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.source || 'manual', data.work_type_id || null, data.risk_assessment_id || null]
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async createBatchEntries(projectId, phaseId, entries, createdBy) {
|
||||
const db = await getDb();
|
||||
const ids = [];
|
||||
for (const entry of entries) {
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO schedule_entries
|
||||
(project_id, phase_id, task_name, start_date, end_date, display_order, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[projectId, phaseId, entry.task_name, entry.start_date, entry.end_date,
|
||||
entry.display_order || 0, createdBy]
|
||||
);
|
||||
ids.push(result.insertId);
|
||||
}
|
||||
return ids;
|
||||
},
|
||||
|
||||
async updateEntry(entryId, data) {
|
||||
const db = await getDb();
|
||||
const fields = [];
|
||||
const params = [];
|
||||
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]); }
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
fields.push('updated_at = NOW()');
|
||||
params.push(entryId);
|
||||
await db.query(`UPDATE schedule_entries SET ${fields.join(', ')} WHERE entry_id = ?`, params);
|
||||
},
|
||||
|
||||
async updateProgress(entryId, progress) {
|
||||
const db = await getDb();
|
||||
let status = 'in_progress';
|
||||
if (progress === 0) status = 'planned';
|
||||
if (progress === 100) status = 'completed';
|
||||
await db.query(
|
||||
'UPDATE schedule_entries SET progress = ?, status = ?, updated_at = NOW() WHERE entry_id = ?',
|
||||
[progress, status, entryId]
|
||||
);
|
||||
},
|
||||
|
||||
async deleteEntry(entryId) {
|
||||
const db = await getDb();
|
||||
await db.query('DELETE FROM schedule_entries WHERE entry_id = ?', [entryId]);
|
||||
},
|
||||
|
||||
// === 의존관계 ===
|
||||
async addDependency(entryId, dependsOnEntryId) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
'INSERT IGNORE INTO schedule_entry_dependencies (entry_id, depends_on_entry_id) VALUES (?, ?)',
|
||||
[entryId, dependsOnEntryId]
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async removeDependency(entryId, dependsOnEntryId) {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
'DELETE FROM schedule_entry_dependencies WHERE entry_id = ? AND depends_on_entry_id = ?',
|
||||
[entryId, dependsOnEntryId]
|
||||
);
|
||||
},
|
||||
|
||||
async getDependencies(entryId) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT d.*, e.task_name AS depends_on_task_name
|
||||
FROM schedule_entry_dependencies d
|
||||
JOIN schedule_entries e ON d.depends_on_entry_id = e.entry_id
|
||||
WHERE d.entry_id = ?
|
||||
`, [entryId]);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// === 마일스톤 ===
|
||||
async getMilestones(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT m.*, pr.project_name, pr.job_no AS project_code, e.task_name AS entry_task_name,
|
||||
su.name AS created_by_name
|
||||
FROM schedule_milestones m
|
||||
JOIN projects pr ON m.project_id = pr.project_id
|
||||
LEFT JOIN schedule_entries e ON m.entry_id = e.entry_id
|
||||
LEFT JOIN sso_users su ON m.created_by = su.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
if (filters.project_id) { sql += ' AND m.project_id = ?'; params.push(filters.project_id); }
|
||||
sql += ' ORDER BY m.milestone_date';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
},
|
||||
|
||||
async createMilestone(data) {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO schedule_milestones
|
||||
(project_id, entry_id, milestone_name, milestone_date, milestone_type, status, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.project_id, data.entry_id || null, data.milestone_name, data.milestone_date,
|
||||
data.milestone_type || 'deadline', data.status || 'upcoming',
|
||||
data.notes || null, data.created_by || null]
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async updateMilestone(milestoneId, data) {
|
||||
const db = await getDb();
|
||||
const fields = [];
|
||||
const params = [];
|
||||
const allowed = ['milestone_name', 'milestone_date', 'milestone_type', 'status', 'entry_id', 'notes'];
|
||||
for (const key of allowed) {
|
||||
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
fields.push('updated_at = NOW()');
|
||||
params.push(milestoneId);
|
||||
await db.query(`UPDATE schedule_milestones SET ${fields.join(', ')} WHERE milestone_id = ?`, params);
|
||||
},
|
||||
|
||||
async deleteMilestone(milestoneId) {
|
||||
const db = await getDb();
|
||||
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) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT id, category, description, review_status, responsible_department,
|
||||
expected_completion_date, actual_completion_date, report_date
|
||||
FROM qc_issues WHERE project_id = ? AND review_status != 'disposed'
|
||||
ORDER BY report_date DESC`,
|
||||
[projectId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ScheduleModel;
|
||||
145
system1-factory/api/models/settlementModel.js
Normal file
145
system1-factory/api/models/settlementModel.js
Normal file
@@ -0,0 +1,145 @@
|
||||
// models/settlementModel.js
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const SettlementModel = {
|
||||
// 월간 분류별 요약
|
||||
async getCategorySummary(yearMonth) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT ci.category,
|
||||
COUNT(*) AS count,
|
||||
SUM(p.quantity * p.unit_price) AS total_amount
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
WHERE DATE_FORMAT(p.purchase_date, '%Y-%m') = ?
|
||||
GROUP BY ci.category
|
||||
`, [yearMonth]);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 월간 업체별 요약
|
||||
async getVendorSummary(yearMonth) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT v.vendor_id, v.vendor_name,
|
||||
COUNT(*) AS count,
|
||||
SUM(p.quantity * p.unit_price) AS total_amount,
|
||||
ms.settlement_id, ms.status AS settlement_status,
|
||||
ms.completed_at, ms.notes AS settlement_notes
|
||||
FROM purchases p
|
||||
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||
LEFT JOIN monthly_settlements ms ON ms.vendor_id = p.vendor_id AND ms.year_month = ?
|
||||
WHERE DATE_FORMAT(p.purchase_date, '%Y-%m') = ?
|
||||
GROUP BY COALESCE(v.vendor_id, 0), v.vendor_name, ms.settlement_id, ms.status, ms.completed_at, ms.notes
|
||||
ORDER BY total_amount DESC
|
||||
`, [yearMonth, yearMonth]);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 월간 상세 구매 목록
|
||||
async getMonthlyPurchases(yearMonth) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT p.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price, ci.photo_path,
|
||||
v.vendor_name, su.name AS purchaser_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||
LEFT JOIN sso_users su ON p.purchaser_id = su.user_id
|
||||
WHERE DATE_FORMAT(p.purchase_date, '%Y-%m') = ?
|
||||
ORDER BY p.purchase_date DESC
|
||||
`, [yearMonth]);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 정산 완료 처리
|
||||
async completeSettlement(yearMonth, vendorId, completedBy, notes) {
|
||||
const db = await getDb();
|
||||
|
||||
// 총액 계산
|
||||
const [[{ total }]] = await db.query(`
|
||||
SELECT COALESCE(SUM(p.quantity * p.unit_price), 0) AS total
|
||||
FROM purchases p
|
||||
WHERE p.vendor_id = ? AND DATE_FORMAT(p.purchase_date, '%Y-%m') = ?
|
||||
`, [vendorId, yearMonth]);
|
||||
|
||||
// UPSERT
|
||||
await db.query(`
|
||||
INSERT INTO monthly_settlements (year_month, vendor_id, total_amount, status, completed_at, completed_by, notes)
|
||||
VALUES (?, ?, ?, 'completed', NOW(), ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status = 'completed', total_amount = ?, completed_at = NOW(), completed_by = ?, notes = ?
|
||||
`, [yearMonth, vendorId, total, completedBy, notes || null, total, completedBy, notes || null]);
|
||||
|
||||
return { year_month: yearMonth, vendor_id: vendorId, total_amount: total, status: 'completed' };
|
||||
},
|
||||
|
||||
// 정산 취소
|
||||
async cancelSettlement(yearMonth, vendorId) {
|
||||
const db = await getDb();
|
||||
await db.query(`
|
||||
UPDATE monthly_settlements SET status = 'pending', completed_at = NULL, completed_by = NULL
|
||||
WHERE year_month = ? AND vendor_id = ?
|
||||
`, [yearMonth, vendorId]);
|
||||
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();
|
||||
const [rows] = await db.query(`
|
||||
SELECT p.purchase_id, p.purchase_date, p.unit_price, p.quantity,
|
||||
ci.item_id, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price,
|
||||
v.vendor_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||
WHERE DATE_FORMAT(p.purchase_date, '%Y-%m') = ?
|
||||
AND p.unit_price != ci.base_price
|
||||
ORDER BY ABS(p.unit_price - ci.base_price) DESC
|
||||
`, [yearMonth]);
|
||||
return rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = SettlementModel;
|
||||
@@ -185,8 +185,8 @@ const TbmModel = {
|
||||
if (existingReport.length === 0) {
|
||||
await conn.query(
|
||||
`INSERT INTO daily_work_reports
|
||||
(report_date, user_id, project_id, work_hours, work_status_id, created_by, tbm_assignment_id, created_at)
|
||||
VALUES (?, ?, 13, 8, 1, ?, ?, NOW())`,
|
||||
(report_date, user_id, project_id, work_type_id, work_hours, work_status_id, created_by, tbm_assignment_id, created_at)
|
||||
VALUES (?, ?, 13, 11, 8, 1, ?, ?, NOW())`,
|
||||
[reportDate, aw.user_id, createdBy, assignRows[0].assignment_id]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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); // 일일순회점검 시스템
|
||||
|
||||
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal file
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal 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;
|
||||
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
13
system1-factory/api/routes/dashboardRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -46,6 +46,9 @@ router.get('/repair-categories', equipmentController.getRepairCategories);
|
||||
// 새 수리 항목 추가
|
||||
router.post('/repair-categories', equipmentController.addRepairCategory);
|
||||
|
||||
// 수리 요청 목록 조회 (?status=pending)
|
||||
router.get('/repair-requests', equipmentController.getRepairRequests);
|
||||
|
||||
// ==================== 사진 관리 ====================
|
||||
|
||||
// 사진 삭제 (설비 ID 없이 photo_id만으로)
|
||||
|
||||
12
system1-factory/api/routes/itemAliasRoutes.js
Normal file
12
system1-factory/api/routes/itemAliasRoutes.js
Normal 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user