Compare commits
298 Commits
abd7564e6b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bf9254170b | ||
|
|
2fc4179052 | ||
|
|
0211889636 | ||
|
|
03119a0849 | ||
|
|
6a20056e05 | ||
|
|
5a062759c5 | ||
|
|
6e5c1554d0 | ||
|
|
e2def8ab14 | ||
|
|
b14448fc54 | ||
|
|
9fda89a374 | ||
|
|
8373fe9e75 | ||
|
|
0a0439c794 | ||
|
|
3b0ac615bf | ||
|
|
976e55d672 | ||
|
|
48994cff1f | ||
|
|
1006e8479e | ||
|
|
3d6cedf667 | ||
|
|
b5b0fa1728 | ||
|
|
fa4c899d95 | ||
|
|
9ac92f5775 | ||
|
|
5945176ad4 | ||
|
|
efc3c14db5 | ||
|
|
b800792152 | ||
|
|
a195dd1d50 | ||
|
|
281f5d35d1 | ||
|
|
5b1b89254c | ||
|
|
65db787f92 | ||
|
|
d827f22f4d | ||
|
|
85f674c9cb | ||
|
|
2d25d54589 | ||
|
|
d42380ff63 | ||
|
|
5aeda43605 | ||
|
|
df0a125faa | ||
|
|
81478dc6ac | ||
|
|
9647ae0d56 | ||
|
|
e6cc466a0e | ||
|
|
617c51ca53 | ||
|
|
e9d73ee30e | ||
|
|
59cbcebb94 | ||
|
|
11cffbd920 | ||
|
|
61c810bd47 | ||
|
|
ec755ed52f | ||
|
|
2f7e083db0 | ||
|
|
cad662473b | ||
|
|
b3012b8320 | ||
|
|
d385ce7ac1 | ||
|
|
7089548722 | ||
|
|
e18983ac06 | ||
|
|
7a12869d26 | ||
|
|
9b81a52283 |
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'hyung-ddfdf3-D341@' hyungi"
|
||||
|
||||
# 로그 확인 (NAS)
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"
|
||||
|
||||
# 컨테이너 상태 (NAS)
|
||||
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose ps"
|
||||
|
||||
# PostgreSQL 접속 (tkeg)
|
||||
ssh hyungi@100.71.132.52 "docker exec tk-tkeg-postgres psql -U tkbom_user -d tk_bom"
|
||||
```
|
||||
|
||||
## 8. 팁
|
||||
- **작은 단위로 커밋**: 서비스별로 나눠서 커밋하면 롤백이 쉬움
|
||||
- **CLAUDE.md 업데이트**: 새 서비스 추가 시 서비스 맵 테이블 갱신
|
||||
- **메모리 활용**: 반복되는 피드백은 Claude가 자동 저장 → 다음 세션에도 적용
|
||||
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
|
||||
@@ -84,6 +84,14 @@ PMA_USER=root
|
||||
PMA_PASSWORD=change_this_root_password_min_12_chars
|
||||
UPLOAD_LIMIT=50M
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# AI Service
|
||||
# -------------------------------------------------------------------
|
||||
OLLAMA_BASE_URL=http://your-ollama-server:11434
|
||||
OLLAMA_TEXT_MODEL=qwen2.5:14b-instruct-q4_K_M
|
||||
OLLAMA_EMBED_MODEL=bge-m3
|
||||
OLLAMA_TIMEOUT=120
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Cloudflare Tunnel
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ venv/
|
||||
coverage/
|
||||
db_archive/
|
||||
*.log
|
||||
DEPLOY_LOG
|
||||
|
||||
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)만 읽고 작업할 것. 다른 섹션 파일 수정 금지.
|
||||
@@ -1,6 +1,6 @@
|
||||
# TK Factory Services - NAS 배포 가이드
|
||||
|
||||
> 최종 업데이트: 2026-02-09
|
||||
> 최종 업데이트: 2026-03-12
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
@@ -128,21 +128,38 @@ cp -r /volume1/docker/tkqc/tkqc-package/uploads \
|
||||
/volume1/docker/backups/$(date +%Y%m%d)/tkqc-uploads
|
||||
```
|
||||
|
||||
### Step 2: 프로젝트 NAS 전송
|
||||
### Step 2: 프로젝트 NAS 전송 (git 기반)
|
||||
|
||||
NAS에 git이 설치되어 있으므로, Gitea에서 직접 clone/pull 합니다.
|
||||
|
||||
**최초 설정 (1회):**
|
||||
```bash
|
||||
# 로컬 Mac에서 실행
|
||||
# node_modules 제외하여 전송
|
||||
rsync -avz --exclude='node_modules' --exclude='.env' --exclude='__pycache__' \
|
||||
-e ssh /Users/hyungiahn/Documents/code/tk-factory-services/ \
|
||||
hyungi@192.168.0.3:/volume1/docker/tk-factory-services/
|
||||
# NAS SSH 접속
|
||||
ssh hyungi@192.168.0.3
|
||||
|
||||
# .env 파일 별도 전송
|
||||
scp -O /Users/hyungiahn/Documents/code/tk-factory-services/.env \
|
||||
hyungi@192.168.0.3:/volume1/docker/tk-factory-services/.env
|
||||
# git credential 저장 설정
|
||||
git config --global credential.helper store
|
||||
|
||||
# 기존 배포 디렉토리 백업 후 clone
|
||||
cd /volume1/docker_1/
|
||||
mv tk-factory-services tk-factory-services.bak
|
||||
git clone https://git.hyungi.net/hyungi/tk-factory-services.git
|
||||
|
||||
# 기존 .env 복원
|
||||
cp tk-factory-services.bak/.env tk-factory-services/.env
|
||||
|
||||
# 확인 후 백업 삭제
|
||||
cd tk-factory-services && git log -1
|
||||
rm -rf ../tk-factory-services.bak
|
||||
```
|
||||
|
||||
> **참고**: Synology에서 `scp`는 `-O` 옵션 필수 (레거시 프로토콜)
|
||||
**이후 배포 (맥북에서 원커맨드):**
|
||||
```bash
|
||||
# 맥북에서 실행 - 자동으로 push 확인, NAS 비교, 빌드, health check 수행
|
||||
./scripts/deploy-remote.sh
|
||||
```
|
||||
|
||||
> **참고**: 설정 파일 `~/.tk-deploy-config` 필요 (아래 "원격 배포 스크립트" 섹션 참고)
|
||||
|
||||
### Step 3: 기존 서비스 중지
|
||||
|
||||
@@ -220,13 +237,60 @@ curl -s http://localhost:30050/api/health # SSO Auth
|
||||
|
||||
---
|
||||
|
||||
## 원격 배포 스크립트
|
||||
|
||||
맥북에서 원커맨드로 배포/확인/롤백할 수 있는 스크립트입니다.
|
||||
|
||||
### 설정 파일
|
||||
|
||||
`~/.tk-deploy-config` 생성 (git 추적 안 함):
|
||||
```bash
|
||||
NAS_HOST=100.71.132.52
|
||||
NAS_USER=hyungi
|
||||
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
|
||||
NAS_SUDO_PASS=<sudo 비밀번호>
|
||||
```
|
||||
|
||||
### 사용법
|
||||
|
||||
```bash
|
||||
# 배포 (pre-flight 체크 → NAS 비교 → 빌드 → health check → 로그 기록)
|
||||
./scripts/deploy-remote.sh
|
||||
|
||||
# 배포 상태 확인 (NAS 버전, origin 대비 차이, 컨테이너 상태)
|
||||
./scripts/check-version.sh
|
||||
|
||||
# 특정 커밋으로 롤백
|
||||
./scripts/rollback-remote.sh <commit-hash>
|
||||
```
|
||||
|
||||
### 배포 흐름
|
||||
|
||||
1. 로컬에서 코드 수정 → `git commit` → `git push`
|
||||
2. `./scripts/deploy-remote.sh` 실행
|
||||
3. 스크립트가 자동으로: 로컬 clean 확인 → origin push 확인 → NAS 버전 비교 → 배포될 커밋 표시 → 사용자 확인 → git pull → docker build → nginx 재시작 → health check
|
||||
|
||||
---
|
||||
|
||||
## 롤백 방법
|
||||
|
||||
문제 발생 시 기존 서비스로 복원:
|
||||
### git 기반 롤백 (권장)
|
||||
|
||||
```bash
|
||||
# 맥북에서 실행 - 특정 커밋으로 롤백
|
||||
./scripts/rollback-remote.sh <commit-hash>
|
||||
|
||||
# 최근 커밋 목록 확인
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
### 레거시 서비스 복원 (통합 이전으로)
|
||||
|
||||
문제 발생 시 기존 개별 서비스로 복원:
|
||||
|
||||
```bash
|
||||
# 통합 서비스 중지
|
||||
cd /volume1/docker/tk-factory-services
|
||||
cd /volume1/docker_1/tk-factory-services
|
||||
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
|
||||
|
||||
# TK-FB 복원
|
||||
|
||||
11
ai-service/Dockerfile
Normal file
11
ai-service/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
COPY --chown=appuser:appuser . .
|
||||
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
||||
EXPOSE 8000
|
||||
USER appuser
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
36
ai-service/config.py
Normal file
36
ai-service/config.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# GPU서버 Ollama (텍스트 생성)
|
||||
OLLAMA_BASE_URL: str = "http://192.168.1.186:11434"
|
||||
OLLAMA_TEXT_MODEL: str = "qwen3.5:9b-q8_0"
|
||||
OLLAMA_TIMEOUT: int = 120
|
||||
|
||||
# 맥미니 Ollama (임베딩) — OrbStack: host.internal / Docker Desktop: host.docker.internal
|
||||
OLLAMA_EMBED_URL: str = "http://host.internal:11434"
|
||||
OLLAMA_EMBED_MODEL: str = "bge-m3"
|
||||
|
||||
# 맥미니 Qdrant (기존 인스턴스, 회사 전용 컬렉션)
|
||||
QDRANT_URL: str = "http://host.internal:6333"
|
||||
QDRANT_COLLECTION: str = "tk_qc_issues"
|
||||
|
||||
# ds923 MariaDB (Tailscale)
|
||||
DB_HOST: str = "100.71.132.52"
|
||||
DB_PORT: int = 30306
|
||||
DB_USER: str = "hyungi_user"
|
||||
DB_PASSWORD: str = ""
|
||||
DB_NAME: str = "hyungi"
|
||||
|
||||
SECRET_KEY: str = ""
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# ds923 System1 API (Tailscale)
|
||||
SYSTEM1_API_URL: str = "http://100.71.132.52:30005"
|
||||
METADATA_DB_PATH: str = "/app/data/metadata.db"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
39
ai-service/db/metadata_store.py
Normal file
39
ai-service/db/metadata_store.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sqlite3
|
||||
from config import settings
|
||||
|
||||
|
||||
class MetadataStore:
|
||||
def __init__(self):
|
||||
self.db_path = settings.METADATA_DB_PATH
|
||||
|
||||
def initialize(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sync_state ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
")"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_last_synced_id(self) -> int:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cur = conn.execute(
|
||||
"SELECT value FROM sync_state WHERE key = 'last_synced_id'"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def set_last_synced_id(self, issue_id: int):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sync_state (key, value) VALUES ('last_synced_id', ?)",
|
||||
(str(issue_id),),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
metadata_store = MetadataStore()
|
||||
107
ai-service/db/vector_store.py
Normal file
107
ai-service/db/vector_store.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import logging
|
||||
import uuid
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VectorStore:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.collection = settings.QDRANT_COLLECTION # "tk_qc_issues"
|
||||
|
||||
def initialize(self):
|
||||
self.client = QdrantClient(url=settings.QDRANT_URL)
|
||||
self._ensure_collection()
|
||||
|
||||
def _ensure_collection(self):
|
||||
collections = [c.name for c in self.client.get_collections().collections]
|
||||
if self.collection not in collections:
|
||||
# bge-m3 기본 출력 = 1024 dims
|
||||
self.client.create_collection(
|
||||
collection_name=self.collection,
|
||||
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_uuid(doc_id) -> str:
|
||||
"""문자열/정수 ID → UUID5 변환 (Qdrant 호환)"""
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, str(doc_id)))
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
doc_id: str,
|
||||
document: str,
|
||||
embedding: list[float],
|
||||
metadata: dict = None,
|
||||
):
|
||||
point_id = self._to_uuid(doc_id)
|
||||
payload = {"document": document, "original_id": str(doc_id)}
|
||||
if metadata:
|
||||
payload.update(metadata)
|
||||
self.client.upsert(
|
||||
collection_name=self.collection,
|
||||
points=[PointStruct(id=point_id, vector=embedding, payload=payload)],
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
embedding: list[float],
|
||||
n_results: int = 5,
|
||||
where: dict = None,
|
||||
) -> list[dict]:
|
||||
query_filter = self._build_filter(where) if where else None
|
||||
try:
|
||||
response = self.client.query_points(
|
||||
collection_name=self.collection,
|
||||
query=embedding,
|
||||
limit=n_results,
|
||||
query_filter=query_filter,
|
||||
with_payload=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Qdrant search failed: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
items = []
|
||||
for hit in response.points:
|
||||
payload = hit.payload or {}
|
||||
item = {
|
||||
"id": payload.get("original_id", str(hit.id)),
|
||||
"document": payload.get("document", ""),
|
||||
"distance": round(1 - hit.score, 4), # cosine score → distance
|
||||
"metadata": {k: v for k, v in payload.items() if k not in ("document", "original_id")},
|
||||
"similarity": round(hit.score, 4),
|
||||
}
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _build_filter(where: dict) -> Filter:
|
||||
"""ChromaDB 스타일 where 조건 → Qdrant Filter 변환"""
|
||||
conditions = []
|
||||
for key, value in where.items():
|
||||
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
|
||||
return Filter(must=conditions)
|
||||
|
||||
def delete(self, doc_id: str):
|
||||
point_id = self._to_uuid(doc_id)
|
||||
self.client.delete(
|
||||
collection_name=self.collection,
|
||||
points_selector=[point_id],
|
||||
)
|
||||
|
||||
def count(self) -> int:
|
||||
info = self.client.get_collection(collection_name=self.collection)
|
||||
return info.points_count
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"total_documents": self.count(),
|
||||
"collection_name": self.collection,
|
||||
}
|
||||
|
||||
|
||||
vector_store = VectorStore()
|
||||
94
ai-service/main.py
Normal file
94
ai-service/main.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from routers import health, embeddings, classification, daily_report, rag, chatbot
|
||||
from db.vector_store import vector_store
|
||||
from db.metadata_store import metadata_store
|
||||
from services.ollama_client import ollama_client
|
||||
from middlewares.auth import verify_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUBLIC_PATHS = {"/", "/api/ai/health", "/api/ai/models"}
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method == "OPTIONS" or request.url.path in PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
try:
|
||||
request.state.user = await verify_token(request)
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=401, content={"detail": str(e.detail) if hasattr(e, "detail") else "인증 실패"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
async def _periodic_sync():
|
||||
"""30분마다 전체 이슈 재동기화 (안전망)"""
|
||||
await asyncio.sleep(60) # 시작 후 1분 대기 (초기화 완료 보장)
|
||||
while True:
|
||||
try:
|
||||
from services.embedding_service import sync_all_issues
|
||||
result = await sync_all_issues()
|
||||
logger.info(f"Periodic sync completed: {result}")
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic sync task cancelled")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Periodic sync failed: {e}")
|
||||
await asyncio.sleep(1800) # 30분
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
vector_store.initialize()
|
||||
metadata_store.initialize()
|
||||
sync_task = asyncio.create_task(_periodic_sync())
|
||||
yield
|
||||
sync_task.cancel()
|
||||
await ollama_client.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="TK AI Service",
|
||||
description="AI 서비스 (유사 검색, 분류, 보고서)",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
ALLOWED_ORIGINS = [
|
||||
"https://tkfb.technicalkorea.net",
|
||||
"https://tkreport.technicalkorea.net",
|
||||
"https://tkqc.technicalkorea.net",
|
||||
"https://tkuser.technicalkorea.net",
|
||||
]
|
||||
if os.getenv("ENV", "production") == "development":
|
||||
ALLOWED_ORIGINS += ["http://localhost:30080", "http://localhost:30180", "http://localhost:30280"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
app.include_router(health.router, prefix="/api/ai")
|
||||
app.include_router(embeddings.router, prefix="/api/ai")
|
||||
app.include_router(classification.router, prefix="/api/ai")
|
||||
app.include_router(daily_report.router, prefix="/api/ai")
|
||||
app.include_router(rag.router, prefix="/api/ai")
|
||||
app.include_router(chatbot.router, prefix="/api/ai")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "TK AI Service", "version": "1.0.0"}
|
||||
0
ai-service/middlewares/__init__.py
Normal file
0
ai-service/middlewares/__init__.py
Normal file
24
ai-service/middlewares/auth.py
Normal file
24
ai-service/middlewares/auth.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import Request, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
from config import settings
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def verify_token(request: Request) -> dict:
|
||||
"""JWT 토큰 검증. SSO 서비스와 동일한 시크릿 사용."""
|
||||
auth: HTTPAuthorizationCredentials = await security(request)
|
||||
if not auth:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authorization 헤더가 필요합니다")
|
||||
|
||||
if not settings.SECRET_KEY:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서버 인증 설정 오류")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(auth.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="토큰이 만료되었습니다")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="유효하지 않은 토큰입니다")
|
||||
18
ai-service/prompts/classify_issue.txt
Normal file
18
ai-service/prompts/classify_issue.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고 내용을 분석하여 판별하세요.
|
||||
|
||||
부적합 내용:
|
||||
{description}
|
||||
|
||||
상세 내용:
|
||||
{detail_notes}
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
|
||||
"category_confidence": 0.0~1.0,
|
||||
"responsible_department": "production|quality|purchasing|design|sales",
|
||||
"department_confidence": 0.0~1.0,
|
||||
"severity": "low|medium|high|critical",
|
||||
"summary": "한줄 요약 (30자 이내)",
|
||||
"reasoning": "판단 근거 (2-3문장)"
|
||||
}}
|
||||
22
ai-service/prompts/daily_report.txt
Normal file
22
ai-service/prompts/daily_report.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
당신은 공장 관리 보고서 작성자입니다. 아래 데이터를 바탕으로 일일 브리핑을 작성하세요.
|
||||
|
||||
날짜: {date}
|
||||
|
||||
[근태 현황]
|
||||
{attendance_data}
|
||||
|
||||
[작업 현황]
|
||||
{work_report_data}
|
||||
|
||||
[부적합 현황]
|
||||
{qc_issue_data}
|
||||
|
||||
[순회점검 현황]
|
||||
{patrol_data}
|
||||
|
||||
다음 형식으로 작성하세요:
|
||||
|
||||
1. 오늘의 요약 (2-3문장)
|
||||
2. 주요 이슈 및 관심사항
|
||||
3. 부적합 현황 (신규/진행/지연)
|
||||
4. 내일 주의사항
|
||||
23
ai-service/prompts/rag_classify.txt
Normal file
23
ai-service/prompts/rag_classify.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고를 분류하세요.
|
||||
|
||||
[신고 내용]
|
||||
{description}
|
||||
|
||||
[상세 내용]
|
||||
{detail_notes}
|
||||
|
||||
[참고: 과거 유사 사례]
|
||||
{retrieved_cases}
|
||||
|
||||
위 과거 사례의 분류 패턴을 참고하여, 현재 부적합을 판별하세요.
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
|
||||
"category_confidence": 0.0~1.0,
|
||||
"responsible_department": "production|quality|purchasing|design|sales",
|
||||
"department_confidence": 0.0~1.0,
|
||||
"severity": "low|medium|high|critical",
|
||||
"summary": "한줄 요약 (30자 이내)",
|
||||
"reasoning": "판단 근거 — 과거 사례 참고 내용 포함 (2-3문장)"
|
||||
}}
|
||||
16
ai-service/prompts/rag_pattern.txt
Normal file
16
ai-service/prompts/rag_pattern.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
당신은 공장 품질관리(QC) 데이터 분석가입니다. 아래 부적합에 대해 패턴을 분석하세요.
|
||||
|
||||
[분석 대상]
|
||||
{description}
|
||||
|
||||
[유사 부적합 {total_similar}건]
|
||||
{retrieved_cases}
|
||||
|
||||
다음을 분석하세요:
|
||||
|
||||
1. **반복 여부**: 이 문제가 과거에도 발생했는지, 반복 빈도는 어느 정도인지
|
||||
2. **공통 패턴**: 유사 사례들의 공통 원인, 공통 부서, 공통 시기 등
|
||||
3. **근본 원인 추정**: 반복되는 원인이 있다면 근본 원인은 무엇인지
|
||||
4. **개선 제안**: 재발 방지를 위한 구조적 개선 방안
|
||||
|
||||
데이터 기반으로 객관적으로 분석하세요.
|
||||
16
ai-service/prompts/rag_qa.txt
Normal file
16
ai-service/prompts/rag_qa.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 과거 부적합 데이터를 기반으로 질문에 답변하세요.
|
||||
|
||||
[질문]
|
||||
{question}
|
||||
|
||||
{stats_summary}
|
||||
|
||||
[관련 부적합 사례 (유사도 검색 결과)]
|
||||
{retrieved_cases}
|
||||
|
||||
답변 규칙:
|
||||
- 통계 요약이 있으면 통계 데이터를 우선 참고하고, 없으면 관련 사례만 참고하세요
|
||||
- 핵심을 먼저 말하고 근거 데이터를 인용하세요
|
||||
- 500자 이내로 간결하게 답변하세요
|
||||
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
|
||||
- 데이터에 없는 내용은 추측하지 마세요
|
||||
18
ai-service/prompts/rag_suggest_solution.txt
Normal file
18
ai-service/prompts/rag_suggest_solution.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈에 대한 해결방안을 제안하세요.
|
||||
|
||||
[현재 부적합]
|
||||
분류: {category}
|
||||
내용: {description}
|
||||
상세: {detail_notes}
|
||||
|
||||
[과거 유사 사례]
|
||||
{retrieved_cases}
|
||||
|
||||
위 과거 사례들을 참고하여 다음을 제안하세요:
|
||||
|
||||
1. **권장 해결방안**: 과거 유사 사례에서 효과적이었던 해결 방법을 기반으로 구체적인 조치를 제안
|
||||
2. **예상 원인**: 유사 사례에서 확인된 원인 패턴을 바탕으로 가능한 원인 분석
|
||||
3. **담당 부서**: 어느 부서에서 처리해야 하는지
|
||||
4. **주의사항**: 과거 사례에서 배운 교훈이나 주의할 점
|
||||
|
||||
간결하고 실용적으로 작성하세요. 과거 사례가 없는 부분은 일반적인 QC 지식으로 보완하세요.
|
||||
17
ai-service/prompts/summarize_issue.txt
Normal file
17
ai-service/prompts/summarize_issue.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈를 간결하게 요약하세요.
|
||||
|
||||
부적합 내용:
|
||||
{description}
|
||||
|
||||
상세 내용:
|
||||
{detail_notes}
|
||||
|
||||
해결 방법:
|
||||
{solution}
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"summary": "핵심 요약 (50자 이내)",
|
||||
"key_points": ["요점1", "요점2", "요점3"],
|
||||
"suggested_action": "권장 조치사항 (선택)"
|
||||
}}
|
||||
10
ai-service/requirements.txt
Normal file
10
ai-service/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
httpx==0.27.0
|
||||
qdrant-client>=1.7.0
|
||||
numpy==1.26.2
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
pymysql==1.1.0
|
||||
sqlalchemy==2.0.23
|
||||
0
ai-service/routers/__init__.py
Normal file
0
ai-service/routers/__init__.py
Normal file
37
ai-service/routers/chatbot.py
Normal file
37
ai-service/routers/chatbot.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.chatbot_service import analyze_user_input, summarize_report
|
||||
|
||||
router = APIRouter(tags=["chatbot"])
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
user_text: str
|
||||
categories: dict = {}
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
description: str = ""
|
||||
type: str = ""
|
||||
category: str = ""
|
||||
item: str = ""
|
||||
location: str = ""
|
||||
project: str = ""
|
||||
|
||||
|
||||
@router.post("/chatbot/analyze")
|
||||
async def chatbot_analyze(req: AnalyzeRequest):
|
||||
try:
|
||||
result = await analyze_user_input(req.user_text, req.categories)
|
||||
return {"success": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 분석 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/chatbot/summarize")
|
||||
async def chatbot_summarize(req: SummarizeRequest):
|
||||
try:
|
||||
result = await summarize_report(req.model_dump())
|
||||
return {"success": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 요약 중 오류가 발생했습니다")
|
||||
47
ai-service/routers/classification.py
Normal file
47
ai-service/routers/classification.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.classification_service import (
|
||||
classify_issue,
|
||||
summarize_issue,
|
||||
classify_and_summarize,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["classification"])
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
solution: str = ""
|
||||
|
||||
|
||||
@router.post("/classify")
|
||||
async def classify(req: ClassifyRequest):
|
||||
try:
|
||||
result = await classify_issue(req.description, req.detail_notes)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/summarize")
|
||||
async def summarize(req: SummarizeRequest):
|
||||
try:
|
||||
result = await summarize_issue(req.description, req.detail_notes, req.solution)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/classify-and-summarize")
|
||||
async def classify_and_summarize_endpoint(req: ClassifyRequest):
|
||||
try:
|
||||
result = await classify_and_summarize(req.description, req.detail_notes)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
33
ai-service/routers/daily_report.py
Normal file
33
ai-service/routers/daily_report.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from services.report_service import generate_daily_report
|
||||
from datetime import date
|
||||
|
||||
router = APIRouter(tags=["daily_report"])
|
||||
|
||||
|
||||
class DailyReportRequest(BaseModel):
|
||||
date: str | None = None
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/report/daily")
|
||||
async def daily_report(req: DailyReportRequest, request: Request):
|
||||
report_date = req.date or date.today().isoformat()
|
||||
token = request.headers.get("authorization", "").replace("Bearer ", "")
|
||||
try:
|
||||
result = await generate_daily_report(report_date, req.project_id, token)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/report/preview")
|
||||
async def report_preview(req: DailyReportRequest, request: Request):
|
||||
report_date = req.date or date.today().isoformat()
|
||||
token = request.headers.get("authorization", "").replace("Bearer ", "")
|
||||
try:
|
||||
result = await generate_daily_report(report_date, req.project_id, token)
|
||||
return {"available": True, "preview": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
77
ai-service/routers/embeddings.py
Normal file
77
ai-service/routers/embeddings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from services.embedding_service import (
|
||||
sync_all_issues,
|
||||
sync_single_issue,
|
||||
sync_incremental,
|
||||
search_similar_by_id,
|
||||
search_similar_by_text,
|
||||
)
|
||||
from db.vector_store import vector_store
|
||||
|
||||
router = APIRouter(tags=["embeddings"])
|
||||
|
||||
|
||||
class SyncSingleRequest(BaseModel):
|
||||
issue_id: int
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
n_results: int = 5
|
||||
project_id: int | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
@router.post("/embeddings/sync")
|
||||
async def sync_embeddings(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(sync_all_issues)
|
||||
return {"status": "sync_started", "message": "전체 임베딩 동기화가 시작되었습니다"}
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-full")
|
||||
async def sync_embeddings_full():
|
||||
result = await sync_all_issues()
|
||||
return {"status": "completed", **result}
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-single")
|
||||
async def sync_single(req: SyncSingleRequest):
|
||||
result = await sync_single_issue(req.issue_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-incremental")
|
||||
async def sync_incr():
|
||||
result = await sync_incremental()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/similar/{issue_id}")
|
||||
async def get_similar(issue_id: int, n_results: int = Query(default=5, le=20)):
|
||||
try:
|
||||
results = await search_similar_by_id(issue_id, n_results)
|
||||
return {"available": True, "results": results, "query_issue_id": issue_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/similar/search")
|
||||
async def search_similar(req: SearchRequest):
|
||||
filters = {}
|
||||
if req.project_id is not None:
|
||||
filters["project_id"] = str(req.project_id)
|
||||
if req.category:
|
||||
filters["category"] = req.category
|
||||
try:
|
||||
results = await search_similar_by_text(
|
||||
req.query, req.n_results, filters or None
|
||||
)
|
||||
return {"available": True, "results": results}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.get("/embeddings/stats")
|
||||
async def embedding_stats():
|
||||
return vector_store.stats()
|
||||
31
ai-service/routers/health.py
Normal file
31
ai-service/routers/health.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter
|
||||
from services.ollama_client import ollama_client
|
||||
from db.vector_store import vector_store
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
backends = await ollama_client.check_health()
|
||||
stats = vector_store.stats()
|
||||
|
||||
# 메인 텍스트 모델명 결정
|
||||
model_name = None
|
||||
text_models = backends.get("ollama_text", {}).get("models", [])
|
||||
if text_models:
|
||||
model_name = text_models[0]
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "tk-ai-service",
|
||||
"model": model_name,
|
||||
"ollama_text": backends.get("ollama_text", {}),
|
||||
"ollama_embed": backends.get("ollama_embed", {}),
|
||||
"embeddings": stats,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models():
|
||||
return await ollama_client.check_health()
|
||||
57
ai-service/routers/rag.py
Normal file
57
ai-service/routers/rag.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.rag_service import (
|
||||
rag_suggest_solution,
|
||||
rag_ask,
|
||||
rag_analyze_pattern,
|
||||
rag_classify_with_context,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["rag"])
|
||||
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
question: str
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
class PatternRequest(BaseModel):
|
||||
description: str
|
||||
n_results: int = 10
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
|
||||
|
||||
@router.post("/rag/suggest-solution/{issue_id}")
|
||||
async def suggest_solution(issue_id: int):
|
||||
try:
|
||||
return await rag_suggest_solution(issue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/rag/ask")
|
||||
async def ask_question(req: AskRequest):
|
||||
try:
|
||||
return await rag_ask(req.question, req.project_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/rag/pattern")
|
||||
async def analyze_pattern(req: PatternRequest):
|
||||
try:
|
||||
return await rag_analyze_pattern(req.description, req.n_results)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/rag/classify")
|
||||
async def classify_with_rag(req: ClassifyRequest):
|
||||
try:
|
||||
return await rag_classify_with_context(req.description, req.detail_notes)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 서비스 처리 중 오류가 발생했습니다")
|
||||
0
ai-service/services/__init__.py
Normal file
0
ai-service/services/__init__.py
Normal file
121
ai-service/services/chatbot_service.py
Normal file
121
ai-service/services/chatbot_service.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
from services.ollama_client import ollama_client
|
||||
|
||||
|
||||
def sanitize_user_input(text: str, max_length: int = 500) -> str:
|
||||
"""사용자 입력 길이 제한 및 정리"""
|
||||
if not text:
|
||||
return ""
|
||||
return str(text)[:max_length].strip()
|
||||
|
||||
|
||||
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||
|
||||
신고 유형:
|
||||
- nonconformity (부적합): 제품/작업 품질 관련 문제
|
||||
- facility (시설설비): 시설, 설비, 장비 관련 문제
|
||||
- safety (안전): 안전 위험, 위험 요소 관련 문제
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요:
|
||||
{
|
||||
"organized_description": "정리된 설명 (1-2문장)",
|
||||
"suggested_type": "nonconformity 또는 facility 또는 safety",
|
||||
"suggested_category_id": 카테고리ID(숫자) 또는 null,
|
||||
"confidence": 0.0~1.0 사이의 확신도
|
||||
}"""
|
||||
|
||||
SUMMARIZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 내용을 요약하는 AI 도우미입니다.
|
||||
주어진 신고 정보를 보기 좋게 정리하여 한국어로 요약해주세요.
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요:
|
||||
{
|
||||
"summary": "요약 텍스트"
|
||||
}"""
|
||||
|
||||
|
||||
async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
||||
"""사용자 초기 입력을 분석하여 유형 제안 + 설명 정리"""
|
||||
category_context = ""
|
||||
for type_key, cats in categories.items():
|
||||
type_label = {"nonconformity": "부적합", "facility": "시설설비", "safety": "안전"}.get(type_key, type_key)
|
||||
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||
|
||||
safe_text = sanitize_user_input(user_text)
|
||||
prompt = f"""카테고리 목록:
|
||||
{category_context}
|
||||
|
||||
사용자 입력:
|
||||
<user_input>{safe_text}</user_input>
|
||||
|
||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||
|
||||
raw = await ollama_client.generate_text(prompt, system=ANALYZE_SYSTEM_PROMPT)
|
||||
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
# Validate required fields
|
||||
if "organized_description" not in result:
|
||||
result["organized_description"] = user_text
|
||||
if "suggested_type" not in result:
|
||||
result["suggested_type"] = "nonconformity"
|
||||
if "confidence" not in result:
|
||||
result["confidence"] = 0.5
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"organized_description": user_text,
|
||||
"suggested_type": "nonconformity",
|
||||
"suggested_category_id": None,
|
||||
"confidence": 0.3,
|
||||
}
|
||||
|
||||
|
||||
async def summarize_report(data: dict) -> dict:
|
||||
"""최종 신고 내용을 요약"""
|
||||
prompt = f"""신고 정보:
|
||||
<user_input>
|
||||
- 설명: {sanitize_user_input(data.get('description', ''))}
|
||||
- 유형: {sanitize_user_input(data.get('type', ''))}
|
||||
- 카테고리: {sanitize_user_input(data.get('category', ''))}
|
||||
- 항목: {sanitize_user_input(data.get('item', ''))}
|
||||
- 위치: {sanitize_user_input(data.get('location', ''))}
|
||||
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
|
||||
</user_input>
|
||||
|
||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||
|
||||
raw = await ollama_client.generate_text(prompt, system=SUMMARIZE_SYSTEM_PROMPT)
|
||||
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
if "summary" in result:
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback: construct summary manually
|
||||
parts = []
|
||||
if data.get("type"):
|
||||
parts.append(f"[{data['type']}]")
|
||||
if data.get("category"):
|
||||
parts.append(data["category"])
|
||||
if data.get("item"):
|
||||
parts.append(f"- {data['item']}")
|
||||
if data.get("location"):
|
||||
parts.append(f"\n위치: {data['location']}")
|
||||
if data.get("project"):
|
||||
parts.append(f"\n프로젝트: {data['project']}")
|
||||
if data.get("description"):
|
||||
parts.append(f"\n내용: {data['description']}")
|
||||
|
||||
return {"summary": " ".join(parts) if parts else "신고 내용 요약"}
|
||||
56
ai-service/services/classification_service.py
Normal file
56
ai-service/services/classification_service.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from services.ollama_client import ollama_client
|
||||
from services.utils import load_prompt, parse_json_response
|
||||
from config import settings
|
||||
|
||||
|
||||
CLASSIFY_PROMPT_PATH = "prompts/classify_issue.txt"
|
||||
SUMMARIZE_PROMPT_PATH = "prompts/summarize_issue.txt"
|
||||
|
||||
|
||||
async def classify_issue(description: str, detail_notes: str = "") -> dict:
|
||||
template = load_prompt(CLASSIFY_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
description=description or "",
|
||||
detail_notes=detail_notes or "",
|
||||
)
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"raw_response": raw, "parse_error": True}
|
||||
|
||||
|
||||
async def summarize_issue(
|
||||
description: str, detail_notes: str = "", solution: str = ""
|
||||
) -> dict:
|
||||
template = load_prompt(SUMMARIZE_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
description=description or "",
|
||||
detail_notes=detail_notes or "",
|
||||
solution=solution or "",
|
||||
)
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"summary": raw.strip()}
|
||||
|
||||
|
||||
async def classify_and_summarize(
|
||||
description: str, detail_notes: str = ""
|
||||
) -> dict:
|
||||
classification = await classify_issue(description, detail_notes)
|
||||
summary_result = await summarize_issue(description, detail_notes)
|
||||
return {
|
||||
"classification": classification,
|
||||
"summary": summary_result.get("summary", ""),
|
||||
}
|
||||
129
ai-service/services/db_client.py
Normal file
129
ai-service/services/db_client.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from config import settings
|
||||
|
||||
|
||||
def get_engine():
|
||||
password = quote_plus(settings.DB_PASSWORD)
|
||||
url = (
|
||||
f"mysql+pymysql://{settings.DB_USER}:{password}"
|
||||
f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
|
||||
)
|
||||
return create_engine(url, pool_pre_ping=True, pool_size=5)
|
||||
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
|
||||
def get_all_issues() -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues ORDER BY id"
|
||||
)
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_issue_by_id(issue_id: int) -> dict | None:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues WHERE id = :id"
|
||||
),
|
||||
{"id": issue_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def get_issues_since(last_id: int) -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues WHERE id > :last_id ORDER BY id"
|
||||
),
|
||||
{"last_id": last_id},
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_daily_qc_stats(date_str: str) -> dict:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT "
|
||||
" COUNT(*) as total, "
|
||||
" SUM(CASE WHEN DATE(report_date) = :d THEN 1 ELSE 0 END) as new_today, "
|
||||
" SUM(CASE WHEN review_status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, "
|
||||
" SUM(CASE WHEN review_status = 'completed' THEN 1 ELSE 0 END) as completed, "
|
||||
" SUM(CASE WHEN review_status = 'pending_review' THEN 1 ELSE 0 END) as pending "
|
||||
"FROM qc_issues"
|
||||
),
|
||||
{"d": date_str},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return dict(row._mapping) if row else {}
|
||||
|
||||
|
||||
def get_category_stats() -> list[dict]:
|
||||
"""카테고리별 부적합 건수 집계"""
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT COALESCE(final_category, category) AS category, "
|
||||
"COUNT(*) AS count "
|
||||
"FROM qc_issues "
|
||||
"GROUP BY COALESCE(final_category, category) "
|
||||
"ORDER BY count DESC"
|
||||
)
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_department_stats() -> list[dict]:
|
||||
"""부서별 부적합 건수 집계"""
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT responsible_department AS department, "
|
||||
"COUNT(*) AS count "
|
||||
"FROM qc_issues "
|
||||
"WHERE responsible_department IS NOT NULL "
|
||||
"AND responsible_department != '' "
|
||||
"GROUP BY responsible_department "
|
||||
"ORDER BY count DESC"
|
||||
)
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_issues_for_date(date_str: str) -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"review_status, responsible_department, solution "
|
||||
"FROM qc_issues "
|
||||
"WHERE DATE(report_date) = :d "
|
||||
"ORDER BY id"
|
||||
),
|
||||
{"d": date_str},
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
157
ai-service/services/embedding_service.py
Normal file
157
ai-service/services/embedding_service.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
|
||||
from services.ollama_client import ollama_client
|
||||
from db.vector_store import vector_store
|
||||
from db.metadata_store import metadata_store
|
||||
from services.db_client import get_all_issues, get_issue_by_id, get_issues_since
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_document_text(issue: dict) -> str:
|
||||
parts = []
|
||||
cat = issue.get("final_category") or issue.get("category")
|
||||
if cat:
|
||||
parts.append(f"분류: {cat}")
|
||||
if issue.get("description"):
|
||||
parts.append(issue["description"])
|
||||
if issue.get("final_description"):
|
||||
parts.append(issue["final_description"])
|
||||
if issue.get("detail_notes"):
|
||||
parts.append(issue["detail_notes"])
|
||||
if issue.get("solution"):
|
||||
parts.append(f"해결: {issue['solution']}")
|
||||
if issue.get("management_comment"):
|
||||
parts.append(f"의견: {issue['management_comment']}")
|
||||
if issue.get("cause_detail"):
|
||||
parts.append(f"원인: {issue['cause_detail']}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_metadata(issue: dict) -> dict:
|
||||
meta = {"issue_id": issue["id"]}
|
||||
for key in [
|
||||
"category", "project_id", "review_status",
|
||||
"responsible_department", "location_info",
|
||||
]:
|
||||
val = issue.get(key)
|
||||
if val is not None:
|
||||
meta[key] = str(val)
|
||||
rd = issue.get("report_date")
|
||||
if rd:
|
||||
meta["report_date"] = str(rd)[:10]
|
||||
meta["has_solution"] = "true" if issue.get("solution") else "false"
|
||||
return meta
|
||||
|
||||
|
||||
BATCH_SIZE = 10
|
||||
|
||||
|
||||
async def _sync_issues_batch(issues: list[dict]) -> tuple[int, int]:
|
||||
"""배치 단위로 임베딩 생성 후 벡터 스토어에 저장"""
|
||||
synced = 0
|
||||
skipped = 0
|
||||
|
||||
# 유효한 이슈와 텍스트 준비
|
||||
valid = []
|
||||
for issue in issues:
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
skipped += 1
|
||||
continue
|
||||
valid.append((issue, doc_text))
|
||||
|
||||
# 배치 단위로 임베딩 생성
|
||||
for i in range(0, len(valid), BATCH_SIZE):
|
||||
batch = valid[i:i + BATCH_SIZE]
|
||||
texts = [doc_text for _, doc_text in batch]
|
||||
try:
|
||||
embeddings = await ollama_client.batch_embeddings(texts)
|
||||
for (issue, doc_text), embedding in zip(batch, embeddings):
|
||||
vector_store.upsert(
|
||||
doc_id=f"issue_{issue['id']}",
|
||||
document=doc_text,
|
||||
embedding=embedding,
|
||||
metadata=build_metadata(issue),
|
||||
)
|
||||
synced += 1
|
||||
except Exception:
|
||||
skipped += len(batch)
|
||||
|
||||
return synced, skipped
|
||||
|
||||
|
||||
async def sync_all_issues() -> dict:
|
||||
issues = get_all_issues()
|
||||
synced, skipped = await _sync_issues_batch(issues)
|
||||
if issues:
|
||||
max_id = max(i["id"] for i in issues)
|
||||
metadata_store.set_last_synced_id(max_id)
|
||||
return {"synced": synced, "skipped": skipped, "total": len(issues)}
|
||||
|
||||
|
||||
async def sync_single_issue(issue_id: int) -> dict:
|
||||
logger.info(f"Sync single issue: {issue_id}")
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return {"status": "not_found"}
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return {"status": "empty_text"}
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
vector_store.upsert(
|
||||
doc_id=f"issue_{issue['id']}",
|
||||
document=doc_text,
|
||||
embedding=embedding,
|
||||
metadata=build_metadata(issue),
|
||||
)
|
||||
return {"status": "synced", "issue_id": issue_id}
|
||||
|
||||
|
||||
async def sync_incremental() -> dict:
|
||||
last_id = metadata_store.get_last_synced_id()
|
||||
issues = get_issues_since(last_id)
|
||||
synced, skipped = await _sync_issues_batch(issues)
|
||||
if issues:
|
||||
max_id = max(i["id"] for i in issues)
|
||||
metadata_store.set_last_synced_id(max_id)
|
||||
return {"synced": synced, "skipped": skipped, "new_issues": len(issues)}
|
||||
|
||||
|
||||
async def search_similar_by_id(issue_id: int, n_results: int = 5) -> list[dict]:
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return []
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return []
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
results = vector_store.query(
|
||||
embedding=embedding,
|
||||
n_results=n_results + 1,
|
||||
)
|
||||
# exclude self
|
||||
filtered = []
|
||||
for r in results:
|
||||
if r["id"] != f"issue_{issue_id}":
|
||||
filtered.append(r)
|
||||
return filtered[:n_results]
|
||||
|
||||
|
||||
async def search_similar_by_text(query: str, n_results: int = 5, filters: dict = None) -> list[dict]:
|
||||
embedding = await ollama_client.generate_embedding(query)
|
||||
where = None
|
||||
if filters:
|
||||
conditions = []
|
||||
for k, v in filters.items():
|
||||
if v is not None:
|
||||
conditions.append({k: str(v)})
|
||||
if len(conditions) == 1:
|
||||
where = conditions[0]
|
||||
elif len(conditions) > 1:
|
||||
where = {"$and": conditions}
|
||||
return vector_store.query(
|
||||
embedding=embedding,
|
||||
n_results=n_results,
|
||||
where=where,
|
||||
)
|
||||
82
ai-service/services/ollama_client.py
Normal file
82
ai-service/services/ollama_client.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from config import settings
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self):
|
||||
self.text_url = settings.OLLAMA_BASE_URL # GPU서버 (텍스트 생성)
|
||||
self.embed_url = settings.OLLAMA_EMBED_URL # 맥미니 (임베딩)
|
||||
self.timeout = httpx.Timeout(float(settings.OLLAMA_TIMEOUT), connect=10.0)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def generate_embedding(self, text: str) -> list[float]:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f"{self.embed_url}/api/embeddings",
|
||||
json={"model": settings.OLLAMA_EMBED_MODEL, "prompt": text},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def batch_embeddings(self, texts: list[str], concurrency: int = 5) -> list[list[float]]:
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _embed(text: str) -> list[float]:
|
||||
async with semaphore:
|
||||
return await self.generate_embedding(text)
|
||||
|
||||
return await asyncio.gather(*[_embed(t) for t in texts])
|
||||
|
||||
async def generate_text(self, prompt: str, system: str = None) -> str:
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f"{self.text_url}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": 0.3, "num_predict": 2048},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["message"]["content"]
|
||||
|
||||
async def check_health(self) -> dict:
|
||||
result = {}
|
||||
short_timeout = httpx.Timeout(5.0, connect=3.0)
|
||||
# GPU서버 Ollama (텍스트 생성)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=short_timeout) as c:
|
||||
response = await c.get(f"{self.text_url}/api/tags")
|
||||
models = response.json().get("models", [])
|
||||
result["ollama_text"] = {"status": "connected", "url": self.text_url, "models": [m["name"] for m in models]}
|
||||
except Exception:
|
||||
result["ollama_text"] = {"status": "disconnected", "url": self.text_url}
|
||||
# 맥미니 Ollama (임베딩)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=short_timeout) as c:
|
||||
response = await c.get(f"{self.embed_url}/api/tags")
|
||||
models = response.json().get("models", [])
|
||||
result["ollama_embed"] = {"status": "connected", "url": self.embed_url, "models": [m["name"] for m in models]}
|
||||
except Exception:
|
||||
result["ollama_embed"] = {"status": "disconnected", "url": self.embed_url}
|
||||
return result
|
||||
|
||||
|
||||
ollama_client = OllamaClient()
|
||||
211
ai-service/services/rag_service.py
Normal file
211
ai-service/services/rag_service.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from services.ollama_client import ollama_client
|
||||
from services.embedding_service import search_similar_by_text, build_document_text
|
||||
from services.db_client import get_issue_by_id, get_category_stats, get_department_stats
|
||||
from services.utils import load_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_stats_cache = {"data": "", "expires": 0}
|
||||
STATS_CACHE_TTL = 300 # 5분
|
||||
|
||||
STATS_KEYWORDS = {"많이", "빈도", "추이", "비율", "통계", "몇 건", "자주", "빈번", "유형별", "부서별"}
|
||||
|
||||
|
||||
def _needs_stats(question: str) -> bool:
|
||||
"""키워드 매칭으로 통계성 질문인지 판별"""
|
||||
return any(kw in question for kw in STATS_KEYWORDS)
|
||||
|
||||
|
||||
def _build_stats_summary() -> str:
|
||||
"""DB 집계 통계 요약 (5분 TTL 캐싱, 실패 시 빈 문자열)"""
|
||||
now = time.time()
|
||||
if _stats_cache["data"] and now < _stats_cache["expires"]:
|
||||
return _stats_cache["data"]
|
||||
try:
|
||||
lines = ["[전체 통계 요약]"]
|
||||
cats = get_category_stats()
|
||||
if cats:
|
||||
total = sum(c["count"] for c in cats)
|
||||
lines.append(f"총 부적합 건수: {total}건")
|
||||
lines.append("카테고리별:")
|
||||
for c in cats[:10]:
|
||||
pct = round(c["count"] / total * 100, 1)
|
||||
lines.append(f" - {c['category']}: {c['count']}건 ({pct}%)")
|
||||
depts = get_department_stats()
|
||||
if depts:
|
||||
lines.append("부서별:")
|
||||
for d in depts[:10]:
|
||||
lines.append(f" - {d['department']}: {d['count']}건")
|
||||
if len(lines) <= 1:
|
||||
return "" # 데이터 없으면 빈 문자열
|
||||
result = "\n".join(lines)
|
||||
_stats_cache["data"] = result
|
||||
_stats_cache["expires"] = now + STATS_CACHE_TTL
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Stats summary failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _format_retrieved_issues(results: list[dict]) -> str:
|
||||
if not results:
|
||||
return "관련 과거 사례가 없습니다."
|
||||
lines = []
|
||||
for i, r in enumerate(results, 1):
|
||||
meta = r.get("metadata", {})
|
||||
similarity = round(r.get("similarity", 0) * 100)
|
||||
doc = (r.get("document", ""))[:500]
|
||||
cat = meta.get("category", "")
|
||||
dept = meta.get("responsible_department", "")
|
||||
status = meta.get("review_status", "")
|
||||
has_sol = meta.get("has_solution", "false")
|
||||
date = meta.get("report_date", "")
|
||||
issue_id = meta.get("issue_id", r["id"])
|
||||
lines.append(
|
||||
f"[사례 {i}] No.{issue_id} (유사도 {similarity}%)\n"
|
||||
f" 분류: {cat} | 부서: {dept} | 상태: {status} | 날짜: {date} | 해결여부: {'O' if has_sol == 'true' else 'X'}\n"
|
||||
f" 내용: {doc}"
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
async def rag_suggest_solution(issue_id: int) -> dict:
|
||||
"""과거 유사 이슈의 해결 사례를 참고하여 해결방안을 제안"""
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return {"available": False, "error": "이슈를 찾을 수 없습니다"}
|
||||
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return {"available": False, "error": "이슈 내용이 비어있습니다"}
|
||||
|
||||
# 해결 완료된 유사 이슈 검색
|
||||
similar = await search_similar_by_text(
|
||||
doc_text, n_results=5, filters={"has_solution": "true"}
|
||||
)
|
||||
# 해결 안 된 것도 포함 (참고용)
|
||||
if len(similar) < 3:
|
||||
all_similar = await search_similar_by_text(doc_text, n_results=5)
|
||||
seen = {r["id"] for r in similar}
|
||||
for r in all_similar:
|
||||
if r["id"] not in seen:
|
||||
similar.append(r)
|
||||
if len(similar) >= 5:
|
||||
break
|
||||
|
||||
context = _format_retrieved_issues(similar)
|
||||
template = load_prompt("prompts/rag_suggest_solution.txt")
|
||||
prompt = template.format(
|
||||
description=issue.get("description", ""),
|
||||
detail_notes=issue.get("detail_notes", ""),
|
||||
category=issue.get("category", ""),
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"issue_id": issue_id,
|
||||
"suggestion": response,
|
||||
"referenced_issues": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"has_solution": r.get("metadata", {}).get("has_solution", "false") == "true",
|
||||
}
|
||||
for r in similar
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_ask(question: str, project_id: int = None) -> dict:
|
||||
"""부적합 데이터를 기반으로 자연어 질문에 답변"""
|
||||
# 프로젝트 필터 없이 전체 데이터에서 검색 (과거 미지정 데이터 포함)
|
||||
results = await search_similar_by_text(
|
||||
question, n_results=7, filters=None
|
||||
)
|
||||
logger.info(f"RAG ask: question='{question[:50]}', results={len(results)}")
|
||||
context = _format_retrieved_issues(results)
|
||||
|
||||
# 통계성 질문일 때만 DB 집계 포함 (토큰 절약)
|
||||
stats = _build_stats_summary() if _needs_stats(question) else ""
|
||||
|
||||
template = load_prompt("prompts/rag_qa.txt")
|
||||
prompt = template.format(
|
||||
question=question,
|
||||
stats_summary=stats,
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"answer": response,
|
||||
"sources": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"snippet": (r.get("document", ""))[:100],
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_analyze_pattern(description: str, n_results: int = 10) -> dict:
|
||||
"""유사 부적합 패턴 분석 — 반복되는 문제인지, 근본 원인은 무엇인지"""
|
||||
results = await search_similar_by_text(description, n_results=n_results)
|
||||
context = _format_retrieved_issues(results)
|
||||
|
||||
template = load_prompt("prompts/rag_pattern.txt")
|
||||
prompt = template.format(
|
||||
description=description,
|
||||
retrieved_cases=context,
|
||||
total_similar=len(results),
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"analysis": response,
|
||||
"similar_count": len(results),
|
||||
"sources": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"category": r.get("metadata", {}).get("category", ""),
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_classify_with_context(description: str, detail_notes: str = "") -> dict:
|
||||
"""과거 사례를 참고하여 더 정확한 분류 수행 (기존 classify 강화)"""
|
||||
query = f"{description} {detail_notes}".strip()
|
||||
similar = await search_similar_by_text(query, n_results=5)
|
||||
context = _format_retrieved_issues(similar)
|
||||
|
||||
template = load_prompt("prompts/rag_classify.txt")
|
||||
prompt = template.format(
|
||||
description=description,
|
||||
detail_notes=detail_notes,
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
import json
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
result["rag_enhanced"] = True
|
||||
result["referenced_count"] = len(similar)
|
||||
return {"available": True, **result}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"available": True, "raw_response": raw, "rag_enhanced": True}
|
||||
102
ai-service/services/report_service.py
Normal file
102
ai-service/services/report_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from services.ollama_client import ollama_client
|
||||
from services.db_client import get_daily_qc_stats, get_issues_for_date
|
||||
from services.utils import load_prompt
|
||||
from config import settings
|
||||
|
||||
|
||||
REPORT_PROMPT_PATH = "prompts/daily_report.txt"
|
||||
|
||||
|
||||
async def _fetch_one(client: httpx.AsyncClient, url: str, params: dict, headers: dict):
|
||||
try:
|
||||
r = await client.get(url, params=params, headers=headers)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_system1_data(date_str: str, token: str) -> dict:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
params = {"date": date_str}
|
||||
base = settings.SYSTEM1_API_URL
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
attendance, work_reports, patrol = await asyncio.gather(
|
||||
_fetch_one(client, f"{base}/api/attendance/daily-status", params, headers),
|
||||
_fetch_one(client, f"{base}/api/daily-work-reports/summary", params, headers),
|
||||
_fetch_one(client, f"{base}/api/patrol/today-status", params, headers),
|
||||
)
|
||||
except Exception:
|
||||
attendance = work_reports = patrol = None
|
||||
return {"attendance": attendance, "work_reports": work_reports, "patrol": patrol}
|
||||
|
||||
|
||||
def _format_attendance(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
if isinstance(data, dict):
|
||||
parts = []
|
||||
for k, v in data.items():
|
||||
parts.append(f" {k}: {v}")
|
||||
return "\n".join(parts)
|
||||
return str(data)
|
||||
|
||||
|
||||
def _format_work_reports(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
return str(data)
|
||||
|
||||
|
||||
def _format_qc_issues(issues: list[dict], stats: dict) -> str:
|
||||
lines = []
|
||||
lines.append(f"전체: {stats.get('total', 0)}건")
|
||||
lines.append(f"금일 신규: {stats.get('new_today', 0)}건")
|
||||
lines.append(f"진행중: {stats.get('in_progress', 0)}건")
|
||||
lines.append(f"완료: {stats.get('completed', 0)}건")
|
||||
lines.append(f"미검토: {stats.get('pending', 0)}건")
|
||||
if issues:
|
||||
lines.append("\n금일 신규 이슈:")
|
||||
for iss in issues[:10]:
|
||||
cat = iss.get("category", "")
|
||||
desc = (iss.get("description") or "")[:50]
|
||||
status = iss.get("review_status", "")
|
||||
lines.append(f" - [{cat}] {desc} (상태: {status})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_patrol(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
return str(data)
|
||||
|
||||
|
||||
async def generate_daily_report(
|
||||
date_str: str, project_id: int = None, token: str = ""
|
||||
) -> dict:
|
||||
system1_data = await _fetch_system1_data(date_str, token)
|
||||
qc_stats = get_daily_qc_stats(date_str)
|
||||
qc_issues = get_issues_for_date(date_str)
|
||||
|
||||
template = load_prompt(REPORT_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
date=date_str,
|
||||
attendance_data=_format_attendance(system1_data["attendance"]),
|
||||
work_report_data=_format_work_reports(system1_data["work_reports"]),
|
||||
qc_issue_data=_format_qc_issues(qc_issues, qc_stats),
|
||||
patrol_data=_format_patrol(system1_data["patrol"]),
|
||||
)
|
||||
|
||||
report_text = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"date": date_str,
|
||||
"report": report_text,
|
||||
"stats": {
|
||||
"qc": qc_stats,
|
||||
"new_issues_count": len(qc_issues),
|
||||
},
|
||||
}
|
||||
22
ai-service/services/utils.py
Normal file
22
ai-service/services/utils.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def load_prompt(path: str) -> str:
|
||||
full_path = os.path.join(_BASE_DIR, path)
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def parse_json_response(raw: str) -> dict:
|
||||
"""LLM 응답에서 JSON을 추출합니다."""
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start == -1 or end == 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw[start:end])
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
@@ -19,36 +19,14 @@ services:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||
ports:
|
||||
- "30306:3306"
|
||||
- "127.0.0.1:30306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: tk-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mproject}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mproject}
|
||||
TZ: Asia/Seoul
|
||||
PGTZ: Asia/Seoul
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./system3-nonconformance/api/migrations:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "30432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mproject}"]
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
container_name: tk-redis
|
||||
@@ -88,6 +66,12 @@ services:
|
||||
- SSO_JWT_REFRESH_EXPIRES_IN=${SSO_JWT_REFRESH_EXPIRES_IN:-30d}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
@@ -102,8 +86,8 @@ services:
|
||||
|
||||
system1-api:
|
||||
build:
|
||||
context: ./system1-factory/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: system1-factory/api/Dockerfile
|
||||
container_name: tk-system1-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -125,6 +109,13 @@ services:
|
||||
- REDIS_PORT=6379
|
||||
- WEATHER_API_URL=${WEATHER_API_URL:-}
|
||||
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system1_uploads:/usr/src/app/uploads
|
||||
- system1_logs:/usr/src/app/logs
|
||||
@@ -147,7 +138,10 @@ services:
|
||||
volumes:
|
||||
- ./system1-factory/web:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- system1-api
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
sso-auth:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -161,8 +155,15 @@ services:
|
||||
- "30008:8000"
|
||||
environment:
|
||||
- API_BASE_URL=http://system1-api:3005
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
- system1-api
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -172,8 +173,8 @@ services:
|
||||
|
||||
system2-api:
|
||||
build:
|
||||
context: ./system2-report/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: system2-report/api/Dockerfile
|
||||
container_name: tk-system2-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -194,6 +195,13 @@ services:
|
||||
- M_PROJECT_USERNAME=${M_PROJECT_USERNAME:-api_service}
|
||||
- M_PROJECT_PASSWORD=${M_PROJECT_PASSWORD:-}
|
||||
- M_PROJECT_DEFAULT_PROJECT_ID=${M_PROJECT_DEFAULT_PROJECT_ID:-1}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3005/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system2_uploads:/usr/src/app/uploads
|
||||
- system2_logs:/usr/src/app/logs
|
||||
@@ -214,7 +222,8 @@ services:
|
||||
ports:
|
||||
- "30180:80"
|
||||
depends_on:
|
||||
- system2-api
|
||||
system2-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -242,6 +251,12 @@ services:
|
||||
- ADMIN_USERNAME=${SYSTEM3_ADMIN_USERNAME:-hyungi}
|
||||
- TZ=Asia/Seoul
|
||||
- TKUSER_API_URL=http://tkuser-api:3000
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system3_uploads:/app/uploads
|
||||
depends_on:
|
||||
@@ -261,7 +276,8 @@ services:
|
||||
volumes:
|
||||
- system3_uploads:/usr/share/nginx/html/uploads
|
||||
depends_on:
|
||||
- system3-api
|
||||
system3-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -271,8 +287,8 @@ services:
|
||||
|
||||
tkuser-api:
|
||||
build:
|
||||
context: ./user-management/api
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: user-management/api/Dockerfile
|
||||
container_name: tk-tkuser-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -286,6 +302,20 @@ services:
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
|
||||
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
|
||||
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
|
||||
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- system1_uploads:/usr/src/app/uploads
|
||||
depends_on:
|
||||
@@ -303,12 +333,254 @@ services:
|
||||
ports:
|
||||
- "30380:80"
|
||||
depends_on:
|
||||
- tkuser-api
|
||||
tkuser-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# Gateway
|
||||
# Purchase Management (tkpurchase)
|
||||
# =================================================================
|
||||
|
||||
tkpurchase-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tkpurchase/api/Dockerfile
|
||||
container_name: tk-tkpurchase-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30400:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkpurchase-web:
|
||||
build:
|
||||
context: ./tkpurchase/web
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tkpurchase-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30480:80"
|
||||
depends_on:
|
||||
tkpurchase-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# Safety Management (tksafety)
|
||||
# =================================================================
|
||||
|
||||
tksafety-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tksafety/api/Dockerfile
|
||||
container_name: tk-tksafety-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30500:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- tksafety_uploads:/usr/src/app/uploads
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tksafety-web:
|
||||
build:
|
||||
context: ./tksafety/web
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tksafety-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30580:80"
|
||||
volumes:
|
||||
- tksafety_uploads:/usr/share/nginx/html/uploads
|
||||
depends_on:
|
||||
tksafety-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# Support (tksupport) - 전사 행정지원
|
||||
# =================================================================
|
||||
|
||||
tksupport-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tksupport/api/Dockerfile
|
||||
container_name: tk-tksupport-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30600:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tksupport-web:
|
||||
build:
|
||||
context: ./tksupport/web
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tksupport-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30680:80"
|
||||
depends_on:
|
||||
tksupport-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# TK-EG - BOM 자재관리 (tkeg)
|
||||
# =================================================================
|
||||
|
||||
tkeg-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: tk-tkeg-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: tk_bom
|
||||
POSTGRES_USER: tkbom_user
|
||||
POSTGRES_PASSWORD: ${TKEG_POSTGRES_PASSWORD}
|
||||
TZ: Asia/Seoul
|
||||
volumes:
|
||||
- tkeg_postgres_data:/var/lib/postgresql/data
|
||||
- ./tkeg/api/database/init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tkbom_user -d tk_bom"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkeg-api:
|
||||
build:
|
||||
context: ./tkeg/api
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tkeg-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30700:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://tkbom_user:${TKEG_POSTGRES_PASSWORD}@tkeg-postgres:5432/tk_bom
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SECRET_KEY=${SSO_JWT_SECRET}
|
||||
- TKUSER_API_URL=http://tkuser-api:3000
|
||||
- ENVIRONMENT=production
|
||||
- TZ=Asia/Seoul
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- tkeg_uploads:/app/uploads
|
||||
depends_on:
|
||||
tkeg-postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkeg-web:
|
||||
build:
|
||||
context: ./tkeg/web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
container_name: tk-tkeg-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30780:80"
|
||||
depends_on:
|
||||
tkeg-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# ntfy — 푸시 알림 서버
|
||||
# =================================================================
|
||||
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: tk-ntfy
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
ports:
|
||||
- "30750:80"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
- ./ntfy/etc:/etc/ntfy
|
||||
- ntfy_cache:/var/cache/ntfy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# AI Service — 맥미니로 이전됨 (~/docker/tk-ai-service/)
|
||||
# =================================================================
|
||||
|
||||
# =================================================================
|
||||
# Gateway (로그인 + 대시보드 + 공유JS)
|
||||
# =================================================================
|
||||
|
||||
gateway:
|
||||
@@ -320,10 +592,10 @@ services:
|
||||
ports:
|
||||
- "30000:80"
|
||||
depends_on:
|
||||
- sso-auth
|
||||
- system1-web
|
||||
- system2-web
|
||||
- system3-web
|
||||
sso-auth:
|
||||
condition: service_healthy
|
||||
system1-api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -336,7 +608,7 @@ services:
|
||||
container_name: tk-phpmyadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30880:80"
|
||||
- "127.0.0.1:30880:80"
|
||||
environment:
|
||||
- PMA_HOST=mariadb
|
||||
- PMA_USER=${PMA_USER:-root}
|
||||
@@ -361,8 +633,14 @@ services:
|
||||
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
depends_on:
|
||||
- gateway
|
||||
- system1-web
|
||||
- system2-web
|
||||
- system3-web
|
||||
- tkpurchase-web
|
||||
- tksafety-web
|
||||
- tksupport-web
|
||||
- tkeg-web
|
||||
- ntfy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -370,18 +648,19 @@ volumes:
|
||||
mariadb_data:
|
||||
external: true
|
||||
name: tkfb-package_db_data
|
||||
postgres_data:
|
||||
external: true
|
||||
name: tkqc-package_postgres_data
|
||||
system1_uploads:
|
||||
external: true
|
||||
name: tkfb_api_uploads
|
||||
system1_logs:
|
||||
system2_uploads:
|
||||
system2_logs:
|
||||
tksafety_uploads:
|
||||
system3_uploads:
|
||||
external: true
|
||||
name: tkqc-package_uploads
|
||||
tkeg_postgres_data:
|
||||
tkeg_uploads:
|
||||
ntfy_cache:
|
||||
networks:
|
||||
tk-network:
|
||||
driver: bridge
|
||||
|
||||
870
gateway/html/dashboard.html
Normal file
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,206 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - TK 공장관리</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: #1a56db;
|
||||
font-size: 22px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-box .sub {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #1a56db;
|
||||
box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-submit:hover { background: #1e40af; }
|
||||
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>TK 공장관리 시스템</h1>
|
||||
<p class="sub">통합 로그인</p>
|
||||
|
||||
<form id="loginForm" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label for="username">사용자명</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">비밀번호</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-submit" id="submitBtn">로그인</button>
|
||||
<p class="error-msg" id="errorMsg"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// SSO 쿠키 유틸리티
|
||||
var ssoCookie = {
|
||||
set: function(name, value, days) {
|
||||
var cookie = name + '=' + encodeURIComponent(value) + '; path=/';
|
||||
if (days) cookie += '; max-age=' + (days * 86400);
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
},
|
||||
get: function(name) {
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
remove: function(name) {
|
||||
var cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
};
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('submitBtn');
|
||||
var errEl = document.getElementById('errorMsg');
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '로그인 중...';
|
||||
|
||||
try {
|
||||
var res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
});
|
||||
|
||||
var data = await res.json();
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || '로그인에 실패했습니다');
|
||||
}
|
||||
|
||||
// 쿠키에 토큰 저장 (서브도메인 공유)
|
||||
ssoCookie.set('sso_token', data.access_token, 7);
|
||||
ssoCookie.set('sso_user', JSON.stringify(data.user), 7);
|
||||
if (data.refresh_token) {
|
||||
ssoCookie.set('sso_refresh_token', data.refresh_token, 30);
|
||||
}
|
||||
|
||||
// localStorage에도 저장 (같은 도메인 내 호환성)
|
||||
localStorage.setItem('sso_token', data.access_token);
|
||||
localStorage.setItem('sso_user', JSON.stringify(data.user));
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('sso_refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// redirect 파라미터가 있으면 해당 URL로, 없으면 포털로
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
// Open redirect 방지: 상대 경로 또는 같은 도메인만 허용
|
||||
if (redirect && (redirect.startsWith('/') && !redirect.startsWith('//')) && !redirect.includes('://')) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = '';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '로그인';
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
function isTokenValid(token) {
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp > Math.floor(Date.now() / 1000);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크 + 만료 확인)
|
||||
var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
|
||||
if (isTokenValid(existingToken)) {
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
window.location.href = redirect || '/';
|
||||
} else {
|
||||
// 만료된 토큰 정리
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
};
|
||||
|
||||
function getToken() {
|
||||
return ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||
}
|
||||
function getUser() {
|
||||
var raw = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
try { return JSON.parse(raw); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function init() {
|
||||
var token = getToken();
|
||||
var user = getUser();
|
||||
|
||||
if (token && user) {
|
||||
showDashboard(user);
|
||||
} else {
|
||||
document.getElementById('loginPrompt').style.display = '';
|
||||
document.getElementById('dashboard').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showDashboard(user) {
|
||||
document.getElementById('loginPrompt').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = '';
|
||||
document.getElementById('userInfo').style.display = 'flex';
|
||||
document.getElementById('userName').textContent = user.name || user.username;
|
||||
document.getElementById('userRole').textContent = '(' + (user.role || '') + ')';
|
||||
document.getElementById('welcomeText').textContent =
|
||||
(user.name || user.username) + '님, 환영합니다';
|
||||
|
||||
// 접근 권한에 따라 카드 비활성화
|
||||
const access = user.system_access || {};
|
||||
if (access.system1 === false) document.getElementById('card-s1').classList.add('no-access');
|
||||
if (access.system2 === false) document.getElementById('card-s2').classList.add('no-access');
|
||||
if (access.system3 === false) document.getElementById('card-s3').classList.add('no-access');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
ssoCookie.remove('sso_token');
|
||||
ssoCookie.remove('sso_user');
|
||||
ssoCookie.remove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
localStorage.removeItem('sso_refresh_token');
|
||||
fetch('/auth/logout', { method: 'POST' }).catch(function(){});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// 서브도메인 링크 설정
|
||||
function setupSystemLinks() {
|
||||
var hostname = window.location.hostname;
|
||||
var protocol = window.location.protocol;
|
||||
var s2Link, s3Link;
|
||||
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
s2Link = protocol + '//tkreport.technicalkorea.net';
|
||||
s3Link = protocol + '//tkqc.technicalkorea.net';
|
||||
} else {
|
||||
// 개발 환경: 포트 기반
|
||||
s2Link = protocol + '//' + hostname + ':30180';
|
||||
s3Link = protocol + '//' + hostname + ':30280';
|
||||
}
|
||||
|
||||
document.getElementById('card-s2-link').href = s2Link;
|
||||
document.getElementById('card-s3-link').href = s3Link;
|
||||
}
|
||||
setupSystemLinks();
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@
|
||||
function cookieRemove(name) {
|
||||
var cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net';
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
@@ -31,11 +31,11 @@
|
||||
*/
|
||||
window.SSOAuth = {
|
||||
getToken: function() {
|
||||
return cookieGet('sso_token') || localStorage.getItem('sso_token');
|
||||
return cookieGet('sso_token');
|
||||
},
|
||||
|
||||
getUser: function() {
|
||||
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
|
||||
var raw = cookieGet('sso_user');
|
||||
try { return JSON.parse(raw); } catch(e) { return null; }
|
||||
},
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
cookieRemove('sso_token');
|
||||
cookieRemove('sso_user');
|
||||
cookieRemove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
localStorage.removeItem('sso_refresh_token');
|
||||
window.location.href = this.getLoginUrl();
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
window.location.href = this.getLoginUrl(window.location.href) + '&logout=1';
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -62,10 +62,10 @@
|
||||
var loginUrl;
|
||||
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/login';
|
||||
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
|
||||
} else {
|
||||
// 개발 환경: Gateway 포트 (30000)
|
||||
loginUrl = window.location.protocol + '//' + hostname + ':30000/login';
|
||||
// 개발 환경: tkds 포트 (30780)
|
||||
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
|
||||
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,34 +1,52 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# ===== Gateway 자체 페이지 (포털, 로그인) =====
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 로그인 페이지
|
||||
location = /login {
|
||||
try_files /login.html =404;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 대시보드 (로그인 포함)
|
||||
location = /dashboard {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
try_files /dashboard.html =404;
|
||||
}
|
||||
|
||||
# 공유 JS/CSS (nav-header 등)
|
||||
location = / {
|
||||
return 302 /dashboard$is_args$args;
|
||||
}
|
||||
|
||||
# 레거시 /login → /dashboard 리다이렉트
|
||||
location = /login {
|
||||
return 302 /dashboard$is_args$args;
|
||||
}
|
||||
|
||||
# 공유 JS (notification-bell.js, nav-header.js)
|
||||
location /shared/ {
|
||||
alias /usr/share/nginx/html/shared/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# ===== SSO Auth API =====
|
||||
# SSO Auth 프록시
|
||||
location /auth/ {
|
||||
proxy_pass http://sso-auth:3000/api/auth/;
|
||||
set $upstream http://sso-auth:3000;
|
||||
rewrite ^/auth/(.*)$ /api/auth/$1 break;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== System 1 API 프록시 =====
|
||||
# System1 API 프록시 (대시보드 배너용: 알림/TBM/휴가 카운트)
|
||||
location /api/ {
|
||||
proxy_pass http://system1-api:3005/api/;
|
||||
set $upstream http://system1-api:3005;
|
||||
proxy_pass $upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -36,34 +54,9 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== System 1 업로드 파일 =====
|
||||
location /uploads/ {
|
||||
proxy_pass http://system1-api:3005/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ===== System 1 FastAPI Bridge =====
|
||||
location /fastapi/ {
|
||||
proxy_pass http://system1-fastapi:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== System 1 Web (나머지 모든 경로) =====
|
||||
location / {
|
||||
proxy_pass http://system1-web:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== Health Check =====
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok","service":"gateway"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
|
||||
167
ntfy/README.md
Normal file
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"
|
||||
87
scripts/check-version.sh
Executable file
87
scripts/check-version.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# ===================================================================
|
||||
# TK Factory Services - 배포 상태 확인 (맥북에서 실행)
|
||||
# ===================================================================
|
||||
# 사용법: ./scripts/check-version.sh
|
||||
# 설정: ~/.tk-deploy-config
|
||||
# ===================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CONFIG_FILE="$HOME/.tk-deploy-config"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# === 설정 로드 ===
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
DOCKER="/usr/local/bin/docker"
|
||||
|
||||
ssh_cmd() {
|
||||
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
|
||||
}
|
||||
|
||||
# === NAS 배포 버전 확인 ===
|
||||
echo "=== TK Factory Services - 배포 상태 ==="
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}[NAS 배포 버전]${NC}"
|
||||
NAS_INFO=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H|%s|%ci'" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$NAS_INFO" ]; then
|
||||
echo -e " ${RED}NAS에서 git 정보를 가져올 수 없습니다${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAS_HASH=$(echo "$NAS_INFO" | cut -d'|' -f1)
|
||||
NAS_MSG=$(echo "$NAS_INFO" | cut -d'|' -f2)
|
||||
NAS_DATE=$(echo "$NAS_INFO" | cut -d'|' -f3)
|
||||
NAS_SHORT="${NAS_HASH:0:7}"
|
||||
|
||||
echo " 커밋: ${NAS_SHORT} - ${NAS_MSG}"
|
||||
echo " 날짜: ${NAS_DATE}"
|
||||
|
||||
# === origin/main 대비 상태 ===
|
||||
echo ""
|
||||
echo -e "${CYAN}[origin/main 대비]${NC}"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
git fetch origin --quiet
|
||||
|
||||
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$ORIGIN_HASH" ]; then
|
||||
if [ "$NAS_HASH" = "$ORIGIN_HASH" ]; then
|
||||
echo -e " ${GREEN}최신 상태${NC} (origin/${LOCAL_BRANCH}과 동일)"
|
||||
else
|
||||
BEHIND_COUNT=$(git log "${NAS_HASH}..${ORIGIN_HASH}" --oneline 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$BEHIND_COUNT" -gt 0 ]; then
|
||||
echo -e " ${YELLOW}${BEHIND_COUNT}개 커밋 뒤처짐${NC}"
|
||||
echo ""
|
||||
echo " 미배포 커밋:"
|
||||
git log "${NAS_HASH}..${ORIGIN_HASH}" --oneline --no-decorate | sed 's/^/ /'
|
||||
else
|
||||
echo -e " ${YELLOW}NAS가 origin보다 앞서 있거나 브랜치가 다릅니다${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Docker 컨테이너 상태 ===
|
||||
echo ""
|
||||
echo -e "${CYAN}[Docker 컨테이너 상태]${NC}"
|
||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} compose ps --format 'table {{.Name}}\t{{.Status}}'" 2>&1 | grep -v '^\[sudo\]' || echo " 컨테이너 상태를 가져올 수 없습니다"
|
||||
|
||||
# === 최근 배포 로그 ===
|
||||
echo ""
|
||||
echo -e "${CYAN}[최근 배포 로그]${NC}"
|
||||
ssh_cmd "tail -5 ${NAS_DEPLOY_PATH}/DEPLOY_LOG" 2>/dev/null | sed 's/^/ /' || echo " 배포 로그가 없습니다"
|
||||
32
scripts/migration-purchase-safety-patch.sql
Normal file
32
scripts/migration-purchase-safety-patch.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- migration-purchase-safety-patch.sql
|
||||
-- 배포 후 스키마 보완 패치
|
||||
-- 생성일: 2026-03-13
|
||||
|
||||
-- 4-a. check_in_time NOT NULL (체크인 시 시간은 항상 존재)
|
||||
ALTER TABLE partner_work_checkins
|
||||
MODIFY check_in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- 4-b. partner_work_reports 테이블 생성 (daily_work_reports 이름 충돌 → 별도 이름)
|
||||
CREATE TABLE IF NOT EXISTS partner_work_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
schedule_id INT NOT NULL,
|
||||
checkin_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
report_date DATE NOT NULL,
|
||||
reporter_id INT NOT NULL,
|
||||
actual_workers INT,
|
||||
work_content TEXT,
|
||||
progress_rate TINYINT,
|
||||
issues TEXT,
|
||||
next_plan TEXT,
|
||||
confirmed_by INT,
|
||||
confirmed_at DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_pwr_report_date (report_date),
|
||||
INDEX idx_pwr_schedule (schedule_id),
|
||||
UNIQUE INDEX uq_pwr_schedule_report_date (schedule_id, report_date),
|
||||
CONSTRAINT fk_pwr_schedule FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id),
|
||||
CONSTRAINT fk_pwr_checkin FOREIGN KEY (checkin_id) REFERENCES partner_work_checkins(id),
|
||||
CONSTRAINT fk_pwr_company FOREIGN KEY (company_id) REFERENCES partner_companies(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
10
scripts/migration-purchase-safety-patch2.sql
Normal file
10
scripts/migration-purchase-safety-patch2.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- migration-purchase-safety-patch2.sql
|
||||
-- 협력업체 작업 신청 기능: status ENUM 확장 + requested_by 필드 추가
|
||||
|
||||
-- status ENUM에 requested, rejected 추가 (기존 값 모두 포함, DEFAULT 'scheduled' 유지)
|
||||
ALTER TABLE partner_schedules
|
||||
MODIFY COLUMN status ENUM('requested','scheduled','in_progress','completed','cancelled','rejected')
|
||||
NOT NULL DEFAULT 'scheduled';
|
||||
|
||||
-- 신청자 필드 추가
|
||||
ALTER TABLE partner_schedules ADD COLUMN requested_by INT NULL AFTER registered_by;
|
||||
147
scripts/migration-purchase-safety.sql
Normal file
147
scripts/migration-purchase-safety.sql
Normal file
@@ -0,0 +1,147 @@
|
||||
-- migration-purchase-safety.sql
|
||||
-- 협력업체/일용직 관리 및 안전교육 테이블 마이그레이션
|
||||
-- MariaDB용, 재실행 안전 (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS)
|
||||
-- 생성일: 2026-03-12
|
||||
|
||||
-- ============================================================
|
||||
-- 1. sso_users 테이블에 협력업체 관련 컬럼 추가
|
||||
-- ============================================================
|
||||
ALTER TABLE sso_users
|
||||
ADD COLUMN IF NOT EXISTS partner_company_id INT DEFAULT NULL
|
||||
COMMENT '협력업체 소속 시 partner_companies.id, 내부직원은 NULL';
|
||||
|
||||
ALTER TABLE sso_users
|
||||
ADD COLUMN IF NOT EXISTS account_expires_at DATETIME DEFAULT NULL
|
||||
COMMENT '협력업체 계정 만료일, 내부직원은 NULL';
|
||||
|
||||
-- 외래키는 IF NOT EXISTS 구문이 없으므로 프로시저로 안전하게 추가
|
||||
DELIMITER //
|
||||
DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company//
|
||||
CREATE PROCEDURE __add_fk_sso_users_partner_company()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sso_users'
|
||||
AND CONSTRAINT_NAME = 'fk_sso_users_partner_company'
|
||||
) THEN
|
||||
ALTER TABLE sso_users
|
||||
ADD CONSTRAINT fk_sso_users_partner_company
|
||||
FOREIGN KEY (partner_company_id) REFERENCES partner_companies(id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL __add_fk_sso_users_partner_company();
|
||||
DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. day_labor_requests (일용직 작업 요청)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS day_labor_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
requester_id INT NOT NULL COMMENT 'sso_users.user_id',
|
||||
department_id INT,
|
||||
work_date DATE NOT NULL,
|
||||
worker_count INT NOT NULL DEFAULT 1,
|
||||
work_description TEXT,
|
||||
workplace_name VARCHAR(100),
|
||||
status ENUM('pending','approved','rejected','completed') DEFAULT 'pending',
|
||||
approved_by INT,
|
||||
approved_at DATETIME,
|
||||
safety_reported BOOLEAN DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_day_labor_work_date (work_date),
|
||||
INDEX idx_day_labor_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. partner_schedules (협력업체 작업 일정)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS partner_schedules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
work_date DATE NOT NULL,
|
||||
work_description TEXT,
|
||||
workplace_name VARCHAR(100),
|
||||
expected_workers INT DEFAULT 1,
|
||||
registered_by INT NOT NULL,
|
||||
status ENUM('scheduled','in_progress','completed','cancelled') DEFAULT 'scheduled',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_partner_sched_work_date (work_date),
|
||||
INDEX idx_partner_sched_company_date (company_id, work_date),
|
||||
CONSTRAINT fk_partner_schedules_company
|
||||
FOREIGN KEY (company_id) REFERENCES partner_companies(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. partner_work_checkins (협력업체 출퇴근 체크)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS partner_work_checkins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
schedule_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
checked_by INT NOT NULL,
|
||||
check_in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
check_out_time DATETIME,
|
||||
worker_names TEXT,
|
||||
actual_worker_count INT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_checkin_schedule_time (schedule_id, check_in_time),
|
||||
CONSTRAINT fk_checkins_schedule
|
||||
FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. partner_work_reports (협력업체 일일 작업 보고)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS partner_work_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
schedule_id INT NOT NULL,
|
||||
checkin_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
report_date DATE NOT NULL,
|
||||
reporter_id INT NOT NULL,
|
||||
actual_workers INT,
|
||||
work_content TEXT,
|
||||
progress_rate TINYINT CHECK (progress_rate BETWEEN 0 AND 100),
|
||||
issues TEXT,
|
||||
next_plan TEXT,
|
||||
confirmed_by INT,
|
||||
confirmed_at DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_daily_report_date (report_date),
|
||||
INDEX idx_daily_report_schedule (schedule_id),
|
||||
UNIQUE INDEX uq_schedule_report_date (schedule_id, report_date),
|
||||
CONSTRAINT fk_daily_reports_schedule
|
||||
FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id),
|
||||
CONSTRAINT fk_daily_reports_checkin
|
||||
FOREIGN KEY (checkin_id) REFERENCES partner_work_checkins(id),
|
||||
CONSTRAINT fk_daily_reports_company
|
||||
FOREIGN KEY (company_id) REFERENCES partner_companies(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. safety_education_reports (안전교육 보고)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS safety_education_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
target_type ENUM('day_labor','partner_schedule','manual') NOT NULL,
|
||||
target_id INT,
|
||||
education_date DATE NOT NULL,
|
||||
educator VARCHAR(50),
|
||||
attendees JSON,
|
||||
status ENUM('planned','completed','cancelled') DEFAULT 'planned',
|
||||
notes TEXT,
|
||||
registered_by INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_safety_edu_date (education_date),
|
||||
INDEX idx_safety_edu_target (target_type, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
23
scripts/migration-schedule-daterange.sql
Normal file
23
scripts/migration-schedule-daterange.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Migration: partner_schedules 기간 기반 + 프로젝트 연결
|
||||
-- 실행 순서: 1단계 → 2단계 → 3단계 순서대로 실행할 것
|
||||
|
||||
-- ===== 1단계: 컬럼 변경 + end_date/project_id 추가 =====
|
||||
ALTER TABLE partner_schedules
|
||||
CHANGE COLUMN work_date start_date DATE NOT NULL,
|
||||
ADD COLUMN end_date DATE DEFAULT NULL AFTER start_date,
|
||||
ADD COLUMN project_id INT DEFAULT NULL AFTER company_id;
|
||||
|
||||
-- ===== 2단계: 기존 데이터 채우기 (단일 날짜 → 시작일=종료일) =====
|
||||
UPDATE partner_schedules SET end_date = start_date WHERE end_date IS NULL;
|
||||
|
||||
-- ===== 3단계: NOT NULL로 변경 + 인덱스 재구성 =====
|
||||
-- ※ 실행 전 SHOW INDEX FROM partner_schedules; 로 실제 인덱스명 확인 후 맞출 것
|
||||
ALTER TABLE partner_schedules
|
||||
MODIFY end_date DATE NOT NULL,
|
||||
ADD INDEX idx_partner_sched_dates (start_date, end_date),
|
||||
ADD INDEX idx_partner_sched_company (company_id, start_date),
|
||||
ADD INDEX idx_partner_sched_project (project_id);
|
||||
|
||||
-- 기존 인덱스가 있다면 별도로 삭제:
|
||||
-- ALTER TABLE partner_schedules DROP INDEX idx_work_date;
|
||||
-- ALTER TABLE partner_schedules DROP INDEX idx_company_date;
|
||||
26
scripts/migration-work-report-enhancement.sql
Normal file
26
scripts/migration-work-report-enhancement.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- ============================================================
|
||||
-- 업무현황 다건 입력 + 작업자 시간 추적 마이그레이션
|
||||
-- 실행: MariaDB (tkpurchase DB)
|
||||
-- 날짜: 2026-03-13
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 유니크 제약 제거 (1일정-1보고 제한 해제)
|
||||
ALTER TABLE partner_work_reports DROP INDEX uq_pwr_schedule_report_date;
|
||||
|
||||
-- 2) 보고 순번 컬럼 추가
|
||||
ALTER TABLE partner_work_reports
|
||||
ADD COLUMN report_seq TINYINT NOT NULL DEFAULT 1 AFTER report_date;
|
||||
|
||||
-- 3) 작업자별 투입시간 테이블
|
||||
CREATE TABLE IF NOT EXISTS work_report_workers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
report_id INT NOT NULL,
|
||||
partner_worker_id INT,
|
||||
worker_name VARCHAR(100) NOT NULL,
|
||||
hours_worked DECIMAL(4,1) DEFAULT 8.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_wrw_report FOREIGN KEY (report_id)
|
||||
REFERENCES partner_work_reports(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wrw_partner_worker FOREIGN KEY (partner_worker_id)
|
||||
REFERENCES partner_workers(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
135
scripts/rollback-remote.sh
Executable file
135
scripts/rollback-remote.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# ===================================================================
|
||||
# TK Factory Services - 원격 롤백 스크립트 (맥북에서 실행)
|
||||
# ===================================================================
|
||||
# 사용법: ./scripts/rollback-remote.sh <commit-hash>
|
||||
# 설정: ~/.tk-deploy-config
|
||||
# ===================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CONFIG_FILE="$HOME/.tk-deploy-config"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# === 인자 확인 ===
|
||||
TARGET_HASH="${1:-}"
|
||||
if [ -z "$TARGET_HASH" ]; then
|
||||
echo "사용법: ./scripts/rollback-remote.sh <commit-hash>"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " ./scripts/rollback-remote.sh abc1234"
|
||||
echo " ./scripts/rollback-remote.sh abc1234567890"
|
||||
echo ""
|
||||
echo "최근 커밋 목록:"
|
||||
cd "$(dirname "$0")/.."
|
||||
git log --oneline -10
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# === 설정 로드 ===
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
DOCKER="/usr/local/bin/docker"
|
||||
|
||||
ssh_cmd() {
|
||||
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
|
||||
}
|
||||
|
||||
nas_docker() {
|
||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
|
||||
}
|
||||
|
||||
# === 커밋 유효성 확인 ===
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
FULL_HASH=$(git rev-parse "$TARGET_HASH" 2>/dev/null || echo "")
|
||||
if [ -z "$FULL_HASH" ]; then
|
||||
echo -e "${RED}ERROR: 유효하지 않은 커밋 해시입니다: ${TARGET_HASH}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_SHORT="${FULL_HASH:0:7}"
|
||||
TARGET_MSG=$(git log -1 --format='%s' "$FULL_HASH")
|
||||
TARGET_DATE=$(git log -1 --format='%ci' "$FULL_HASH")
|
||||
|
||||
# === 현재 NAS 상태 확인 ===
|
||||
echo "=== TK Factory Services - 원격 롤백 ==="
|
||||
echo ""
|
||||
|
||||
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
|
||||
NAS_SHORT="${NAS_HASH:0:7}"
|
||||
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
|
||||
|
||||
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
|
||||
echo -e " 롤백 대상: ${CYAN}${TARGET_SHORT}${NC} - ${TARGET_MSG}"
|
||||
echo -e " 커밋 날짜: ${TARGET_DATE}"
|
||||
|
||||
if [ "$FULL_HASH" = "$NAS_HASH" ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}이미 해당 버전입니다.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "롤백을 진행하시겠습니까? [y/N] " confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "롤백이 취소되었습니다."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# === 롤백 실행 ===
|
||||
echo ""
|
||||
echo -e "${CYAN}[1/3] NAS 코드 롤백${NC}"
|
||||
|
||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git checkout ${FULL_HASH}"
|
||||
|
||||
echo -e " ${GREEN}완료${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}[2/3] Docker 컨테이너 재빌드${NC}"
|
||||
echo " (빌드에 시간이 걸릴 수 있습니다...)"
|
||||
echo ""
|
||||
|
||||
nas_docker "compose up -d --build"
|
||||
|
||||
echo ""
|
||||
echo " nginx 프록시 컨테이너 재시작..."
|
||||
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}[3/3] 배포 검증${NC} (15초 대기)"
|
||||
sleep 15
|
||||
|
||||
echo ""
|
||||
echo "=== Container Status ==="
|
||||
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
|
||||
|
||||
# 배포 로그 기록
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
ssh_cmd "echo '${TIMESTAMP} | ${TARGET_SHORT} | ROLLBACK: ${TARGET_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}롤백 완료!${NC}"
|
||||
echo " 버전: ${TARGET_SHORT} - ${TARGET_MSG}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}주의: NAS가 detached HEAD 상태입니다.${NC}"
|
||||
echo " 다음 정상 배포 시 자동으로 main 브랜치로 복귀합니다."
|
||||
28
shared/config/database.js
Normal file
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;
|
||||
@@ -6,12 +6,16 @@
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const userModel = require('../models/userModel');
|
||||
const redis = require('../utils/redis');
|
||||
|
||||
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
||||
const JWT_EXPIRES_IN = process.env.SSO_JWT_EXPIRES_IN || '7d';
|
||||
const JWT_REFRESH_SECRET = process.env.SSO_JWT_REFRESH_SECRET;
|
||||
const JWT_REFRESH_EXPIRES_IN = process.env.SSO_JWT_REFRESH_EXPIRES_IN || '30d';
|
||||
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOGIN_LOCKOUT_SECONDS = 300; // 5분
|
||||
|
||||
/**
|
||||
* JWT 토큰 페이로드 생성 (모든 시스템 공통 구조)
|
||||
*/
|
||||
@@ -28,6 +32,7 @@ function createTokenPayload(user) {
|
||||
role: user.role,
|
||||
access_level: user.role,
|
||||
sub: user.username,
|
||||
partner_company_id: user.partner_company_id || null,
|
||||
system_access: {
|
||||
system1: user.system1_access,
|
||||
system2: user.system2_access,
|
||||
@@ -47,24 +52,42 @@ async function login(req, res, next) {
|
||||
return res.status(400).json({ success: false, error: '사용자명과 비밀번호를 입력하세요' });
|
||||
}
|
||||
|
||||
// 로그인 시도 횟수 확인
|
||||
const attemptKey = `login_attempts:${username}`;
|
||||
const attempts = parseInt(await redis.get(attemptKey)) || 0;
|
||||
if (attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
return res.status(429).json({ success: false, error: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
|
||||
}
|
||||
|
||||
const user = await userModel.findByUsername(username);
|
||||
if (!user) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
// 협력업체 계정 만료일 체크
|
||||
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
|
||||
return res.status(401).json({ success: false, error: '계정이 만료되었습니다. 관리자에게 문의하세요.' });
|
||||
}
|
||||
|
||||
const valid = await userModel.verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
// 로그인 성공 시 시도 횟수 초기화
|
||||
await redis.del(attemptKey);
|
||||
await userModel.updateLastLogin(user.user_id);
|
||||
|
||||
const payload = createTokenPayload(user);
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||
const refresh_token = jwt.sign(
|
||||
{ user_id: user.user_id, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||
);
|
||||
|
||||
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
||||
@@ -104,20 +127,39 @@ async function loginForm(req, res, next) {
|
||||
return res.status(400).json({ detail: 'Missing username or password' });
|
||||
}
|
||||
|
||||
// Rate limiting (동일 로직: /login과 공유)
|
||||
const attemptKey = `login_attempts:${username}`;
|
||||
const attempts = parseInt(await redis.get(attemptKey)) || 0;
|
||||
if (attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
return res.status(429).json({ detail: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
|
||||
}
|
||||
|
||||
const user = await userModel.findByUsername(username);
|
||||
if (!user) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ detail: 'Incorrect username or password' });
|
||||
}
|
||||
|
||||
// 협력업체 계정 만료일 체크
|
||||
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
|
||||
return res.status(401).json({ detail: '계정이 만료되었습니다' });
|
||||
}
|
||||
|
||||
const valid = await userModel.verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
await redis.incr(attemptKey);
|
||||
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
||||
return res.status(401).json({ detail: 'Incorrect username or password' });
|
||||
}
|
||||
|
||||
// 로그인 성공 시 시도 횟수 초기화
|
||||
await redis.del(attemptKey);
|
||||
|
||||
await userModel.updateLastLogin(user.user_id);
|
||||
|
||||
const payload = createTokenPayload(user);
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||
|
||||
res.json({
|
||||
access_token,
|
||||
@@ -145,7 +187,8 @@ async function validate(req, res, next) {
|
||||
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
// TODO: issuer/audience 클레임 검증 추가 검토
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
||||
@@ -187,7 +230,7 @@ async function me(req, res, next) {
|
||||
return res.status(401).json({ detail: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ detail: 'User not found or inactive' });
|
||||
@@ -219,7 +262,7 @@ async function refresh(req, res, next) {
|
||||
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
|
||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||
if (decoded.type !== 'refresh') {
|
||||
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
||||
}
|
||||
@@ -230,11 +273,11 @@ async function refresh(req, res, next) {
|
||||
}
|
||||
|
||||
const payload = createTokenPayload(user);
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||
const new_refresh_token = jwt.sign(
|
||||
{ user_id: user.user_id, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -323,6 +366,65 @@ async function deleteUser(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/change-password — 본인 비밀번호 변경
|
||||
*/
|
||||
async function changePassword(req, res, next) {
|
||||
try {
|
||||
const token = extractToken(req);
|
||||
if (!token) return res.status(401).json({ success: false, message: '인증이 필요합니다' });
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const userId = decoded.user_id || decoded.id;
|
||||
const user = await userModel.findById(userId);
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ success: false, message: '유효하지 않은 사용자입니다' });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ success: false, message: '현재 비밀번호와 새 비밀번호를 모두 입력해주세요' });
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, message: '새 비밀번호는 6자 이상이어야 합니다' });
|
||||
}
|
||||
if (currentPassword === newPassword) {
|
||||
return res.status(400).json({ success: false, message: '새 비밀번호는 현재 비밀번호와 달라야 합니다' });
|
||||
}
|
||||
|
||||
const isValid = await userModel.verifyPassword(currentPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
return res.status(400).json({ success: false, message: '현재 비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
await userModel.update(userId, { password: newPassword });
|
||||
res.json({ success: true, message: '비밀번호가 변경되었습니다. 다시 로그인해주세요.' });
|
||||
} catch (err) {
|
||||
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ success: false, message: '인증이 만료되었습니다' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/check-password-strength — 비밀번호 강도 체크
|
||||
*/
|
||||
async function checkPasswordStrength(req, res) {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.json({ success: true, data: { score: 0, level: 'weak' } });
|
||||
|
||||
let score = 0;
|
||||
if (password.length >= 6) score++;
|
||||
if (password.length >= 8) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
|
||||
const level = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong';
|
||||
res.json({ success: true, data: { score, level } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
||||
*/
|
||||
@@ -345,5 +447,7 @@ module.exports = {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
deleteUser,
|
||||
changePassword,
|
||||
checkPasswordStrength
|
||||
};
|
||||
|
||||
@@ -10,12 +10,29 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const { initRedis } = require('./utils/redis');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const allowedOrigins = [
|
||||
'https://tkfb.technicalkorea.net',
|
||||
'https://tkreport.technicalkorea.net',
|
||||
'https://tkqc.technicalkorea.net',
|
||||
'https://tkuser.technicalkorea.net',
|
||||
'https://tkpurchase.technicalkorea.net',
|
||||
'https://tksafety.technicalkorea.net',
|
||||
'https://tksupport.technicalkorea.net',
|
||||
'https://tkfb.technicalkorea.net',
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
||||
}
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
origin: function(origin, cb) {
|
||||
if (!origin || allowedOrigins.includes(origin) || /^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
||||
cb(null, false);
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
@@ -42,7 +59,8 @@ app.use((err, req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
await initRedis();
|
||||
console.log(`SSO Auth Service running on port ${PORT}`);
|
||||
});
|
||||
|
||||
|
||||
1246
sso-auth-service/package-lock.json
generated
Normal file
1246
sso-auth-service/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"mysql2": "^3.14.1"
|
||||
"mysql2": "^3.14.1",
|
||||
"redis": "^4.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
85
sso-auth-service/utils/redis.js
Normal file
85
sso-auth-service/utils/redis.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Redis 클라이언트 (로그인 시도 제한, 토큰 블랙리스트)
|
||||
*
|
||||
* Redis 미연결 시 메모리 폴백으로 동작
|
||||
*/
|
||||
|
||||
const { createClient } = require('redis');
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || 'redis';
|
||||
const REDIS_PORT = process.env.REDIS_PORT || 6379;
|
||||
|
||||
let client = null;
|
||||
let connected = false;
|
||||
|
||||
// 메모리 폴백 (Redis 미연결 시)
|
||||
const memoryStore = new Map();
|
||||
|
||||
async function initRedis() {
|
||||
try {
|
||||
client = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}` });
|
||||
client.on('error', () => { connected = false; });
|
||||
client.on('connect', () => { connected = true; });
|
||||
await client.connect();
|
||||
console.log('Redis 연결 성공');
|
||||
} catch {
|
||||
console.warn('Redis 연결 실패 - 메모리 폴백 사용');
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
if (connected) {
|
||||
return await client.get(key);
|
||||
}
|
||||
const entry = memoryStore.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiry && entry.expiry < Date.now()) {
|
||||
memoryStore.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
async function set(key, value, ttlSeconds) {
|
||||
if (connected) {
|
||||
await client.set(key, value, { EX: ttlSeconds });
|
||||
} else {
|
||||
memoryStore.set(key, {
|
||||
value,
|
||||
expiry: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function del(key) {
|
||||
if (connected) {
|
||||
await client.del(key);
|
||||
} else {
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function incr(key) {
|
||||
if (connected) {
|
||||
return await client.incr(key);
|
||||
}
|
||||
const entry = memoryStore.get(key);
|
||||
const current = entry ? parseInt(entry.value) || 0 : 0;
|
||||
const next = current + 1;
|
||||
memoryStore.set(key, { value: String(next), expiry: entry?.expiry || null });
|
||||
return next;
|
||||
}
|
||||
|
||||
async function expire(key, ttlSeconds) {
|
||||
if (connected) {
|
||||
await client.expire(key, ttlSeconds);
|
||||
} else {
|
||||
const entry = memoryStore.get(key);
|
||||
if (entry) {
|
||||
entry.expiry = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initRedis, get, set, del, incr, expire };
|
||||
@@ -1,33 +1,30 @@
|
||||
# Node.js 공식 이미지 사용
|
||||
FROM node:18-alpine
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# 패키지 파일 복사 (캐싱 최적화)
|
||||
COPY package*.json ./
|
||||
# shared 모듈 복사
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# 프로덕션 의존성만 설치
|
||||
RUN npm install --omit=dev
|
||||
# 패키지 파일 복사 + 프로덕션 의존성 설치 (sharp용 빌드 도구 포함)
|
||||
COPY system1-factory/api/package*.json ./
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
||||
npm install --omit=dev && \
|
||||
npm install sharp && \
|
||||
apk del .build-deps
|
||||
|
||||
# 앱 소스 복사
|
||||
COPY . .
|
||||
COPY system1-factory/api/ ./
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
RUN mkdir -p logs uploads
|
||||
# shared 모듈 심링크 (routes에서 ../../../shared/ 경로 호환)
|
||||
RUN ln -s /usr/src/app/shared /usr/src/shared && ln -s /usr/src/app/shared /usr/shared
|
||||
|
||||
# 실행 권한 설정
|
||||
# 로그/업로드 디렉토리 생성
|
||||
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
||||
RUN chown -R node:node /usr/src/app
|
||||
|
||||
# 보안을 위해 non-root 사용자로 실행
|
||||
USER node
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3005
|
||||
|
||||
# 헬스체크 추가
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||
|
||||
# 앱 시작
|
||||
CMD ["node", "index.js"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -13,9 +13,14 @@ const logger = require('../utils/logger');
|
||||
* 허용된 Origin 목록
|
||||
*/
|
||||
const allowedOrigins = [
|
||||
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
|
||||
'https://tkreport.technicalkorea.net', // System 2
|
||||
'https://tkqc.technicalkorea.net', // System 3
|
||||
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
||||
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
|
||||
'https://tkreport.technicalkorea.net', // System 2
|
||||
'https://tkqc.technicalkorea.net', // System 3
|
||||
'https://tkuser.technicalkorea.net', // User Management
|
||||
'https://tkpurchase.technicalkorea.net', // Purchase Management
|
||||
'https://tksafety.technicalkorea.net', // Safety Management
|
||||
'https://tksupport.technicalkorea.net', // Support Management
|
||||
'http://localhost:20000', // 웹 UI (로컬)
|
||||
'http://localhost:30080', // 웹 UI (Docker)
|
||||
'http://localhost:3005', // API 서버
|
||||
@@ -45,6 +50,12 @@ const corsOptions = {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// *.technicalkorea.net 서브도메인 허용 (인앱 브라우저 대응)
|
||||
if (/^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin)) {
|
||||
logger.debug('CORS: technicalkorea.net 서브도메인 허용', { origin });
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// 개발 환경에서는 모든 localhost 허용
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||
@@ -59,9 +70,9 @@ const corsOptions = {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// 차단
|
||||
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
|
||||
logger.warn('CORS: 차단된 Origin', { origin });
|
||||
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
|
||||
callback(null, false);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -77,7 +88,7 @@ const corsOptions = {
|
||||
/**
|
||||
* 허용된 헤더
|
||||
*/
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Internal-Service-Key'],
|
||||
|
||||
/**
|
||||
* 노출할 헤더
|
||||
|
||||
@@ -64,12 +64,7 @@ function setupMiddlewares(app) {
|
||||
code: 'RATE_LIMIT_EXCEEDED'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
// 인증된 사용자는 더 많은 요청 허용
|
||||
skip: (req) => {
|
||||
// Authorization 헤더가 있으면 Rate Limit 완화
|
||||
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
|
||||
}
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
// 로그인 시도 제한 (브루트포스 방지)
|
||||
|
||||
@@ -47,12 +47,20 @@ function setupRoutes(app) {
|
||||
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
|
||||
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
||||
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
||||
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
||||
const workIssueRoutes = require('../routes/workIssueRoutes');
|
||||
const departmentRoutes = require('../routes/departmentRoutes');
|
||||
const patrolRoutes = require('../routes/patrolRoutes');
|
||||
const notificationRoutes = require('../routes/notificationRoutes');
|
||||
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
|
||||
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
|
||||
const purchaseRoutes = require('../routes/purchaseRoutes');
|
||||
const settlementRoutes = require('../routes/settlementRoutes');
|
||||
const itemAliasRoutes = require('../routes/itemAliasRoutes');
|
||||
const purchaseBatchRoutes = require('../routes/purchaseBatchRoutes');
|
||||
const consumableCategoryRoutes = require('../routes/consumableCategoryRoutes');
|
||||
const scheduleRoutes = require('../routes/scheduleRoutes');
|
||||
const meetingRoutes = require('../routes/meetingRoutes');
|
||||
const proxyInputRoutes = require('../routes/proxyInputRoutes');
|
||||
const monthlyComparisonRoutes = require('../routes/monthlyComparisonRoutes');
|
||||
const dashboardRoutes = require('../routes/dashboardRoutes');
|
||||
|
||||
// Rate Limiters 설정
|
||||
const rateLimit = require('express-rate-limit');
|
||||
@@ -107,7 +115,7 @@ function setupRoutes(app) {
|
||||
'/api/setup/migrate-existing-data',
|
||||
'/api/setup/check-data-status',
|
||||
'/api/monthly-status/calendar',
|
||||
'/api/monthly-status/daily-details'
|
||||
'/api/monthly-status/daily-details',
|
||||
];
|
||||
|
||||
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
|
||||
@@ -154,13 +162,21 @@ function setupRoutes(app) {
|
||||
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
|
||||
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||
app.use('/api/notifications', notificationRoutes); // 알림 시스템
|
||||
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
|
||||
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
|
||||
app.use('/api/purchases', purchaseRoutes); // 구매 내역
|
||||
app.use('/api/settlements', settlementRoutes); // 월간 정산
|
||||
app.use('/api/item-aliases', itemAliasRoutes); // 품목 별칭
|
||||
app.use('/api/purchase-batches', purchaseBatchRoutes); // 구매 그룹
|
||||
app.use('/api/consumable-categories', consumableCategoryRoutes); // 소모품 카테고리
|
||||
app.use('/api/schedule', scheduleRoutes); // 공정표
|
||||
app.use('/api/meetings', meetingRoutes); // 생산회의록
|
||||
app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황
|
||||
app.use('/api/monthly-comparison', monthlyComparisonRoutes); // 월간 비교·확인·정산
|
||||
app.use('/api/dashboard', dashboardRoutes); // 대시보드 개인 요약
|
||||
app.use('/api', uploadBgRoutes);
|
||||
|
||||
// Swagger API 문서
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -33,7 +33,7 @@ const login = asyncHandler(async (req, res) => {
|
||||
// ✅ 사용자 등록 기능 추가
|
||||
const register = async (req, res) => {
|
||||
try {
|
||||
const { username, password, name, access_level, worker_id } = req.body;
|
||||
const { username, password, name, access_level } = req.body;
|
||||
const db = await getDb();
|
||||
|
||||
// 필수 필드 검증
|
||||
@@ -72,9 +72,9 @@ const register = async (req, res) => {
|
||||
|
||||
// 사용자 등록
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO users (username, password, name, role, access_level, worker_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[username, hashedPassword, name, role, access_level, worker_id]
|
||||
`INSERT INTO users (username, password, name, role, access_level)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[username, hashedPassword, name, role, access_level]
|
||||
);
|
||||
|
||||
console.log('[사용자 등록 성공]', username);
|
||||
@@ -141,8 +141,8 @@ const getAllUsers = async (req, res) => {
|
||||
|
||||
// 비밀번호 제외하고 조회
|
||||
const [rows] = await db.query(
|
||||
`SELECT user_id, username, name, role, access_level, worker_id, created_at
|
||||
FROM users
|
||||
`SELECT user_id, username, name, role, access_level, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC`
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -135,7 +135,7 @@ const getDailyWorkReports = async (req, res) => {
|
||||
try {
|
||||
const userInfo = {
|
||||
user_id: req.user?.user_id || req.user?.id,
|
||||
role: req.user?.role || 'user'
|
||||
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
|
||||
};
|
||||
|
||||
if (!userInfo.user_id) {
|
||||
@@ -303,7 +303,7 @@ const updateWorkReport = async (req, res) => {
|
||||
const updateData = req.body;
|
||||
const userInfo = {
|
||||
user_id: req.user?.user_id || req.user?.id,
|
||||
role: req.user?.role || 'user'
|
||||
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
|
||||
};
|
||||
|
||||
if (!userInfo.user_id) {
|
||||
|
||||
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;
|
||||
@@ -243,21 +243,20 @@ exports.getAllUsers = asyncHandler(async (req, res) => {
|
||||
const db = await getDb();
|
||||
|
||||
const [users] = await db.query(`
|
||||
SELECT
|
||||
SELECT
|
||||
user_id,
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
access_level,
|
||||
worker_id,
|
||||
is_active,
|
||||
last_login_at,
|
||||
failed_login_attempts,
|
||||
locked_until,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
@@ -272,20 +271,20 @@ exports.getAllUsers = asyncHandler(async (req, res) => {
|
||||
* 사용자 생성
|
||||
*/
|
||||
exports.createUser = asyncHandler(async (req, res) => {
|
||||
const { username, password, name, email, role, access_level, worker_id } = req.body;
|
||||
|
||||
const { username, password, name, email, role, access_level } = req.body;
|
||||
|
||||
// 스키마 기반 유효성 검사
|
||||
validateSchema(req.body, schemas.createUser);
|
||||
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
|
||||
// 사용자명 중복 확인
|
||||
const [existing] = await db.query('SELECT user_id FROM users WHERE username = ?', [username]);
|
||||
if (existing.length > 0) {
|
||||
throw new ApiError('이미 존재하는 사용자명입니다.', 409);
|
||||
}
|
||||
|
||||
|
||||
// 이메일 중복 확인 (이메일이 제공된 경우)
|
||||
if (email) {
|
||||
const [existingEmail] = await db.query('SELECT user_id FROM users WHERE email = ?', [email]);
|
||||
@@ -293,15 +292,15 @@ exports.createUser = asyncHandler(async (req, res) => {
|
||||
throw new ApiError('이미 사용 중인 이메일입니다.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 비밀번호 해시화
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
|
||||
// 사용자 생성
|
||||
const [result] = await db.query(`
|
||||
INSERT INTO users (username, password, name, email, role, access_level, worker_id, is_active, created_at, password_changed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW())
|
||||
`, [username, hashedPassword, name, email || null, role, access_level || role, worker_id || null]);
|
||||
INSERT INTO users (username, password, name, email, role, access_level, is_active, created_at, password_changed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, NOW(), NOW())
|
||||
`, [username, hashedPassword, name, email || null, role, access_level || role]);
|
||||
|
||||
// 비밀번호 변경 로그 기록
|
||||
await db.query(`
|
||||
@@ -322,9 +321,9 @@ exports.createUser = asyncHandler(async (req, res) => {
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, email, role, access_level, is_active, worker_id } = req.body;
|
||||
const { name, email, role, access_level, is_active } = req.body;
|
||||
const db = await getDb();
|
||||
|
||||
|
||||
// 사용자 존재 확인
|
||||
const [user] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [id]);
|
||||
if (user.length === 0) {
|
||||
@@ -333,11 +332,11 @@ exports.updateUser = async (req, res) => {
|
||||
error: '해당 사용자를 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 이메일 중복 확인 (다른 사용자가 사용 중인지)
|
||||
if (email) {
|
||||
const [existingEmail] = await db.query(
|
||||
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
|
||||
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
|
||||
[email, id]
|
||||
);
|
||||
if (existingEmail.length > 0) {
|
||||
@@ -347,13 +346,13 @@ exports.updateUser = async (req, res) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
await db.query(`
|
||||
UPDATE users
|
||||
SET name = ?, email = ?, role = ?, access_level = ?, is_active = ?, worker_id = ?, updated_at = NOW()
|
||||
UPDATE users
|
||||
SET name = ?, email = ?, role = ?, access_level = ?, is_active = ?, updated_at = NOW()
|
||||
WHERE user_id = ?
|
||||
`, [name, email || null, role, access_level || role, is_active ? 1 : 0, worker_id || null, id]);
|
||||
`, [name, email || null, role, access_level || role, is_active ? 1 : 0, id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -194,6 +194,32 @@ const TbmController = {
|
||||
return res.status(400).json({ success: false, message: '팀원 목록이 필요합니다.' });
|
||||
}
|
||||
|
||||
// 중복 배정 검증
|
||||
const sessionRows = await TbmModel.getSessionById(sessionId);
|
||||
if (sessionRows.length > 0) {
|
||||
const sessionDate = sessionRows[0].session_date;
|
||||
let dateStr;
|
||||
if (sessionDate instanceof Date) {
|
||||
dateStr = sessionDate.toISOString().split('T')[0];
|
||||
} else if (typeof sessionDate === 'string') {
|
||||
dateStr = sessionDate.split('T')[0];
|
||||
} else {
|
||||
dateStr = new Date(sessionDate).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const userIds = members.map(m => m.user_id);
|
||||
const duplicates = await TbmModel.checkDuplicateAssignments(dateStr, userIds, sessionId);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
const names = duplicates.map(d => d.worker_name).join(', ');
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `다음 작업자가 이미 다른 TBM에 배정되어 있습니다: ${names}`,
|
||||
duplicates: duplicates
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await TbmModel.addTeamMembers(sessionId, members);
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -43,7 +43,6 @@ const getAllUsers = asyncHandler(async (req, res) => {
|
||||
r.name as role,
|
||||
u._access_level_old as access_level,
|
||||
u.is_active,
|
||||
u.worker_id,
|
||||
w.worker_name,
|
||||
w.department_id,
|
||||
d.department_name,
|
||||
@@ -52,7 +51,7 @@ const getAllUsers = asyncHandler(async (req, res) => {
|
||||
u.last_login_at as last_login
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
LEFT JOIN workers w ON u.worker_id = w.worker_id
|
||||
LEFT JOIN workers w ON w.user_id = u.user_id
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
ORDER BY u.created_at DESC
|
||||
`;
|
||||
@@ -224,7 +223,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
checkAdminPermission(req.user);
|
||||
|
||||
const { id } = req.params;
|
||||
const { username, name, email, role, role_id, password, worker_id } = req.body;
|
||||
const { username, name, email, role, role_id, password } = req.body;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
@@ -233,7 +232,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
logger.info('사용자 수정 요청', { userId: id, body: req.body });
|
||||
|
||||
// 최소 하나의 수정 필드가 필요
|
||||
if (!username && !name && email === undefined && !role && !role_id && !password && worker_id === undefined) {
|
||||
if (!username && !name && email === undefined && !role && !role_id && !password) {
|
||||
throw new ValidationError('수정할 필드가 없습니다');
|
||||
}
|
||||
|
||||
@@ -324,22 +323,6 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
values.push(hashedPassword);
|
||||
}
|
||||
|
||||
// worker_id 업데이트 (null도 허용 - 연결 해제)
|
||||
if (worker_id !== undefined) {
|
||||
if (worker_id !== null) {
|
||||
// worker_id가 유효한지 확인
|
||||
const [workerCheck] = await db.execute('SELECT worker_id, worker_name FROM workers WHERE worker_id = ?', [worker_id]);
|
||||
if (workerCheck.length === 0) {
|
||||
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||
}
|
||||
logger.info('작업자 연결', { userId: id, worker_id, worker_name: workerCheck[0].worker_name });
|
||||
} else {
|
||||
logger.info('작업자 연결 해제', { userId: id });
|
||||
}
|
||||
updates.push('worker_id = ?');
|
||||
values.push(worker_id);
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
@@ -699,8 +682,7 @@ const resetUserPassword = asyncHandler(async (req, res) => {
|
||||
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 비밀번호를 000000으로 초기화
|
||||
const hashedPassword = await bcrypt.hash('000000', 10);
|
||||
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
|
||||
await db.execute(
|
||||
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
|
||||
[hashedPassword, id]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,37 +21,32 @@ const getAnalysisFilters = asyncHandler(async (req, res) => {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
// 프로젝트 목록
|
||||
const [projects] = await db.query(`
|
||||
SELECT DISTINCT p.project_id, p.project_name
|
||||
FROM projects p
|
||||
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
|
||||
ORDER BY p.project_name
|
||||
`);
|
||||
|
||||
// 작업자 목록
|
||||
const [workers] = await db.query(`
|
||||
SELECT DISTINCT w.user_id, w.worker_name
|
||||
FROM workers w
|
||||
INNER JOIN daily_work_reports dwr ON w.user_id = dwr.user_id
|
||||
ORDER BY w.worker_name
|
||||
`);
|
||||
|
||||
// 작업 유형 목록
|
||||
const [workTypes] = await db.query(`
|
||||
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
|
||||
FROM work_types wt
|
||||
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
|
||||
ORDER BY wt.name
|
||||
`);
|
||||
|
||||
// 날짜 범위
|
||||
const [dateRange] = await db.query(`
|
||||
SELECT
|
||||
MIN(report_date) as min_date,
|
||||
MAX(report_date) as max_date
|
||||
FROM daily_work_reports
|
||||
`);
|
||||
const [[projects], [workers], [workTypes], [dateRange]] = await Promise.all([
|
||||
db.query(`
|
||||
SELECT DISTINCT p.project_id, p.project_name
|
||||
FROM projects p
|
||||
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
|
||||
ORDER BY p.project_name
|
||||
`),
|
||||
db.query(`
|
||||
SELECT DISTINCT w.user_id, w.worker_name
|
||||
FROM workers w
|
||||
INNER JOIN daily_work_reports dwr ON w.user_id = dwr.user_id
|
||||
ORDER BY w.worker_name
|
||||
`),
|
||||
db.query(`
|
||||
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
|
||||
FROM work_types wt
|
||||
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
|
||||
ORDER BY wt.name
|
||||
`),
|
||||
db.query(`
|
||||
SELECT
|
||||
MIN(report_date) as min_date,
|
||||
MAX(report_date) as max_date
|
||||
FROM daily_work_reports
|
||||
`),
|
||||
]);
|
||||
|
||||
logger.info('분석 필터 데이터 조회 성공', {
|
||||
projects: projects.length,
|
||||
@@ -131,115 +126,108 @@ const getAnalyticsByPeriod = asyncHandler(async (req, res) => {
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [overallStats] = await db.query(overallSql, queryParams);
|
||||
|
||||
// 2. 일별 통계
|
||||
const dailyStatsSql = `
|
||||
SELECT
|
||||
dwr.report_date,
|
||||
SUM(dwr.work_hours) as daily_hours,
|
||||
COUNT(*) as daily_entries,
|
||||
COUNT(DISTINCT dwr.user_id) as daily_workers
|
||||
FROM daily_work_reports dwr
|
||||
WHERE ${whereClause}
|
||||
GROUP BY dwr.report_date
|
||||
ORDER BY dwr.report_date ASC
|
||||
`;
|
||||
|
||||
const [dailyStats] = await db.query(dailyStatsSql, queryParams);
|
||||
|
||||
// 3. 일별 에러 통계
|
||||
const dailyErrorStatsSql = `
|
||||
SELECT
|
||||
dwr.report_date,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
|
||||
COUNT(*) as daily_total,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as daily_error_rate
|
||||
FROM daily_work_reports dwr
|
||||
WHERE ${whereClause}
|
||||
GROUP BY dwr.report_date
|
||||
ORDER BY dwr.report_date ASC
|
||||
`;
|
||||
|
||||
const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams);
|
||||
|
||||
// 4. 에러 유형별 분석
|
||||
const errorAnalysisSql = `
|
||||
SELECT
|
||||
et.id as error_type_id,
|
||||
et.name as error_type_name,
|
||||
COUNT(*) as error_count,
|
||||
SUM(dwr.work_hours) as error_hours,
|
||||
ROUND((COUNT(*) / (SELECT COUNT(*) FROM daily_work_reports WHERE error_type_id IS NOT NULL)) * 100, 2) as error_percentage
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||
WHERE ${whereClause} AND dwr.error_type_id IS NOT NULL
|
||||
GROUP BY et.id, et.name
|
||||
ORDER BY error_count DESC
|
||||
`;
|
||||
|
||||
const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams);
|
||||
|
||||
// 5. 작업 유형별 분석
|
||||
const workTypeAnalysisSql = `
|
||||
SELECT
|
||||
wt.id as work_type_id,
|
||||
wt.name as work_type_name,
|
||||
COUNT(*) as work_count,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
AVG(dwr.work_hours) as avg_hours,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY wt.id, wt.name
|
||||
ORDER BY total_hours DESC
|
||||
`;
|
||||
|
||||
const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams);
|
||||
|
||||
// 6. 작업자별 성과 분석
|
||||
const workerAnalysisSql = `
|
||||
SELECT
|
||||
w.user_id,
|
||||
w.worker_name,
|
||||
COUNT(*) as total_entries,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
AVG(dwr.work_hours) as avg_hours_per_entry,
|
||||
COUNT(DISTINCT dwr.project_id) as projects_worked,
|
||||
COUNT(DISTINCT dwr.report_date) as working_days,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN workers w ON dwr.user_id = w.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY w.user_id, w.worker_name
|
||||
ORDER BY total_hours DESC
|
||||
`;
|
||||
|
||||
const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams);
|
||||
|
||||
// 7. 프로젝트별 분석
|
||||
const projectAnalysisSql = `
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
COUNT(*) as total_entries,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
COUNT(DISTINCT dwr.user_id) as workers_count,
|
||||
COUNT(DISTINCT dwr.report_date) as working_days,
|
||||
AVG(dwr.work_hours) as avg_hours_per_entry,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY p.project_id, p.project_name
|
||||
ORDER BY total_hours DESC
|
||||
`;
|
||||
|
||||
const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams);
|
||||
const [
|
||||
[overallStats],
|
||||
[dailyStats],
|
||||
[dailyErrorStats],
|
||||
[errorAnalysis],
|
||||
[workTypeAnalysis],
|
||||
[workerAnalysis],
|
||||
[projectAnalysis],
|
||||
] = await Promise.all([
|
||||
// 1. 전체 요약 통계
|
||||
db.query(overallSql, queryParams),
|
||||
// 2. 일별 통계
|
||||
db.query(`
|
||||
SELECT
|
||||
dwr.report_date,
|
||||
SUM(dwr.work_hours) as daily_hours,
|
||||
COUNT(*) as daily_entries,
|
||||
COUNT(DISTINCT dwr.user_id) as daily_workers
|
||||
FROM daily_work_reports dwr
|
||||
WHERE ${whereClause}
|
||||
GROUP BY dwr.report_date
|
||||
ORDER BY dwr.report_date ASC
|
||||
`, queryParams),
|
||||
// 3. 일별 에러 통계
|
||||
db.query(`
|
||||
SELECT
|
||||
dwr.report_date,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
|
||||
COUNT(*) as daily_total,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as daily_error_rate
|
||||
FROM daily_work_reports dwr
|
||||
WHERE ${whereClause}
|
||||
GROUP BY dwr.report_date
|
||||
ORDER BY dwr.report_date ASC
|
||||
`, queryParams),
|
||||
// 4. 에러 유형별 분석
|
||||
db.query(`
|
||||
SELECT
|
||||
et.id as error_type_id,
|
||||
et.name as error_type_name,
|
||||
COUNT(*) as error_count,
|
||||
SUM(dwr.work_hours) as error_hours,
|
||||
ROUND((COUNT(*) / (SELECT COUNT(*) FROM daily_work_reports WHERE error_type_id IS NOT NULL)) * 100, 2) as error_percentage
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||
WHERE ${whereClause} AND dwr.error_type_id IS NOT NULL
|
||||
GROUP BY et.id, et.name
|
||||
ORDER BY error_count DESC
|
||||
`, queryParams),
|
||||
// 5. 작업 유형별 분석
|
||||
db.query(`
|
||||
SELECT
|
||||
wt.id as work_type_id,
|
||||
wt.name as work_type_name,
|
||||
COUNT(*) as work_count,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
AVG(dwr.work_hours) as avg_hours,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY wt.id, wt.name
|
||||
ORDER BY total_hours DESC
|
||||
`, queryParams),
|
||||
// 6. 작업자별 성과 분석
|
||||
db.query(`
|
||||
SELECT
|
||||
w.user_id,
|
||||
w.worker_name,
|
||||
COUNT(*) as total_entries,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
AVG(dwr.work_hours) as avg_hours_per_entry,
|
||||
COUNT(DISTINCT dwr.project_id) as projects_worked,
|
||||
COUNT(DISTINCT dwr.report_date) as working_days,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN workers w ON dwr.user_id = w.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY w.user_id, w.worker_name
|
||||
ORDER BY total_hours DESC
|
||||
`, queryParams),
|
||||
// 7. 프로젝트별 분석
|
||||
db.query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
COUNT(*) as total_entries,
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
COUNT(DISTINCT dwr.user_id) as workers_count,
|
||||
COUNT(DISTINCT dwr.report_date) as working_days,
|
||||
AVG(dwr.work_hours) as avg_hours_per_entry,
|
||||
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
|
||||
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY p.project_id, p.project_name
|
||||
ORDER BY total_hours DESC
|
||||
`, queryParams),
|
||||
]);
|
||||
|
||||
logger.info('기간별 분석 데이터 조회 성공', {
|
||||
start_date,
|
||||
|
||||
@@ -28,24 +28,28 @@ exports.createWorker = asyncHandler(async (req, res) => {
|
||||
|
||||
const lastID = await workerModel.create(workerData);
|
||||
|
||||
// 계정 생성 요청이 있으면 users 테이블에 계정 생성
|
||||
// 계정 생성 요청이 있으면 users 테이블에 계정 생성 + workers.user_id 연결
|
||||
if (createAccount && workerData.worker_name) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const username = await generateUniqueUsername(workerData.worker_name, db);
|
||||
const hashedPassword = await bcrypt.hash('1234', 10);
|
||||
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
|
||||
|
||||
// User 역할 조회
|
||||
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
|
||||
|
||||
if (userRole && userRole.length > 0) {
|
||||
await db.query(
|
||||
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[username, hashedPassword, workerData.worker_name, lastID, userRole[0].id]
|
||||
const [insertResult] = await db.query(
|
||||
`INSERT INTO users (username, password, name, role_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())`,
|
||||
[username, hashedPassword, workerData.worker_name, userRole[0].id]
|
||||
);
|
||||
|
||||
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, username });
|
||||
// workers.user_id 연결
|
||||
const newUserId = insertResult.insertId;
|
||||
await db.query('UPDATE workers SET user_id = ? WHERE worker_id = ?', [newUserId, lastID]);
|
||||
|
||||
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, user_id: newUserId, username });
|
||||
}
|
||||
} catch (accountError) {
|
||||
logger.error('계정 생성 실패 (작업자는 생성됨)', { worker_id: lastID, error: accountError.message });
|
||||
@@ -109,7 +113,7 @@ exports.getWorkerById = asyncHandler(async (req, res) => {
|
||||
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||
}
|
||||
|
||||
const row = await workerModel.getById(id);
|
||||
const row = await workerModel.getByUserId(id);
|
||||
|
||||
if (!row) {
|
||||
throw new NotFoundError('작업자를 찾을 수 없습니다');
|
||||
@@ -132,18 +136,11 @@ exports.updateWorker = asyncHandler(async (req, res) => {
|
||||
throw new ValidationError('유효하지 않은 작업자 ID입니다');
|
||||
}
|
||||
|
||||
const workerData = { ...req.body, worker_id: id };
|
||||
const workerData = { ...req.body, user_id: id };
|
||||
const createAccount = req.body.create_account;
|
||||
|
||||
console.log('🔧 작업자 수정 요청:', {
|
||||
worker_id: id,
|
||||
받은데이터: req.body,
|
||||
처리할데이터: workerData,
|
||||
create_account: createAccount
|
||||
});
|
||||
|
||||
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
|
||||
const currentWorker = await workerModel.getById(id);
|
||||
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용, user_id 기준)
|
||||
const currentWorker = await workerModel.getByUserId(id);
|
||||
|
||||
if (!currentWorker) {
|
||||
throw new NotFoundError('작업자를 찾을 수 없습니다');
|
||||
@@ -158,61 +155,43 @@ exports.updateWorker = asyncHandler(async (req, res) => {
|
||||
let accountAction = null;
|
||||
let accountUsername = null;
|
||||
|
||||
console.log('🔍 계정 생성 체크:', {
|
||||
createAccount,
|
||||
hasAccount,
|
||||
currentWorker_user_id: currentWorker.user_id,
|
||||
worker_name: workerData.worker_name
|
||||
});
|
||||
|
||||
if (createAccount && !hasAccount && workerData.worker_name) {
|
||||
// 계정 생성
|
||||
console.log('✅ 계정 생성 로직 시작');
|
||||
try {
|
||||
console.log('🔑 사용자명 생성 중...');
|
||||
const username = await generateUniqueUsername(workerData.worker_name, db);
|
||||
console.log('🔑 생성된 사용자명:', username);
|
||||
const hashedPassword = await bcrypt.hash(process.env.DEFAULT_PASSWORD || 'changeme!1', 10);
|
||||
|
||||
const hashedPassword = await bcrypt.hash('1234', 10);
|
||||
console.log('🔒 비밀번호 해싱 완료');
|
||||
|
||||
// User 역할 조회
|
||||
console.log('👤 User 역할 조회 중...');
|
||||
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
|
||||
console.log('👤 User 역할 조회 결과:', userRole);
|
||||
|
||||
if (userRole && userRole.length > 0) {
|
||||
console.log('💾 계정 DB 삽입 시작...');
|
||||
await db.query(
|
||||
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
|
||||
const [insertResult] = await db.query(
|
||||
`INSERT INTO users (username, password, name, role_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())`,
|
||||
[username, hashedPassword, workerData.worker_name, userRole[0].id]
|
||||
);
|
||||
console.log('✅ 계정 DB 삽입 완료');
|
||||
|
||||
// workers.user_id 연결
|
||||
const newUserId = insertResult.insertId;
|
||||
await db.query('UPDATE workers SET user_id = ? WHERE user_id = ?', [newUserId, id]);
|
||||
|
||||
accountAction = 'created';
|
||||
accountUsername = username;
|
||||
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
|
||||
} else {
|
||||
console.log('❌ User 역할을 찾을 수 없음');
|
||||
logger.info('작업자 계정 생성 성공', { user_id: id, new_user_id: newUserId, username });
|
||||
}
|
||||
} catch (accountError) {
|
||||
console.error('❌ 계정 생성 오류:', accountError);
|
||||
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
|
||||
logger.error('계정 생성 실패', { user_id: id, error: accountError.message });
|
||||
accountAction = 'failed';
|
||||
}
|
||||
} else {
|
||||
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
|
||||
}
|
||||
|
||||
if (!createAccount && hasAccount) {
|
||||
// 계정 연동 해제 (users.worker_id = NULL)
|
||||
// 계정 연동 해제 (workers.user_id = NULL)
|
||||
try {
|
||||
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
|
||||
await db.query('UPDATE workers SET user_id = NULL WHERE user_id = ?', [id]);
|
||||
accountAction = 'unlinked';
|
||||
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
|
||||
logger.info('작업자 계정 연동 해제 성공', { user_id: id });
|
||||
} catch (unlinkError) {
|
||||
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
|
||||
logger.error('계정 연동 해제 실패', { user_id: id, error: unlinkError.message });
|
||||
accountAction = 'unlink_failed';
|
||||
}
|
||||
} else if (createAccount && hasAccount) {
|
||||
@@ -220,10 +199,10 @@ exports.updateWorker = asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
// 작업자 관련 캐시 무효화
|
||||
logger.info('작업자 수정 후 캐시 무효화', { worker_id: id });
|
||||
logger.info('작업자 수정 후 캐시 무효화', { user_id: id });
|
||||
await cache.invalidateCache.worker();
|
||||
|
||||
logger.info('작업자 수정 성공', { worker_id: id });
|
||||
logger.info('작업자 수정 성공', { user_id: id });
|
||||
|
||||
// 응답 메시지 구성
|
||||
let message = '작업자 정보가 성공적으로 수정되었습니다';
|
||||
@@ -265,11 +244,11 @@ exports.removeWorker = asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
// 작업자 관련 캐시 무효화
|
||||
logger.info('작업자 삭제 후 캐시 무효화 시작', { worker_id: id });
|
||||
logger.info('작업자 삭제 후 캐시 무효화 시작', { user_id: id });
|
||||
await cache.invalidateCache.worker();
|
||||
await cache.delPattern('workers:*');
|
||||
await cache.flush();
|
||||
logger.info('작업자 삭제 후 캐시 무효화 완료', { worker_id: id });
|
||||
logger.info('작업자 삭제 후 캐시 무효화 완료', { user_id: id });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user